hindsight-api 0.1.11__py3-none-any.whl → 0.1.12__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 (44) 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 +234 -228
  7. hindsight_api/api/mcp.py +14 -3
  8. hindsight_api/engine/__init__.py +12 -1
  9. hindsight_api/engine/entity_resolver.py +38 -37
  10. hindsight_api/engine/interface.py +592 -0
  11. hindsight_api/engine/llm_wrapper.py +176 -6
  12. hindsight_api/engine/memory_engine.py +993 -217
  13. hindsight_api/engine/retain/bank_utils.py +13 -12
  14. hindsight_api/engine/retain/chunk_storage.py +3 -2
  15. hindsight_api/engine/retain/fact_storage.py +10 -7
  16. hindsight_api/engine/retain/link_utils.py +17 -16
  17. hindsight_api/engine/retain/observation_regeneration.py +17 -16
  18. hindsight_api/engine/retain/orchestrator.py +2 -3
  19. hindsight_api/engine/retain/types.py +25 -8
  20. hindsight_api/engine/search/graph_retrieval.py +6 -5
  21. hindsight_api/engine/search/mpfp_retrieval.py +8 -7
  22. hindsight_api/engine/search/retrieval.py +12 -11
  23. hindsight_api/engine/search/think_utils.py +1 -1
  24. hindsight_api/engine/search/tracer.py +1 -1
  25. hindsight_api/engine/task_backend.py +32 -0
  26. hindsight_api/extensions/__init__.py +66 -0
  27. hindsight_api/extensions/base.py +81 -0
  28. hindsight_api/extensions/builtin/__init__.py +18 -0
  29. hindsight_api/extensions/builtin/tenant.py +33 -0
  30. hindsight_api/extensions/context.py +110 -0
  31. hindsight_api/extensions/http.py +89 -0
  32. hindsight_api/extensions/loader.py +125 -0
  33. hindsight_api/extensions/operation_validator.py +325 -0
  34. hindsight_api/extensions/tenant.py +63 -0
  35. hindsight_api/main.py +1 -1
  36. hindsight_api/mcp_local.py +7 -1
  37. hindsight_api/migrations.py +54 -10
  38. hindsight_api/models.py +15 -0
  39. hindsight_api/pg0.py +1 -1
  40. {hindsight_api-0.1.11.dist-info → hindsight_api-0.1.12.dist-info}/METADATA +1 -1
  41. hindsight_api-0.1.12.dist-info/RECORD +74 -0
  42. hindsight_api-0.1.11.dist-info/RECORD +0 -64
  43. {hindsight_api-0.1.11.dist-info → hindsight_api-0.1.12.dist-info}/WHEEL +0 -0
  44. {hindsight_api-0.1.11.dist-info → hindsight_api-0.1.12.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]:
@@ -33,9 +33,11 @@ from pydantic import BaseModel, ConfigDict, Field
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
 
@@ -337,7 +339,7 @@ class RetainResponse(BaseModel):
337
339
  success: bool
338
340
  bank_id: str
339
341
  items_count: int
340
- async_: bool = Field(
342
+ is_async: bool = Field(
341
343
  alias="async", serialization_alias="async", description="Whether the operation was processed asynchronously"
342
344
  )
343
345
 
@@ -706,7 +708,11 @@ class DeleteResponse(BaseModel):
706
708
  deleted_count: int | None = None
707
709
 
708
710
 
709
- def create_app(memory: MemoryEngine, initialize_memory: bool = True) -> FastAPI:
711
+ def create_app(
712
+ memory: MemoryEngine,
713
+ initialize_memory: bool = True,
714
+ http_extension: HttpExtension | None = None,
715
+ ) -> FastAPI:
710
716
  """
711
717
  Create and configure the FastAPI application.
712
718
 
@@ -714,6 +720,8 @@ def create_app(memory: MemoryEngine, initialize_memory: bool = True) -> FastAPI:
714
720
  memory: MemoryEngine instance (already initialized with required parameters).
715
721
  Migrations are controlled by the MemoryEngine's run_migrations parameter.
716
722
  initialize_memory: Whether to initialize memory system on startup (default: True)
723
+ http_extension: Optional HTTP extension to mount custom endpoints under /extension/.
724
+ If None, attempts to load from HINDSIGHT_API_HTTP_EXTENSION env var.
717
725
 
718
726
  Returns:
719
727
  Configured FastAPI application
@@ -723,6 +731,11 @@ def create_app(memory: MemoryEngine, initialize_memory: bool = True) -> FastAPI:
723
731
  In that case, you should call memory.initialize() manually before starting the server
724
732
  and memory.close() when shutting down.
725
733
  """
734
+ # Load HTTP extension from environment if not provided
735
+ if http_extension is None:
736
+ http_extension = load_extension("HTTP", HttpExtension)
737
+ if http_extension:
738
+ logging.info(f"Loaded HTTP extension: {http_extension.__class__.__name__}")
726
739
 
727
740
  @asynccontextmanager
728
741
  async def lifespan(app: FastAPI):
@@ -746,8 +759,18 @@ def create_app(memory: MemoryEngine, initialize_memory: bool = True) -> FastAPI:
746
759
  await memory.initialize()
747
760
  logging.info("Memory system initialized")
748
761
 
762
+ # Call HTTP extension startup hook
763
+ if http_extension:
764
+ await http_extension.on_startup()
765
+ logging.info("HTTP extension started")
766
+
749
767
  yield
750
768
 
769
+ # Call HTTP extension shutdown hook
770
+ if http_extension:
771
+ await http_extension.on_shutdown()
772
+ logging.info("HTTP extension stopped")
773
+
751
774
  # Shutdown: Cleanup memory system
752
775
  await memory.close()
753
776
  logging.info("Memory system closed")
@@ -775,12 +798,36 @@ def create_app(memory: MemoryEngine, initialize_memory: bool = True) -> FastAPI:
775
798
  # Register all routes
776
799
  _register_routes(app)
777
800
 
801
+ # Mount HTTP extension router if available
802
+ if http_extension:
803
+ extension_router = http_extension.get_router(memory)
804
+ app.include_router(extension_router, prefix="/ext", tags=["Extension"])
805
+ logging.info("HTTP extension router mounted at /ext/")
806
+
778
807
  return app
779
808
 
780
809
 
781
810
  def _register_routes(app: FastAPI):
782
811
  """Register all API routes on the given app instance."""
783
812
 
813
+ def get_request_context(authorization: str | None = Header(default=None)) -> RequestContext:
814
+ """
815
+ Extract request context from Authorization header.
816
+
817
+ Supports:
818
+ - Bearer token: "Bearer <api_key>"
819
+ - Direct API key: "<api_key>"
820
+
821
+ Returns RequestContext with extracted API key (may be None if no auth header).
822
+ """
823
+ api_key = None
824
+ if authorization:
825
+ if authorization.lower().startswith("bearer "):
826
+ api_key = authorization[7:].strip()
827
+ else:
828
+ api_key = authorization.strip()
829
+ return RequestContext(api_key=api_key)
830
+
784
831
  @app.get(
785
832
  "/health",
786
833
  summary="Health check endpoint",
@@ -821,10 +868,12 @@ def _register_routes(app: FastAPI):
821
868
  operation_id="get_graph",
822
869
  tags=["Memory"],
823
870
  )
824
- async def api_graph(bank_id: str, type: str | None = None):
871
+ async def api_graph(
872
+ bank_id: str, type: str | None = None, request_context: RequestContext = Depends(get_request_context)
873
+ ):
825
874
  """Get graph data from database, filtered by bank_id and optionally by type."""
826
875
  try:
827
- data = await app.state.memory.get_graph_data(bank_id, type)
876
+ data = await app.state.memory.get_graph_data(bank_id, type, request_context=request_context)
828
877
  return data
829
878
  except Exception as e:
830
879
  import traceback
@@ -841,7 +890,14 @@ def _register_routes(app: FastAPI):
841
890
  operation_id="list_memories",
842
891
  tags=["Memory"],
843
892
  )
844
- async def api_list(bank_id: str, type: str | None = None, q: str | None = None, limit: int = 100, offset: int = 0):
893
+ async def api_list(
894
+ bank_id: str,
895
+ type: str | None = None,
896
+ q: str | None = None,
897
+ limit: int = 100,
898
+ offset: int = 0,
899
+ request_context: RequestContext = Depends(get_request_context),
900
+ ):
845
901
  """
846
902
  List memory units for table view with optional full-text search.
847
903
 
@@ -857,7 +913,12 @@ def _register_routes(app: FastAPI):
857
913
  """
858
914
  try:
859
915
  data = await app.state.memory.list_memory_units(
860
- bank_id=bank_id, fact_type=type, search_query=q, limit=limit, offset=offset
916
+ bank_id=bank_id,
917
+ fact_type=type,
918
+ search_query=q,
919
+ limit=limit,
920
+ offset=offset,
921
+ request_context=request_context,
861
922
  )
862
923
  return data
863
924
  except Exception as e:
@@ -880,7 +941,9 @@ def _register_routes(app: FastAPI):
880
941
  operation_id="recall_memories",
881
942
  tags=["Memory"],
882
943
  )
883
- async def api_recall(bank_id: str, request: RecallRequest):
944
+ async def api_recall(
945
+ bank_id: str, request: RecallRequest, request_context: RequestContext = Depends(get_request_context)
946
+ ):
884
947
  """Run a recall and return results with trace."""
885
948
  metrics = get_metrics_collector()
886
949
 
@@ -923,6 +986,7 @@ def _register_routes(app: FastAPI):
923
986
  max_entity_tokens=max_entity_tokens,
924
987
  include_chunks=include_chunks,
925
988
  max_chunk_tokens=max_chunk_tokens,
989
+ request_context=request_context,
926
990
  )
927
991
 
928
992
  # Convert core MemoryFact objects to API RecallResult objects (excluding internal metrics)
@@ -995,14 +1059,20 @@ def _register_routes(app: FastAPI):
995
1059
  operation_id="reflect",
996
1060
  tags=["Memory"],
997
1061
  )
998
- async def api_reflect(bank_id: str, request: ReflectRequest):
1062
+ async def api_reflect(
1063
+ bank_id: str, request: ReflectRequest, request_context: RequestContext = Depends(get_request_context)
1064
+ ):
999
1065
  metrics = get_metrics_collector()
1000
1066
 
1001
1067
  try:
1002
1068
  # Use the memory system's reflect_async method (record metrics)
1003
1069
  with metrics.record_operation("reflect", bank_id=bank_id, budget=request.budget.value):
1004
1070
  core_result = await app.state.memory.reflect_async(
1005
- bank_id=bank_id, query=request.query, budget=request.budget, context=request.context
1071
+ bank_id=bank_id,
1072
+ query=request.query,
1073
+ budget=request.budget,
1074
+ context=request.context,
1075
+ request_context=request_context,
1006
1076
  )
1007
1077
 
1008
1078
  # Convert core MemoryFact objects to API ReflectFact objects if facts are requested
@@ -1041,10 +1111,10 @@ def _register_routes(app: FastAPI):
1041
1111
  operation_id="list_banks",
1042
1112
  tags=["Banks"],
1043
1113
  )
1044
- async def api_list_banks():
1114
+ async def api_list_banks(request_context: RequestContext = Depends(get_request_context)):
1045
1115
  """Get list of all banks with their profiles."""
1046
1116
  try:
1047
- banks = await app.state.memory.list_banks()
1117
+ banks = await app.state.memory.list_banks(request_context=request_context)
1048
1118
  return BankListResponse(banks=banks)
1049
1119
  except Exception as e:
1050
1120
  import traceback
@@ -1067,9 +1137,9 @@ def _register_routes(app: FastAPI):
1067
1137
  async with acquire_with_retry(pool) as conn:
1068
1138
  # Get node counts by fact_type
1069
1139
  node_stats = await conn.fetch(
1070
- """
1140
+ f"""
1071
1141
  SELECT fact_type, COUNT(*) as count
1072
- FROM memory_units
1142
+ FROM {fq_table("memory_units")}
1073
1143
  WHERE bank_id = $1
1074
1144
  GROUP BY fact_type
1075
1145
  """,
@@ -1078,10 +1148,10 @@ def _register_routes(app: FastAPI):
1078
1148
 
1079
1149
  # Get link counts by link_type
1080
1150
  link_stats = await conn.fetch(
1081
- """
1151
+ f"""
1082
1152
  SELECT ml.link_type, COUNT(*) as count
1083
- FROM memory_links ml
1084
- JOIN memory_units mu ON ml.from_unit_id = mu.id
1153
+ FROM {fq_table("memory_links")} ml
1154
+ JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id
1085
1155
  WHERE mu.bank_id = $1
1086
1156
  GROUP BY ml.link_type
1087
1157
  """,
@@ -1090,10 +1160,10 @@ def _register_routes(app: FastAPI):
1090
1160
 
1091
1161
  # Get link counts by fact_type (from nodes)
1092
1162
  link_fact_type_stats = await conn.fetch(
1093
- """
1163
+ f"""
1094
1164
  SELECT mu.fact_type, COUNT(*) as count
1095
- FROM memory_links ml
1096
- JOIN memory_units mu ON ml.from_unit_id = mu.id
1165
+ FROM {fq_table("memory_links")} ml
1166
+ JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id
1097
1167
  WHERE mu.bank_id = $1
1098
1168
  GROUP BY mu.fact_type
1099
1169
  """,
@@ -1102,10 +1172,10 @@ def _register_routes(app: FastAPI):
1102
1172
 
1103
1173
  # Get link counts by fact_type AND link_type
1104
1174
  link_breakdown_stats = await conn.fetch(
1105
- """
1175
+ f"""
1106
1176
  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
1177
+ FROM {fq_table("memory_links")} ml
1178
+ JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id
1109
1179
  WHERE mu.bank_id = $1
1110
1180
  GROUP BY mu.fact_type, ml.link_type
1111
1181
  """,
@@ -1114,9 +1184,9 @@ def _register_routes(app: FastAPI):
1114
1184
 
1115
1185
  # Get pending and failed operations counts
1116
1186
  ops_stats = await conn.fetch(
1117
- """
1187
+ f"""
1118
1188
  SELECT status, COUNT(*) as count
1119
- FROM async_operations
1189
+ FROM {fq_table("async_operations")}
1120
1190
  WHERE bank_id = $1
1121
1191
  GROUP BY status
1122
1192
  """,
@@ -1128,9 +1198,9 @@ def _register_routes(app: FastAPI):
1128
1198
 
1129
1199
  # Get document count
1130
1200
  doc_count_result = await conn.fetchrow(
1131
- """
1201
+ f"""
1132
1202
  SELECT COUNT(*) as count
1133
- FROM documents
1203
+ FROM {fq_table("documents")}
1134
1204
  WHERE bank_id = $1
1135
1205
  """,
1136
1206
  bank_id,
@@ -1184,11 +1254,13 @@ def _register_routes(app: FastAPI):
1184
1254
  tags=["Entities"],
1185
1255
  )
1186
1256
  async def api_list_entities(
1187
- bank_id: str, limit: int = Query(default=100, description="Maximum number of entities to return")
1257
+ bank_id: str,
1258
+ limit: int = Query(default=100, description="Maximum number of entities to return"),
1259
+ request_context: RequestContext = Depends(get_request_context),
1188
1260
  ):
1189
1261
  """List entities for a memory bank."""
1190
1262
  try:
1191
- entities = await app.state.memory.list_entities(bank_id, limit=limit)
1263
+ entities = await app.state.memory.list_entities(bank_id, limit=limit, request_context=request_context)
1192
1264
  return EntityListResponse(items=[EntityListItem(**e) for e in entities])
1193
1265
  except Exception as e:
1194
1266
  import traceback
@@ -1205,37 +1277,26 @@ def _register_routes(app: FastAPI):
1205
1277
  operation_id="get_entity",
1206
1278
  tags=["Entities"],
1207
1279
  )
1208
- async def api_get_entity(bank_id: str, entity_id: str):
1280
+ async def api_get_entity(
1281
+ bank_id: str, entity_id: str, request_context: RequestContext = Depends(get_request_context)
1282
+ ):
1209
1283
  """Get entity details with observations."""
1210
1284
  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
- )
1285
+ entity = await app.state.memory.get_entity(bank_id, entity_id, request_context=request_context)
1223
1286
 
1224
- if not entity_row:
1287
+ if entity is None:
1225
1288
  raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
1226
1289
 
1227
- # Get observations for the entity
1228
- observations = await app.state.memory.get_entity_observations(bank_id, entity_id, limit=20)
1229
-
1230
1290
  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"]),
1291
+ id=entity["id"],
1292
+ canonical_name=entity["canonical_name"],
1293
+ mention_count=entity["mention_count"],
1294
+ first_seen=entity["first_seen"],
1295
+ last_seen=entity["last_seen"],
1296
+ metadata=_parse_metadata(entity["metadata"]),
1237
1297
  observations=[
1238
- EntityObservationResponse(text=obs.text, mentioned_at=obs.mentioned_at) for obs in observations
1298
+ EntityObservationResponse(text=obs.text, mentioned_at=obs.mentioned_at)
1299
+ for obs in entity["observations"]
1239
1300
  ],
1240
1301
  )
1241
1302
  except HTTPException:
@@ -1255,42 +1316,40 @@ def _register_routes(app: FastAPI):
1255
1316
  operation_id="regenerate_entity_observations",
1256
1317
  tags=["Entities"],
1257
1318
  )
1258
- async def api_regenerate_entity_observations(bank_id: str, entity_id: str):
1319
+ async def api_regenerate_entity_observations(
1320
+ bank_id: str,
1321
+ entity_id: str,
1322
+ request_context: RequestContext = Depends(get_request_context),
1323
+ ):
1259
1324
  """Regenerate observations for an entity."""
1260
1325
  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
- )
1326
+ # Get the entity to verify it exists and get canonical_name
1327
+ entity = await app.state.memory.get_entity(bank_id, entity_id, request_context=request_context)
1273
1328
 
1274
- if not entity_row:
1329
+ if entity is None:
1275
1330
  raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
1276
1331
 
1277
1332
  # Regenerate observations
1278
1333
  await app.state.memory.regenerate_entity_observations(
1279
- bank_id=bank_id, entity_id=entity_id, entity_name=entity_row["canonical_name"]
1334
+ bank_id=bank_id,
1335
+ entity_id=entity_id,
1336
+ entity_name=entity["canonical_name"],
1337
+ request_context=request_context,
1280
1338
  )
1281
1339
 
1282
- # Get updated observations
1283
- observations = await app.state.memory.get_entity_observations(bank_id, entity_id, limit=20)
1340
+ # Get updated entity with new observations
1341
+ entity = await app.state.memory.get_entity(bank_id, entity_id, request_context=request_context)
1284
1342
 
1285
1343
  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"]),
1344
+ id=entity["id"],
1345
+ canonical_name=entity["canonical_name"],
1346
+ mention_count=entity["mention_count"],
1347
+ first_seen=entity["first_seen"],
1348
+ last_seen=entity["last_seen"],
1349
+ metadata=_parse_metadata(entity["metadata"]),
1292
1350
  observations=[
1293
- EntityObservationResponse(text=obs.text, mentioned_at=obs.mentioned_at) for obs in observations
1351
+ EntityObservationResponse(text=obs.text, mentioned_at=obs.mentioned_at)
1352
+ for obs in entity["observations"]
1294
1353
  ],
1295
1354
  )
1296
1355
  except HTTPException:
@@ -1310,7 +1369,13 @@ def _register_routes(app: FastAPI):
1310
1369
  operation_id="list_documents",
1311
1370
  tags=["Documents"],
1312
1371
  )
1313
- async def api_list_documents(bank_id: str, q: str | None = None, limit: int = 100, offset: int = 0):
1372
+ async def api_list_documents(
1373
+ bank_id: str,
1374
+ q: str | None = None,
1375
+ limit: int = 100,
1376
+ offset: int = 0,
1377
+ request_context: RequestContext = Depends(get_request_context),
1378
+ ):
1314
1379
  """
1315
1380
  List documents for a memory bank with optional search.
1316
1381
 
@@ -1321,7 +1386,9 @@ def _register_routes(app: FastAPI):
1321
1386
  offset: Offset for pagination (default: 0)
1322
1387
  """
1323
1388
  try:
1324
- data = await app.state.memory.list_documents(bank_id=bank_id, search_query=q, limit=limit, offset=offset)
1389
+ data = await app.state.memory.list_documents(
1390
+ bank_id=bank_id, search_query=q, limit=limit, offset=offset, request_context=request_context
1391
+ )
1325
1392
  return data
1326
1393
  except Exception as e:
1327
1394
  import traceback
@@ -1338,7 +1405,9 @@ def _register_routes(app: FastAPI):
1338
1405
  operation_id="get_document",
1339
1406
  tags=["Documents"],
1340
1407
  )
1341
- async def api_get_document(bank_id: str, document_id: str):
1408
+ async def api_get_document(
1409
+ bank_id: str, document_id: str, request_context: RequestContext = Depends(get_request_context)
1410
+ ):
1342
1411
  """
1343
1412
  Get a specific document with its original text.
1344
1413
 
@@ -1347,7 +1416,7 @@ def _register_routes(app: FastAPI):
1347
1416
  document_id: Document ID (from path)
1348
1417
  """
1349
1418
  try:
1350
- document = await app.state.memory.get_document(document_id, bank_id)
1419
+ document = await app.state.memory.get_document(document_id, bank_id, request_context=request_context)
1351
1420
  if not document:
1352
1421
  raise HTTPException(status_code=404, detail="Document not found")
1353
1422
  return document
@@ -1368,7 +1437,7 @@ def _register_routes(app: FastAPI):
1368
1437
  operation_id="get_chunk",
1369
1438
  tags=["Documents"],
1370
1439
  )
1371
- async def api_get_chunk(chunk_id: str):
1440
+ async def api_get_chunk(chunk_id: str, request_context: RequestContext = Depends(get_request_context)):
1372
1441
  """
1373
1442
  Get a specific chunk with its text.
1374
1443
 
@@ -1376,7 +1445,7 @@ def _register_routes(app: FastAPI):
1376
1445
  chunk_id: Chunk ID (from path, format: bank_id_document_id_chunk_index)
1377
1446
  """
1378
1447
  try:
1379
- chunk = await app.state.memory.get_chunk(chunk_id)
1448
+ chunk = await app.state.memory.get_chunk(chunk_id, request_context=request_context)
1380
1449
  if not chunk:
1381
1450
  raise HTTPException(status_code=404, detail="Chunk not found")
1382
1451
  return chunk
@@ -1401,7 +1470,9 @@ def _register_routes(app: FastAPI):
1401
1470
  operation_id="delete_document",
1402
1471
  tags=["Documents"],
1403
1472
  )
1404
- async def api_delete_document(bank_id: str, document_id: str):
1473
+ async def api_delete_document(
1474
+ bank_id: str, document_id: str, request_context: RequestContext = Depends(get_request_context)
1475
+ ):
1405
1476
  """
1406
1477
  Delete a document and all its associated memory units and links.
1407
1478
 
@@ -1410,7 +1481,7 @@ def _register_routes(app: FastAPI):
1410
1481
  document_id: Document ID to delete (from path)
1411
1482
  """
1412
1483
  try:
1413
- result = await app.state.memory.delete_document(document_id, bank_id)
1484
+ result = await app.state.memory.delete_document(document_id, bank_id, request_context=request_context)
1414
1485
 
1415
1486
  if result["document_deleted"] == 0:
1416
1487
  raise HTTPException(status_code=404, detail="Document not found")
@@ -1437,45 +1508,14 @@ def _register_routes(app: FastAPI):
1437
1508
  operation_id="list_operations",
1438
1509
  tags=["Operations"],
1439
1510
  )
1440
- async def api_list_operations(bank_id: str):
1511
+ async def api_list_operations(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
1441
1512
  """List all async operations (pending and failed) for a memory bank."""
1442
1513
  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
-
1514
+ operations = await app.state.memory.list_operations(bank_id, request_context=request_context)
1515
+ return {
1516
+ "bank_id": bank_id,
1517
+ "operations": operations,
1518
+ }
1479
1519
  except Exception as e:
1480
1520
  import traceback
1481
1521
 
@@ -1490,39 +1530,21 @@ def _register_routes(app: FastAPI):
1490
1530
  operation_id="cancel_operation",
1491
1531
  tags=["Operations"],
1492
1532
  )
1493
- async def api_cancel_operation(bank_id: str, operation_id: str):
1533
+ async def api_cancel_operation(
1534
+ bank_id: str, operation_id: str, request_context: RequestContext = Depends(get_request_context)
1535
+ ):
1494
1536
  """Cancel a pending async operation."""
1495
1537
  try:
1496
1538
  # Validate UUID format
1497
1539
  try:
1498
- op_uuid = uuid.UUID(operation_id)
1540
+ uuid.UUID(operation_id)
1499
1541
  except ValueError:
1500
1542
  raise HTTPException(status_code=400, detail=f"Invalid operation_id format: {operation_id}")
1501
1543
 
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
1544
+ result = await app.state.memory.cancel_operation(bank_id, operation_id, request_context=request_context)
1545
+ return result
1546
+ except ValueError as e:
1547
+ raise HTTPException(status_code=404, detail=str(e))
1526
1548
  except Exception as e:
1527
1549
  import traceback
1528
1550
 
@@ -1538,10 +1560,10 @@ def _register_routes(app: FastAPI):
1538
1560
  operation_id="get_bank_profile",
1539
1561
  tags=["Banks"],
1540
1562
  )
1541
- async def api_get_bank_profile(bank_id: str):
1563
+ async def api_get_bank_profile(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
1542
1564
  """Get memory bank profile (disposition + background)."""
1543
1565
  try:
1544
- profile = await app.state.memory.get_bank_profile(bank_id)
1566
+ profile = await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
1545
1567
  # Convert DispositionTraits object to dict for Pydantic
1546
1568
  disposition_dict = (
1547
1569
  profile["disposition"].model_dump()
@@ -1569,14 +1591,18 @@ def _register_routes(app: FastAPI):
1569
1591
  operation_id="update_bank_disposition",
1570
1592
  tags=["Banks"],
1571
1593
  )
1572
- async def api_update_bank_disposition(bank_id: str, request: UpdateDispositionRequest):
1594
+ async def api_update_bank_disposition(
1595
+ bank_id: str, request: UpdateDispositionRequest, request_context: RequestContext = Depends(get_request_context)
1596
+ ):
1573
1597
  """Update bank disposition traits."""
1574
1598
  try:
1575
1599
  # Update disposition
1576
- await app.state.memory.update_bank_disposition(bank_id, request.disposition.model_dump())
1600
+ await app.state.memory.update_bank_disposition(
1601
+ bank_id, request.disposition.model_dump(), request_context=request_context
1602
+ )
1577
1603
 
1578
1604
  # Get updated profile
1579
- profile = await app.state.memory.get_bank_profile(bank_id)
1605
+ profile = await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
1580
1606
  disposition_dict = (
1581
1607
  profile["disposition"].model_dump()
1582
1608
  if hasattr(profile["disposition"], "model_dump")
@@ -1603,11 +1629,13 @@ def _register_routes(app: FastAPI):
1603
1629
  operation_id="add_bank_background",
1604
1630
  tags=["Banks"],
1605
1631
  )
1606
- async def api_add_bank_background(bank_id: str, request: AddBackgroundRequest):
1632
+ async def api_add_bank_background(
1633
+ bank_id: str, request: AddBackgroundRequest, request_context: RequestContext = Depends(get_request_context)
1634
+ ):
1607
1635
  """Add or merge bank background information. Optionally infer disposition traits."""
1608
1636
  try:
1609
1637
  result = await app.state.memory.merge_bank_background(
1610
- bank_id, request.content, update_disposition=request.update_disposition
1638
+ bank_id, request.content, update_disposition=request.update_disposition, request_context=request_context
1611
1639
  )
1612
1640
 
1613
1641
  response = BackgroundResponse(background=result["background"])
@@ -1630,51 +1658,31 @@ def _register_routes(app: FastAPI):
1630
1658
  operation_id="create_or_update_bank",
1631
1659
  tags=["Banks"],
1632
1660
  )
1633
- async def api_create_or_update_bank(bank_id: str, request: CreateBankRequest):
1661
+ async def api_create_or_update_bank(
1662
+ bank_id: str, request: CreateBankRequest, request_context: RequestContext = Depends(get_request_context)
1663
+ ):
1634
1664
  """Create or update an agent with disposition and background."""
1635
1665
  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
1666
+ # Ensure bank exists by getting profile (auto-creates with defaults)
1667
+ await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
1668
+
1669
+ # Update name and/or background if provided
1670
+ if request.name is not None or request.background is not None:
1671
+ await app.state.memory.update_bank(
1672
+ bank_id,
1673
+ name=request.name,
1674
+ background=request.background,
1675
+ request_context=request_context,
1676
+ )
1654
1677
 
1655
1678
  # Update disposition if provided
1656
1679
  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
1680
+ await app.state.memory.update_bank_disposition(
1681
+ bank_id, request.disposition.model_dump(), request_context=request_context
1682
+ )
1675
1683
 
1676
1684
  # Get final profile
1677
- final_profile = await app.state.memory.get_bank_profile(bank_id)
1685
+ final_profile = await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
1678
1686
  disposition_dict = (
1679
1687
  final_profile["disposition"].model_dump()
1680
1688
  if hasattr(final_profile["disposition"], "model_dump")
@@ -1702,10 +1710,10 @@ def _register_routes(app: FastAPI):
1702
1710
  operation_id="delete_bank",
1703
1711
  tags=["Banks"],
1704
1712
  )
1705
- async def api_delete_bank(bank_id: str):
1713
+ async def api_delete_bank(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
1706
1714
  """Delete an entire memory bank and all its data."""
1707
1715
  try:
1708
- result = await app.state.memory.delete_bank(bank_id)
1716
+ result = await app.state.memory.delete_bank(bank_id, request_context=request_context)
1709
1717
  return DeleteResponse(
1710
1718
  success=True,
1711
1719
  message=f"Bank '{bank_id}' and all associated data deleted successfully",
@@ -1745,7 +1753,9 @@ def _register_routes(app: FastAPI):
1745
1753
  operation_id="retain_memories",
1746
1754
  tags=["Memory"],
1747
1755
  )
1748
- async def api_retain(bank_id: str, request: RetainRequest):
1756
+ async def api_retain(
1757
+ bank_id: str, request: RetainRequest, request_context: RequestContext = Depends(get_request_context)
1758
+ ):
1749
1759
  """Retain memories with optional async processing."""
1750
1760
  metrics = get_metrics_collector()
1751
1761
 
@@ -1766,47 +1776,42 @@ def _register_routes(app: FastAPI):
1766
1776
 
1767
1777
  if request.async_:
1768
1778
  # 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(
1779
+ result = await app.state.memory.submit_async_retain(bank_id, contents, request_context=request_context)
1780
+ return RetainResponse.model_validate(
1787
1781
  {
1788
- "type": "batch_retain",
1789
- "operation_id": str(operation_id),
1782
+ "success": True,
1790
1783
  "bank_id": bank_id,
1791
- "contents": contents,
1784
+ "items_count": result["items_count"],
1785
+ "async": True,
1792
1786
  }
1793
1787
  )
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
1788
  else:
1801
1789
  # Synchronous processing: wait for completion (record metrics)
1802
1790
  with metrics.record_operation("retain", bank_id=bank_id):
1803
- result = await app.state.memory.retain_batch_async(bank_id=bank_id, contents=contents)
1791
+ result = await app.state.memory.retain_batch_async(
1792
+ bank_id=bank_id, contents=contents, request_context=request_context
1793
+ )
1804
1794
 
1805
- return RetainResponse(success=True, bank_id=bank_id, items_count=len(contents), async_=False)
1795
+ return RetainResponse.model_validate(
1796
+ {"success": True, "bank_id": bank_id, "items_count": len(contents), "async": False}
1797
+ )
1806
1798
  except Exception as e:
1807
1799
  import traceback
1808
1800
 
1809
- error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
1801
+ # Create a summary of the input for debugging
1802
+ input_summary = []
1803
+ for i, item in enumerate(request.items):
1804
+ content_preview = item.content[:100] + "..." if len(item.content) > 100 else item.content
1805
+ input_summary.append(
1806
+ f" [{i}] content={content_preview!r}, context={item.context}, timestamp={item.timestamp}"
1807
+ )
1808
+ input_debug = "\n".join(input_summary)
1809
+
1810
+ error_detail = (
1811
+ f"{str(e)}\n\n"
1812
+ f"Input ({len(request.items)} items):\n{input_debug}\n\n"
1813
+ f"Traceback:\n{traceback.format_exc()}"
1814
+ )
1810
1815
  logger.error(f"Error in /v1/default/banks/{bank_id}/memories (retain): {error_detail}")
1811
1816
  raise HTTPException(status_code=500, detail=str(e))
1812
1817
 
@@ -1821,10 +1826,11 @@ def _register_routes(app: FastAPI):
1821
1826
  async def api_clear_bank_memories(
1822
1827
  bank_id: str,
1823
1828
  type: str | None = Query(None, description="Optional fact type filter (world, experience, opinion)"),
1829
+ request_context: RequestContext = Depends(get_request_context),
1824
1830
  ):
1825
1831
  """Clear memories for a memory bank, optionally filtered by type."""
1826
1832
  try:
1827
- await app.state.memory.delete_bank(bank_id, fact_type=type)
1833
+ await app.state.memory.delete_bank(bank_id, fact_type=type, request_context=request_context)
1828
1834
 
1829
1835
  return DeleteResponse(success=True)
1830
1836
  except Exception as e: