hindsight-api 0.3.0__py3-none-any.whl → 0.4.0__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 (74) hide show
  1. hindsight_api/admin/cli.py +59 -0
  2. hindsight_api/alembic/versions/h3c4d5e6f7g8_mental_models_v4.py +112 -0
  3. hindsight_api/alembic/versions/i4d5e6f7g8h9_delete_opinions.py +41 -0
  4. hindsight_api/alembic/versions/j5e6f7g8h9i0_mental_model_versions.py +95 -0
  5. hindsight_api/alembic/versions/k6f7g8h9i0j1_add_directive_subtype.py +58 -0
  6. hindsight_api/alembic/versions/l7g8h9i0j1k2_add_worker_columns.py +109 -0
  7. hindsight_api/alembic/versions/m8h9i0j1k2l3_mental_model_id_to_text.py +41 -0
  8. hindsight_api/alembic/versions/n9i0j1k2l3m4_learnings_and_pinned_reflections.py +134 -0
  9. hindsight_api/alembic/versions/o0j1k2l3m4n5_migrate_mental_models_data.py +113 -0
  10. hindsight_api/alembic/versions/p1k2l3m4n5o6_new_knowledge_architecture.py +194 -0
  11. hindsight_api/alembic/versions/q2l3m4n5o6p7_fix_mental_model_fact_type.py +50 -0
  12. hindsight_api/alembic/versions/r3m4n5o6p7q8_add_reflect_response_to_reflections.py +47 -0
  13. hindsight_api/alembic/versions/s4n5o6p7q8r9_add_consolidated_at_to_memory_units.py +53 -0
  14. hindsight_api/alembic/versions/t5o6p7q8r9s0_rename_mental_models_to_observations.py +134 -0
  15. hindsight_api/alembic/versions/u6p7q8r9s0t1_mental_models_text_id.py +41 -0
  16. hindsight_api/alembic/versions/v7q8r9s0t1u2_add_max_tokens_to_mental_models.py +50 -0
  17. hindsight_api/api/http.py +1119 -93
  18. hindsight_api/api/mcp.py +11 -191
  19. hindsight_api/config.py +145 -45
  20. hindsight_api/engine/consolidation/__init__.py +5 -0
  21. hindsight_api/engine/consolidation/consolidator.py +859 -0
  22. hindsight_api/engine/consolidation/prompts.py +69 -0
  23. hindsight_api/engine/cross_encoder.py +114 -9
  24. hindsight_api/engine/directives/__init__.py +5 -0
  25. hindsight_api/engine/directives/models.py +37 -0
  26. hindsight_api/engine/embeddings.py +102 -5
  27. hindsight_api/engine/interface.py +32 -13
  28. hindsight_api/engine/llm_wrapper.py +505 -43
  29. hindsight_api/engine/memory_engine.py +2090 -1089
  30. hindsight_api/engine/mental_models/__init__.py +14 -0
  31. hindsight_api/engine/mental_models/models.py +53 -0
  32. hindsight_api/engine/reflect/__init__.py +18 -0
  33. hindsight_api/engine/reflect/agent.py +933 -0
  34. hindsight_api/engine/reflect/models.py +109 -0
  35. hindsight_api/engine/reflect/observations.py +186 -0
  36. hindsight_api/engine/reflect/prompts.py +483 -0
  37. hindsight_api/engine/reflect/tools.py +437 -0
  38. hindsight_api/engine/reflect/tools_schema.py +250 -0
  39. hindsight_api/engine/response_models.py +130 -4
  40. hindsight_api/engine/retain/bank_utils.py +79 -201
  41. hindsight_api/engine/retain/fact_extraction.py +81 -48
  42. hindsight_api/engine/retain/fact_storage.py +5 -8
  43. hindsight_api/engine/retain/link_utils.py +5 -8
  44. hindsight_api/engine/retain/orchestrator.py +1 -55
  45. hindsight_api/engine/retain/types.py +2 -2
  46. hindsight_api/engine/search/graph_retrieval.py +2 -2
  47. hindsight_api/engine/search/link_expansion_retrieval.py +164 -29
  48. hindsight_api/engine/search/mpfp_retrieval.py +1 -1
  49. hindsight_api/engine/search/retrieval.py +14 -14
  50. hindsight_api/engine/search/think_utils.py +41 -140
  51. hindsight_api/engine/search/trace.py +0 -1
  52. hindsight_api/engine/search/tracer.py +2 -5
  53. hindsight_api/engine/search/types.py +0 -3
  54. hindsight_api/engine/task_backend.py +112 -196
  55. hindsight_api/engine/utils.py +0 -151
  56. hindsight_api/extensions/__init__.py +10 -1
  57. hindsight_api/extensions/builtin/tenant.py +5 -1
  58. hindsight_api/extensions/operation_validator.py +81 -4
  59. hindsight_api/extensions/tenant.py +26 -0
  60. hindsight_api/main.py +16 -5
  61. hindsight_api/mcp_local.py +12 -53
  62. hindsight_api/mcp_tools.py +494 -0
  63. hindsight_api/models.py +0 -2
  64. hindsight_api/worker/__init__.py +11 -0
  65. hindsight_api/worker/main.py +296 -0
  66. hindsight_api/worker/poller.py +486 -0
  67. {hindsight_api-0.3.0.dist-info → hindsight_api-0.4.0.dist-info}/METADATA +12 -6
  68. hindsight_api-0.4.0.dist-info/RECORD +112 -0
  69. {hindsight_api-0.3.0.dist-info → hindsight_api-0.4.0.dist-info}/entry_points.txt +1 -0
  70. hindsight_api/engine/retain/observation_regeneration.py +0 -254
  71. hindsight_api/engine/search/observation_utils.py +0 -125
  72. hindsight_api/engine/search/scoring.py +0 -159
  73. hindsight_api-0.3.0.dist-info/RECORD +0 -82
  74. {hindsight_api-0.3.0.dist-info → hindsight_api-0.4.0.dist-info}/WHEEL +0 -0
hindsight_api/api/http.py CHANGED
@@ -10,7 +10,7 @@ import logging
10
10
  import uuid
11
11
  from contextlib import asynccontextmanager
12
12
  from datetime import datetime
13
- from typing import Any
13
+ from typing import Any, Literal
14
14
 
15
15
  from fastapi import Depends, FastAPI, Header, HTTPException, Query
16
16
 
@@ -36,6 +36,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
36
36
  from hindsight_api import MemoryEngine
37
37
  from hindsight_api.engine.db_utils import acquire_with_retry
38
38
  from hindsight_api.engine.memory_engine import Budget, fq_table
39
+ from hindsight_api.engine.reflect.observations import Observation
39
40
  from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, TokenUsage
40
41
  from hindsight_api.engine.search.tags import TagsMatch
41
42
  from hindsight_api.extensions import HttpExtension, OperationValidationError, load_extension
@@ -90,7 +91,9 @@ class RecallRequest(BaseModel):
90
91
 
91
92
  query: str
92
93
  types: list[str] | None = Field(
93
- default=None, description="List of fact types to recall (defaults to all if not specified)"
94
+ default=None,
95
+ description="List of fact types to recall: 'world', 'experience', 'observation'. Defaults to world and experience if not specified. "
96
+ "Note: 'opinion' is accepted but ignored (opinions are excluded from recall).",
94
97
  )
95
98
  budget: Budget = Budget.MID
96
99
  max_tokens: int = 4096
@@ -427,6 +430,15 @@ class FactsIncludeOptions(BaseModel):
427
430
  pass # No additional options needed, just enable/disable
428
431
 
429
432
 
433
+ class ToolCallsIncludeOptions(BaseModel):
434
+ """Options for including tool calls in reflect results."""
435
+
436
+ output: bool = Field(
437
+ default=True,
438
+ description="Include tool outputs in the trace. Set to false to only include inputs (smaller payload).",
439
+ )
440
+
441
+
430
442
  class ReflectIncludeOptions(BaseModel):
431
443
  """Options for including additional data in reflect results."""
432
444
 
@@ -434,6 +446,10 @@ class ReflectIncludeOptions(BaseModel):
434
446
  default=None,
435
447
  description="Include facts that the answer is based on. Set to {} to enable, null to disable (default: disabled).",
436
448
  )
449
+ tool_calls: ToolCallsIncludeOptions | None = Field(
450
+ default=None,
451
+ description="Include tool calls trace. Set to {} for full trace (input+output), {output: false} for inputs only.",
452
+ )
437
453
 
438
454
 
439
455
  class ReflectRequest(BaseModel):
@@ -444,7 +460,6 @@ class ReflectRequest(BaseModel):
444
460
  "example": {
445
461
  "query": "What do you think about artificial intelligence?",
446
462
  "budget": "low",
447
- "context": "This is for a research paper on AI ethics",
448
463
  "max_tokens": 4096,
449
464
  "include": {"facts": {}},
450
465
  "response_schema": {
@@ -463,7 +478,13 @@ class ReflectRequest(BaseModel):
463
478
 
464
479
  query: str
465
480
  budget: Budget = Budget.LOW
466
- context: str | None = None
481
+ context: str | None = Field(
482
+ default=None,
483
+ description="DEPRECATED: Additional context is now concatenated with the query. "
484
+ "Pass context directly in the query field instead. "
485
+ "If provided, it will be appended to the query for backward compatibility.",
486
+ deprecated=True,
487
+ )
467
488
  max_tokens: int = Field(default=4096, description="Maximum tokens for the response")
468
489
  include: ReflectIncludeOptions = Field(
469
490
  default_factory=ReflectIncludeOptions, description="Options for including additional data (disabled by default)"
@@ -514,6 +535,58 @@ class ReflectFact(BaseModel):
514
535
  occurred_end: str | None = None
515
536
 
516
537
 
538
+ class ReflectDirective(BaseModel):
539
+ """A directive applied during reflect."""
540
+
541
+ id: str = Field(description="Directive ID")
542
+ name: str = Field(description="Directive name")
543
+ content: str = Field(description="Directive content")
544
+
545
+
546
+ class ReflectMentalModel(BaseModel):
547
+ """A mental model used during reflect."""
548
+
549
+ id: str = Field(description="Mental model ID")
550
+ text: str = Field(description="Mental model content")
551
+ context: str | None = Field(default=None, description="Additional context")
552
+
553
+
554
+ class ReflectToolCall(BaseModel):
555
+ """A tool call made during reflect agent execution."""
556
+
557
+ tool: str = Field(description="Tool name: lookup, recall, learn, expand")
558
+ input: dict = Field(description="Tool input parameters")
559
+ output: dict | None = Field(
560
+ default=None, description="Tool output (only included when include.tool_calls.output is true)"
561
+ )
562
+ duration_ms: int = Field(description="Execution time in milliseconds")
563
+ iteration: int = Field(default=0, description="Iteration number (1-based) when this tool was called")
564
+
565
+
566
+ class ReflectLLMCall(BaseModel):
567
+ """An LLM call made during reflect agent execution."""
568
+
569
+ scope: str = Field(description="Call scope: agent_1, agent_2, final, etc.")
570
+ duration_ms: int = Field(description="Execution time in milliseconds")
571
+
572
+
573
+ class ReflectBasedOn(BaseModel):
574
+ """Evidence the response is based on: memories, mental models, and directives."""
575
+
576
+ memories: list[ReflectFact] = Field(default_factory=list, description="Memory facts used to generate the response")
577
+ mental_models: list[ReflectMentalModel] = Field(
578
+ default_factory=list, description="Mental models used during reflection"
579
+ )
580
+ directives: list[ReflectDirective] = Field(default_factory=list, description="Directives applied during reflection")
581
+
582
+
583
+ class ReflectTrace(BaseModel):
584
+ """Execution trace of LLM and tool calls during reflection."""
585
+
586
+ tool_calls: list[ReflectToolCall] = Field(default_factory=list, description="Tool calls made during reflection")
587
+ llm_calls: list[ReflectLLMCall] = Field(default_factory=list, description="LLM calls made during reflection")
588
+
589
+
517
590
  class ReflectResponse(BaseModel):
518
591
  """Response model for think endpoint."""
519
592
 
@@ -521,21 +594,38 @@ class ReflectResponse(BaseModel):
521
594
  json_schema_extra={
522
595
  "example": {
523
596
  "text": "Based on my understanding, AI is a transformative technology...",
524
- "based_on": [
525
- {"id": "123", "text": "AI is used in healthcare", "type": "world"},
526
- {"id": "456", "text": "I discussed AI applications last week", "type": "experience"},
527
- ],
597
+ "based_on": {
598
+ "memories": [
599
+ {"id": "123", "text": "AI is used in healthcare", "type": "world"},
600
+ {"id": "456", "text": "I discussed AI applications last week", "type": "experience"},
601
+ ],
602
+ },
528
603
  "structured_output": {
529
604
  "summary": "AI is transformative",
530
605
  "key_points": ["Used in healthcare", "Discussed recently"],
531
606
  },
532
607
  "usage": {"input_tokens": 1500, "output_tokens": 500, "total_tokens": 2000},
608
+ "trace": {
609
+ "tool_calls": [{"tool": "recall", "input": {"query": "AI"}, "duration_ms": 150}],
610
+ "llm_calls": [{"scope": "agent_1", "duration_ms": 1200}],
611
+ "observations": [
612
+ {
613
+ "id": "obs-1",
614
+ "name": "AI Technology",
615
+ "type": "concept",
616
+ "subtype": "structural",
617
+ }
618
+ ],
619
+ },
533
620
  }
534
621
  }
535
622
  )
536
623
 
537
624
  text: str
538
- based_on: list[ReflectFact] = [] # Facts used to generate the response
625
+ based_on: ReflectBasedOn | None = Field(
626
+ default=None,
627
+ description="Evidence used to generate the response. Only present when include.facts is set.",
628
+ )
539
629
  structured_output: dict | None = Field(
540
630
  default=None,
541
631
  description="Structured output parsed according to the request's response_schema. Only present when response_schema was provided in the request.",
@@ -544,6 +634,10 @@ class ReflectResponse(BaseModel):
544
634
  default=None,
545
635
  description="Token usage metrics for LLM calls during reflection.",
546
636
  )
637
+ trace: ReflectTrace | None = Field(
638
+ default=None,
639
+ description="Execution trace of tool and LLM calls. Only present when include.tool_calls is set.",
640
+ )
547
641
 
548
642
 
549
643
  class BanksResponse(BaseModel):
@@ -573,7 +667,7 @@ class BankProfileResponse(BaseModel):
573
667
  "bank_id": "user123",
574
668
  "name": "Alice",
575
669
  "disposition": {"skepticism": 3, "literalism": 3, "empathy": 3},
576
- "background": "I am a software engineer with 10 years of experience in startups",
670
+ "mission": "I am a software engineer helping my team stay organized and ship quality code",
577
671
  }
578
672
  }
579
673
  )
@@ -581,7 +675,9 @@ class BankProfileResponse(BaseModel):
581
675
  bank_id: str
582
676
  name: str
583
677
  disposition: DispositionTraits
584
- background: str
678
+ mission: str = Field(description="The agent's mission - who they are and what they're trying to accomplish")
679
+ # Deprecated: use mission instead. Kept for backwards compatibility.
680
+ background: str | None = Field(default=None, description="Deprecated: use mission instead")
585
681
 
586
682
 
587
683
  class UpdateDispositionRequest(BaseModel):
@@ -590,8 +686,32 @@ class UpdateDispositionRequest(BaseModel):
590
686
  disposition: DispositionTraits
591
687
 
592
688
 
689
+ class SetMissionRequest(BaseModel):
690
+ """Request model for setting/updating the agent's mission."""
691
+
692
+ model_config = ConfigDict(
693
+ json_schema_extra={"example": {"content": "I am a PM helping my engineering team stay organized"}}
694
+ )
695
+
696
+ content: str = Field(description="The mission content - who you are and what you're trying to accomplish")
697
+
698
+
699
+ class MissionResponse(BaseModel):
700
+ """Response model for mission update."""
701
+
702
+ model_config = ConfigDict(
703
+ json_schema_extra={
704
+ "example": {
705
+ "mission": "I am a PM helping my engineering team stay organized and ship quality code.",
706
+ }
707
+ }
708
+ )
709
+
710
+ mission: str
711
+
712
+
593
713
  class AddBackgroundRequest(BaseModel):
594
- """Request model for adding/merging background information."""
714
+ """Request model for adding/merging background information. Deprecated: use SetMissionRequest instead."""
595
715
 
596
716
  model_config = ConfigDict(
597
717
  json_schema_extra={"example": {"content": "I was born in Texas", "update_disposition": True}}
@@ -599,23 +719,24 @@ class AddBackgroundRequest(BaseModel):
599
719
 
600
720
  content: str = Field(description="New background information to add or merge")
601
721
  update_disposition: bool = Field(
602
- default=True, description="If true, infer disposition traits from the merged background (default: true)"
722
+ default=True, description="Deprecated - disposition is no longer auto-inferred from mission"
603
723
  )
604
724
 
605
725
 
606
726
  class BackgroundResponse(BaseModel):
607
- """Response model for background update."""
727
+ """Response model for background update. Deprecated: use MissionResponse instead."""
608
728
 
609
729
  model_config = ConfigDict(
610
730
  json_schema_extra={
611
731
  "example": {
612
- "background": "I was born in Texas. I am a software engineer with 10 years of experience.",
613
- "disposition": {"skepticism": 3, "literalism": 3, "empathy": 3},
732
+ "mission": "I was born in Texas. I am a software engineer with 10 years of experience.",
614
733
  }
615
734
  }
616
735
  )
617
736
 
618
- background: str
737
+ mission: str
738
+ # Deprecated fields kept for backwards compatibility
739
+ background: str | None = Field(default=None, description="Deprecated: same as mission")
619
740
  disposition: DispositionTraits | None = None
620
741
 
621
742
 
@@ -625,7 +746,7 @@ class BankListItem(BaseModel):
625
746
  bank_id: str
626
747
  name: str | None = None
627
748
  disposition: DispositionTraits
628
- background: str | None = None
749
+ mission: str | None = None
629
750
  created_at: str | None = None
630
751
  updated_at: str | None = None
631
752
 
@@ -641,7 +762,7 @@ class BankListResponse(BaseModel):
641
762
  "bank_id": "user123",
642
763
  "name": "Alice",
643
764
  "disposition": {"skepticism": 3, "literalism": 3, "empathy": 3},
644
- "background": "I am a software engineer",
765
+ "mission": "I am a software engineer helping my team ship quality code",
645
766
  "created_at": "2024-01-15T10:30:00Z",
646
767
  "updated_at": "2024-01-16T14:20:00Z",
647
768
  }
@@ -661,14 +782,16 @@ class CreateBankRequest(BaseModel):
661
782
  "example": {
662
783
  "name": "Alice",
663
784
  "disposition": {"skepticism": 3, "literalism": 3, "empathy": 3},
664
- "background": "I am a creative software engineer with 10 years of experience",
785
+ "mission": "I am a PM helping my engineering team stay organized",
665
786
  }
666
787
  }
667
788
  )
668
789
 
669
790
  name: str | None = None
670
791
  disposition: DispositionTraits | None = None
671
- background: str | None = None
792
+ mission: str | None = Field(default=None, description="The agent's mission")
793
+ # Deprecated: use mission instead
794
+ background: str | None = Field(default=None, description="Deprecated: use mission instead")
672
795
 
673
796
 
674
797
  class GraphDataResponse(BaseModel):
@@ -894,6 +1017,9 @@ class BankStatsResponse(BaseModel):
894
1017
  "links_breakdown": {"fact": {"temporal": 100, "semantic": 60, "entity": 40}},
895
1018
  "pending_operations": 2,
896
1019
  "failed_operations": 0,
1020
+ "last_consolidated_at": "2024-01-15T10:30:00Z",
1021
+ "pending_consolidation": 0,
1022
+ "total_observations": 45,
897
1023
  }
898
1024
  }
899
1025
  )
@@ -908,6 +1034,156 @@ class BankStatsResponse(BaseModel):
908
1034
  links_breakdown: dict[str, dict[str, int]]
909
1035
  pending_operations: int
910
1036
  failed_operations: int
1037
+ # Consolidation stats
1038
+ last_consolidated_at: str | None = Field(default=None, description="When consolidation last ran (ISO format)")
1039
+ pending_consolidation: int = Field(default=0, description="Number of memories not yet processed into observations")
1040
+ total_observations: int = Field(default=0, description="Total number of observations")
1041
+
1042
+
1043
+ # Mental Model models
1044
+
1045
+
1046
+ class ObservationEvidenceResponse(BaseModel):
1047
+ """A single piece of evidence supporting an observation."""
1048
+
1049
+ memory_id: str = Field(description="ID of the memory unit this evidence comes from")
1050
+ quote: str = Field(description="Exact quote from the memory supporting the observation")
1051
+ relevance: str = Field(description="Brief explanation of how this quote supports the observation")
1052
+ timestamp: str = Field(description="When the source memory was created (ISO format)")
1053
+
1054
+
1055
+ # =========================================================================
1056
+ # Directive Models
1057
+ # =========================================================================
1058
+
1059
+
1060
+ class DirectiveResponse(BaseModel):
1061
+ """Response model for a directive."""
1062
+
1063
+ id: str
1064
+ bank_id: str
1065
+ name: str
1066
+ content: str
1067
+ priority: int = 0
1068
+ is_active: bool = True
1069
+ tags: list[str] = Field(default_factory=list)
1070
+ created_at: str | None = None
1071
+ updated_at: str | None = None
1072
+
1073
+
1074
+ class DirectiveListResponse(BaseModel):
1075
+ """Response model for listing directives."""
1076
+
1077
+ items: list[DirectiveResponse]
1078
+
1079
+
1080
+ class CreateDirectiveRequest(BaseModel):
1081
+ """Request model for creating a directive."""
1082
+
1083
+ name: str = Field(description="Human-readable name for the directive")
1084
+ content: str = Field(description="The directive text to inject into prompts")
1085
+ priority: int = Field(default=0, description="Higher priority directives are injected first")
1086
+ is_active: bool = Field(default=True, description="Whether this directive is active")
1087
+ tags: list[str] = Field(default_factory=list, description="Tags for filtering")
1088
+
1089
+
1090
+ class UpdateDirectiveRequest(BaseModel):
1091
+ """Request model for updating a directive."""
1092
+
1093
+ name: str | None = Field(default=None, description="New name")
1094
+ content: str | None = Field(default=None, description="New content")
1095
+ priority: int | None = Field(default=None, description="New priority")
1096
+ is_active: bool | None = Field(default=None, description="New active status")
1097
+ tags: list[str] | None = Field(default=None, description="New tags")
1098
+
1099
+
1100
+ # =========================================================================
1101
+ # Mental Models (stored reflect responses)
1102
+ # =========================================================================
1103
+
1104
+
1105
+ class MentalModelTrigger(BaseModel):
1106
+ """Trigger settings for a mental model."""
1107
+
1108
+ refresh_after_consolidation: bool = Field(
1109
+ default=False,
1110
+ description="If true, refresh this mental model after observations consolidation (real-time mode)",
1111
+ )
1112
+
1113
+
1114
+ class MentalModelResponse(BaseModel):
1115
+ """Response model for a mental model (stored reflect response)."""
1116
+
1117
+ id: str
1118
+ bank_id: str
1119
+ name: str
1120
+ source_query: str
1121
+ content: str
1122
+ tags: list[str] = Field(default_factory=list)
1123
+ max_tokens: int = Field(default=2048)
1124
+ trigger: MentalModelTrigger = Field(default_factory=MentalModelTrigger)
1125
+ last_refreshed_at: str | None = None
1126
+ created_at: str | None = None
1127
+ reflect_response: dict | None = Field(
1128
+ default=None,
1129
+ description="Full reflect API response payload including based_on facts and observations",
1130
+ )
1131
+
1132
+
1133
+ class MentalModelListResponse(BaseModel):
1134
+ """Response model for listing mental models."""
1135
+
1136
+ items: list[MentalModelResponse]
1137
+
1138
+
1139
+ class CreateMentalModelRequest(BaseModel):
1140
+ """Request model for creating a mental model."""
1141
+
1142
+ model_config = ConfigDict(
1143
+ json_schema_extra={
1144
+ "example": {
1145
+ "name": "Team Communication Preferences",
1146
+ "source_query": "How does the team prefer to communicate?",
1147
+ "tags": ["team"],
1148
+ "max_tokens": 2048,
1149
+ "trigger": {"refresh_after_consolidation": False},
1150
+ }
1151
+ }
1152
+ )
1153
+
1154
+ name: str = Field(description="Human-readable name for the mental model")
1155
+ source_query: str = Field(description="The query to run to generate content")
1156
+ tags: list[str] = Field(default_factory=list, description="Tags for scoped visibility")
1157
+ max_tokens: int = Field(default=2048, ge=256, le=8192, description="Maximum tokens for generated content")
1158
+ trigger: MentalModelTrigger = Field(default_factory=MentalModelTrigger, description="Trigger settings")
1159
+
1160
+
1161
+ class CreateMentalModelResponse(BaseModel):
1162
+ """Response model for mental model creation."""
1163
+
1164
+ operation_id: str = Field(description="Operation ID to track progress")
1165
+
1166
+
1167
+ class UpdateMentalModelRequest(BaseModel):
1168
+ """Request model for updating a mental model."""
1169
+
1170
+ model_config = ConfigDict(
1171
+ json_schema_extra={
1172
+ "example": {
1173
+ "name": "Updated Team Communication Preferences",
1174
+ "source_query": "How does the team prefer to communicate?",
1175
+ "max_tokens": 4096,
1176
+ "tags": ["team", "communication"],
1177
+ "trigger": {"refresh_after_consolidation": True},
1178
+ }
1179
+ }
1180
+ )
1181
+
1182
+ name: str | None = Field(default=None, description="New name for the mental model")
1183
+ source_query: str | None = Field(default=None, description="New source query for the mental model")
1184
+ max_tokens: int | None = Field(default=None, ge=256, le=8192, description="Maximum tokens for generated content")
1185
+ tags: list[str] | None = Field(default=None, description="Tags for scoped visibility")
1186
+ trigger: MentalModelTrigger | None = Field(default=None, description="Trigger settings")
911
1187
 
912
1188
 
913
1189
  class OperationResponse(BaseModel):
@@ -919,7 +1195,7 @@ class OperationResponse(BaseModel):
919
1195
  "id": "550e8400-e29b-41d4-a716-446655440000",
920
1196
  "task_type": "retain",
921
1197
  "items_count": 5,
922
- "document_id": "meeting-notes-2024",
1198
+ "document_id": None,
923
1199
  "created_at": "2024-01-15T10:30:00Z",
924
1200
  "status": "pending",
925
1201
  "error_message": None,
@@ -930,12 +1206,19 @@ class OperationResponse(BaseModel):
930
1206
  id: str
931
1207
  task_type: str
932
1208
  items_count: int
933
- document_id: str | None
1209
+ document_id: str | None = None
934
1210
  created_at: str
935
1211
  status: str
936
1212
  error_message: str | None
937
1213
 
938
1214
 
1215
+ class ConsolidationResponse(BaseModel):
1216
+ """Response model for consolidation trigger endpoint."""
1217
+
1218
+ operation_id: str = Field(description="ID of the async consolidation operation")
1219
+ deduplicated: bool = Field(default=False, description="True if an existing pending task was reused")
1220
+
1221
+
939
1222
  class OperationsListResponse(BaseModel):
940
1223
  """Response model for list operations endpoint."""
941
1224
 
@@ -943,12 +1226,13 @@ class OperationsListResponse(BaseModel):
943
1226
  json_schema_extra={
944
1227
  "example": {
945
1228
  "bank_id": "user123",
1229
+ "total": 150,
1230
+ "limit": 20,
1231
+ "offset": 0,
946
1232
  "operations": [
947
1233
  {
948
1234
  "id": "550e8400-e29b-41d4-a716-446655440000",
949
1235
  "task_type": "retain",
950
- "items_count": 5,
951
- "document_id": None,
952
1236
  "created_at": "2024-01-15T10:30:00Z",
953
1237
  "status": "pending",
954
1238
  "error_message": None,
@@ -959,6 +1243,9 @@ class OperationsListResponse(BaseModel):
959
1243
  )
960
1244
 
961
1245
  bank_id: str
1246
+ total: int
1247
+ limit: int
1248
+ offset: int
962
1249
  operations: list[OperationResponse]
963
1250
 
964
1251
 
@@ -980,6 +1267,76 @@ class CancelOperationResponse(BaseModel):
980
1267
  operation_id: str
981
1268
 
982
1269
 
1270
+ class OperationStatusResponse(BaseModel):
1271
+ """Response model for getting a single operation status."""
1272
+
1273
+ model_config = ConfigDict(
1274
+ json_schema_extra={
1275
+ "example": {
1276
+ "operation_id": "550e8400-e29b-41d4-a716-446655440000",
1277
+ "status": "completed",
1278
+ "operation_type": "refresh_mental_models",
1279
+ "created_at": "2024-01-15T10:30:00Z",
1280
+ "updated_at": "2024-01-15T10:31:30Z",
1281
+ "completed_at": "2024-01-15T10:31:30Z",
1282
+ "error_message": None,
1283
+ }
1284
+ }
1285
+ )
1286
+
1287
+ operation_id: str
1288
+ status: Literal["pending", "completed", "failed", "not_found"]
1289
+ operation_type: str | None = None
1290
+ created_at: str | None = None
1291
+ updated_at: str | None = None
1292
+ completed_at: str | None = None
1293
+ error_message: str | None = None
1294
+
1295
+
1296
+ class AsyncOperationSubmitResponse(BaseModel):
1297
+ """Response model for submitting an async operation."""
1298
+
1299
+ model_config = ConfigDict(
1300
+ json_schema_extra={
1301
+ "example": {
1302
+ "operation_id": "550e8400-e29b-41d4-a716-446655440000",
1303
+ "status": "queued",
1304
+ }
1305
+ }
1306
+ )
1307
+
1308
+ operation_id: str
1309
+ status: str
1310
+
1311
+
1312
+ class FeaturesInfo(BaseModel):
1313
+ """Feature flags indicating which capabilities are enabled."""
1314
+
1315
+ observations: bool = Field(description="Whether observations (auto-consolidation) are enabled")
1316
+ mcp: bool = Field(description="Whether MCP (Model Context Protocol) server is enabled")
1317
+ worker: bool = Field(description="Whether the background worker is enabled")
1318
+
1319
+
1320
+ class VersionResponse(BaseModel):
1321
+ """Response model for the version/info endpoint."""
1322
+
1323
+ model_config = ConfigDict(
1324
+ json_schema_extra={
1325
+ "example": {
1326
+ "api_version": "1.0.0",
1327
+ "features": {
1328
+ "observations": False,
1329
+ "mcp": True,
1330
+ "worker": True,
1331
+ },
1332
+ }
1333
+ }
1334
+ )
1335
+
1336
+ api_version: str = Field(description="API version string")
1337
+ features: FeaturesInfo = Field(description="Enabled feature flags")
1338
+
1339
+
983
1340
  def create_app(
984
1341
  memory: MemoryEngine,
985
1342
  initialize_memory: bool = True,
@@ -1015,6 +1372,16 @@ def create_app(
1015
1372
  Lifespan context manager for startup and shutdown events.
1016
1373
  Note: This only fires when running the app standalone, not when mounted.
1017
1374
  """
1375
+ import asyncio
1376
+ import socket
1377
+
1378
+ from hindsight_api.config import get_config
1379
+ from hindsight_api.worker import WorkerPoller
1380
+
1381
+ config = get_config()
1382
+ poller = None
1383
+ poller_task = None
1384
+
1018
1385
  # Initialize OpenTelemetry metrics
1019
1386
  try:
1020
1387
  prometheus_reader = initialize_metrics(service_name="hindsight-api", service_version="1.0.0")
@@ -1037,6 +1404,21 @@ def create_app(
1037
1404
  metrics_collector.set_db_pool(memory._pool)
1038
1405
  logging.info("DB pool metrics configured")
1039
1406
 
1407
+ # Start worker poller if enabled (standalone mode)
1408
+ if config.worker_enabled and memory._pool is not None:
1409
+ worker_id = config.worker_id or socket.gethostname()
1410
+ poller = WorkerPoller(
1411
+ pool=memory._pool,
1412
+ worker_id=worker_id,
1413
+ executor=memory.execute_task,
1414
+ poll_interval_ms=config.worker_poll_interval_ms,
1415
+ batch_size=config.worker_batch_size,
1416
+ max_retries=config.worker_max_retries,
1417
+ tenant_extension=getattr(memory, "_tenant_extension", None),
1418
+ )
1419
+ poller_task = asyncio.create_task(poller.run())
1420
+ logging.info(f"Worker poller started (worker_id={worker_id})")
1421
+
1040
1422
  # Call HTTP extension startup hook
1041
1423
  if http_extension:
1042
1424
  await http_extension.on_startup()
@@ -1044,6 +1426,17 @@ def create_app(
1044
1426
 
1045
1427
  yield
1046
1428
 
1429
+ # Shutdown worker poller if running
1430
+ if poller is not None:
1431
+ await poller.shutdown_graceful(timeout=30.0)
1432
+ if poller_task is not None:
1433
+ poller_task.cancel()
1434
+ try:
1435
+ await poller_task
1436
+ except asyncio.CancelledError:
1437
+ pass
1438
+ logging.info("Worker poller stopped")
1439
+
1047
1440
  # Call HTTP extension shutdown hook
1048
1441
  if http_extension:
1049
1442
  await http_extension.on_shutdown()
@@ -1158,6 +1551,34 @@ def _register_routes(app: FastAPI):
1158
1551
  status_code = 200 if health.get("status") == "healthy" else 503
1159
1552
  return JSONResponse(content=health, status_code=status_code)
1160
1553
 
1554
+ @app.get(
1555
+ "/version",
1556
+ response_model=VersionResponse,
1557
+ summary="Get API version and feature flags",
1558
+ description="Returns API version information and enabled feature flags. "
1559
+ "Use this to check which capabilities are available in this deployment.",
1560
+ tags=["Monitoring"],
1561
+ operation_id="get_version",
1562
+ )
1563
+ async def version_endpoint() -> VersionResponse:
1564
+ """
1565
+ Get API version and enabled features.
1566
+
1567
+ Returns version info and feature flags that can be used by clients
1568
+ to determine which capabilities are available.
1569
+ """
1570
+ from hindsight_api.config import get_config
1571
+
1572
+ config = get_config()
1573
+ return VersionResponse(
1574
+ api_version="1.0.0",
1575
+ features=FeaturesInfo(
1576
+ observations=config.enable_observations,
1577
+ mcp=config.mcp_enabled,
1578
+ worker=config.worker_enabled,
1579
+ ),
1580
+ )
1581
+
1161
1582
  @app.get(
1162
1583
  "/metrics",
1163
1584
  summary="Prometheus metrics endpoint",
@@ -1301,8 +1722,10 @@ def _register_routes(app: FastAPI):
1301
1722
  metrics = get_metrics_collector()
1302
1723
 
1303
1724
  try:
1304
- # Default to world, experience, opinion if not specified (exclude observation by default)
1725
+ # Default to world and experience if not specified (exclude observation and opinion)
1726
+ # Filter out 'opinion' even if requested - opinions are excluded from recall
1305
1727
  fact_types = request.types if request.types else list(VALID_RECALL_FACT_TYPES)
1728
+ fact_types = [ft for ft in fact_types if ft != "opinion"]
1306
1729
 
1307
1730
  # Parse query_timestamp if provided
1308
1731
  question_date = None
@@ -1391,7 +1814,10 @@ def _register_routes(app: FastAPI):
1391
1814
  )
1392
1815
 
1393
1816
  response = RecallResponse(
1394
- results=recall_results, trace=core_result.trace, entities=entities_response, chunks=chunks_response
1817
+ results=recall_results,
1818
+ trace=core_result.trace,
1819
+ entities=entities_response,
1820
+ chunks=chunks_response,
1395
1821
  )
1396
1822
 
1397
1823
  handler_duration = time.time() - handler_start
@@ -1442,13 +1868,18 @@ def _register_routes(app: FastAPI):
1442
1868
  metrics = get_metrics_collector()
1443
1869
 
1444
1870
  try:
1871
+ # Handle deprecated context field by concatenating with query
1872
+ query = request.query
1873
+ if request.context:
1874
+ query = f"{request.query}\n\nAdditional context: {request.context}"
1875
+
1445
1876
  # Use the memory system's reflect_async method (record metrics)
1446
1877
  with metrics.record_operation("reflect", bank_id=bank_id, source="api", budget=request.budget.value):
1447
1878
  core_result = await app.state.memory.reflect_async(
1448
1879
  bank_id=bank_id,
1449
- query=request.query,
1880
+ query=query,
1450
1881
  budget=request.budget,
1451
- context=request.context,
1882
+ context=None, # Deprecated, now concatenated with query
1452
1883
  max_tokens=request.max_tokens,
1453
1884
  response_schema=request.response_schema,
1454
1885
  request_context=request_context,
@@ -1456,27 +1887,73 @@ def _register_routes(app: FastAPI):
1456
1887
  tags_match=request.tags_match,
1457
1888
  )
1458
1889
 
1459
- # Convert core MemoryFact objects to API ReflectFact objects if facts are requested
1460
- based_on_facts = []
1890
+ # Build based_on (memories + mental_models + directives) if facts are requested
1891
+ based_on_result: ReflectBasedOn | None = None
1461
1892
  if request.include.facts is not None:
1893
+ memories = []
1894
+ mental_models = []
1895
+ directives = []
1462
1896
  for fact_type, facts in core_result.based_on.items():
1463
- for fact in facts:
1464
- based_on_facts.append(
1465
- ReflectFact(
1466
- id=fact.id,
1467
- text=fact.text,
1468
- type=fact.fact_type,
1469
- context=fact.context,
1470
- occurred_start=fact.occurred_start,
1471
- occurred_end=fact.occurred_end,
1897
+ if fact_type == "directives":
1898
+ # Directives have different structure (id, name, content)
1899
+ for directive in facts:
1900
+ directives.append(
1901
+ ReflectDirective(
1902
+ id=directive.id,
1903
+ name=directive.name,
1904
+ content=directive.content,
1905
+ )
1906
+ )
1907
+ elif fact_type == "mental_models":
1908
+ # Mental models are MemoryFact with type "mental_models"
1909
+ for fact in facts:
1910
+ mental_models.append(
1911
+ ReflectMentalModel(
1912
+ id=fact.id,
1913
+ text=fact.text,
1914
+ context=fact.context,
1915
+ )
1472
1916
  )
1473
- )
1917
+ else:
1918
+ for fact in facts:
1919
+ memories.append(
1920
+ ReflectFact(
1921
+ id=fact.id,
1922
+ text=fact.text,
1923
+ type=fact.fact_type,
1924
+ context=fact.context,
1925
+ occurred_start=fact.occurred_start,
1926
+ occurred_end=fact.occurred_end,
1927
+ )
1928
+ )
1929
+ based_on_result = ReflectBasedOn(memories=memories, mental_models=mental_models, directives=directives)
1930
+
1931
+ # Build trace (tool_calls + llm_calls + observations) if tool_calls is requested
1932
+ trace_result: ReflectTrace | None = None
1933
+ if request.include.tool_calls is not None:
1934
+ include_output = request.include.tool_calls.output
1935
+ tool_calls = [
1936
+ ReflectToolCall(
1937
+ tool=tc.tool,
1938
+ input=tc.input,
1939
+ output=tc.output if include_output else None,
1940
+ duration_ms=tc.duration_ms,
1941
+ iteration=tc.iteration,
1942
+ )
1943
+ for tc in core_result.tool_trace
1944
+ ]
1945
+ llm_calls = [ReflectLLMCall(scope=lc.scope, duration_ms=lc.duration_ms) for lc in core_result.llm_trace]
1946
+ trace_result = ReflectTrace(
1947
+ tool_calls=tool_calls,
1948
+ llm_calls=llm_calls,
1949
+ )
1474
1950
 
1475
1951
  return ReflectResponse(
1476
1952
  text=core_result.text,
1477
- based_on=based_on_facts,
1953
+ based_on=based_on_result,
1478
1954
  structured_output=core_result.structured_output,
1479
1955
  usage=core_result.usage,
1956
+ trace=trace_result,
1480
1957
  )
1481
1958
 
1482
1959
  except OperationValidationError as e:
@@ -1602,6 +2079,31 @@ def _register_routes(app: FastAPI):
1602
2079
  )
1603
2080
  total_documents = doc_count_result["count"] if doc_count_result else 0
1604
2081
 
2082
+ # Get consolidation stats from memory-level tracking
2083
+ consolidation_stats = await conn.fetchrow(
2084
+ f"""
2085
+ SELECT
2086
+ MAX(consolidated_at) as last_consolidated_at,
2087
+ COUNT(*) FILTER (WHERE consolidated_at IS NULL AND fact_type IN ('experience', 'world')) as pending
2088
+ FROM {fq_table("memory_units")}
2089
+ WHERE bank_id = $1
2090
+ """,
2091
+ bank_id,
2092
+ )
2093
+ last_consolidated_at = consolidation_stats["last_consolidated_at"] if consolidation_stats else None
2094
+ pending_consolidation = consolidation_stats["pending"] if consolidation_stats else 0
2095
+
2096
+ # Count total observations (consolidated knowledge)
2097
+ observation_count_result = await conn.fetchrow(
2098
+ f"""
2099
+ SELECT COUNT(*) as count
2100
+ FROM {fq_table("memory_units")}
2101
+ WHERE bank_id = $1 AND fact_type = 'observation'
2102
+ """,
2103
+ bank_id,
2104
+ )
2105
+ total_observations = observation_count_result["count"] if observation_count_result else 0
2106
+
1605
2107
  # Format results
1606
2108
  nodes_by_type = {row["fact_type"]: row["count"] for row in node_stats}
1607
2109
  links_by_type = {row["link_type"]: row["count"] for row in link_stats}
@@ -1631,6 +2133,9 @@ def _register_routes(app: FastAPI):
1631
2133
  links_breakdown=links_breakdown,
1632
2134
  pending_operations=pending_operations,
1633
2135
  failed_operations=failed_operations,
2136
+ last_consolidated_at=(last_consolidated_at.isoformat() if last_consolidated_at else None),
2137
+ pending_consolidation=pending_consolidation,
2138
+ total_observations=total_observations,
1634
2139
  )
1635
2140
 
1636
2141
  except (AuthenticationError, HTTPException):
@@ -1718,54 +2223,422 @@ def _register_routes(app: FastAPI):
1718
2223
  @app.post(
1719
2224
  "/v1/default/banks/{bank_id}/entities/{entity_id}/regenerate",
1720
2225
  response_model=EntityDetailResponse,
1721
- summary="Regenerate entity observations",
1722
- description="Regenerate observations for an entity based on all facts mentioning it.",
2226
+ summary="Regenerate entity observations (deprecated)",
2227
+ description="This endpoint is deprecated. Entity observations have been replaced by mental models.",
1723
2228
  operation_id="regenerate_entity_observations",
1724
2229
  tags=["Entities"],
2230
+ deprecated=True,
1725
2231
  )
1726
2232
  async def api_regenerate_entity_observations(
1727
2233
  bank_id: str,
1728
2234
  entity_id: str,
1729
2235
  request_context: RequestContext = Depends(get_request_context),
1730
2236
  ):
1731
- """Regenerate observations for an entity."""
2237
+ """Regenerate observations for an entity. DEPRECATED."""
2238
+ raise HTTPException(
2239
+ status_code=410,
2240
+ detail="This endpoint is deprecated. Entity observations are no longer supported.",
2241
+ )
2242
+
2243
+ # =========================================================================
2244
+ # =========================================================================
2245
+ # MENTAL MODELS ENDPOINTS (stored reflect responses)
2246
+ # =========================================================================
2247
+
2248
+ @app.get(
2249
+ "/v1/default/banks/{bank_id}/mental-models",
2250
+ response_model=MentalModelListResponse,
2251
+ summary="List mental models",
2252
+ description="List user-curated living documents that stay current.",
2253
+ operation_id="list_mental_models",
2254
+ tags=["Mental Models"],
2255
+ )
2256
+ async def api_list_mental_models(
2257
+ bank_id: str,
2258
+ tags_filter: list[str] | None = Query(None, alias="tags", description="Filter by tags"),
2259
+ tags_match: Literal["any", "all", "exact"] = Query("any", description="How to match tags"),
2260
+ limit: int = Query(100, ge=1, le=1000),
2261
+ offset: int = Query(0, ge=0),
2262
+ request_context: RequestContext = Depends(get_request_context),
2263
+ ):
2264
+ """List mental models for a bank."""
1732
2265
  try:
1733
- # Get the entity to verify it exists and get canonical_name
1734
- entity = await app.state.memory.get_entity(bank_id, entity_id, request_context=request_context)
2266
+ mental_models = await app.state.memory.list_mental_models(
2267
+ bank_id=bank_id,
2268
+ tags=tags_filter,
2269
+ tags_match=tags_match,
2270
+ limit=limit,
2271
+ offset=offset,
2272
+ request_context=request_context,
2273
+ )
2274
+ return MentalModelListResponse(items=[MentalModelResponse(**m) for m in mental_models])
2275
+ except (AuthenticationError, HTTPException):
2276
+ raise
2277
+ except Exception as e:
2278
+ import traceback
1735
2279
 
1736
- if entity is None:
1737
- raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
2280
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
2281
+ logger.error(f"Error in GET /v1/default/banks/{bank_id}/mental-models: {error_detail}")
2282
+ raise HTTPException(status_code=500, detail=str(e))
1738
2283
 
1739
- # Regenerate observations
1740
- await app.state.memory.regenerate_entity_observations(
2284
+ @app.get(
2285
+ "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}",
2286
+ response_model=MentalModelResponse,
2287
+ summary="Get mental model",
2288
+ description="Get a specific mental model by ID.",
2289
+ operation_id="get_mental_model",
2290
+ tags=["Mental Models"],
2291
+ )
2292
+ async def api_get_mental_model(
2293
+ bank_id: str,
2294
+ mental_model_id: str,
2295
+ request_context: RequestContext = Depends(get_request_context),
2296
+ ):
2297
+ """Get a mental model by ID."""
2298
+ try:
2299
+ mental_model = await app.state.memory.get_mental_model(
1741
2300
  bank_id=bank_id,
1742
- entity_id=entity_id,
1743
- entity_name=entity["canonical_name"],
2301
+ mental_model_id=mental_model_id,
1744
2302
  request_context=request_context,
1745
2303
  )
2304
+ if mental_model is None:
2305
+ raise HTTPException(status_code=404, detail=f"Mental model '{mental_model_id}' not found")
2306
+ return MentalModelResponse(**mental_model)
2307
+ except (AuthenticationError, HTTPException):
2308
+ raise
2309
+ except Exception as e:
2310
+ import traceback
1746
2311
 
1747
- # Get updated entity with new observations
1748
- entity = await app.state.memory.get_entity(bank_id, entity_id, request_context=request_context)
2312
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
2313
+ logger.error(f"Error in GET /v1/default/banks/{bank_id}/mental-models/{mental_model_id}: {error_detail}")
2314
+ raise HTTPException(status_code=500, detail=str(e))
1749
2315
 
1750
- return EntityDetailResponse(
1751
- id=entity["id"],
1752
- canonical_name=entity["canonical_name"],
1753
- mention_count=entity["mention_count"],
1754
- first_seen=entity["first_seen"],
1755
- last_seen=entity["last_seen"],
1756
- metadata=_parse_metadata(entity["metadata"]),
1757
- observations=[
1758
- EntityObservationResponse(text=obs.text, mentioned_at=obs.mentioned_at)
1759
- for obs in entity["observations"]
1760
- ],
2316
+ @app.post(
2317
+ "/v1/default/banks/{bank_id}/mental-models",
2318
+ response_model=CreateMentalModelResponse,
2319
+ summary="Create mental model",
2320
+ description="Create a mental model by running reflect with the source query in the background. "
2321
+ "Returns an operation ID to track progress. The content is auto-generated by the reflect endpoint. "
2322
+ "Use the operations endpoint to check completion status.",
2323
+ operation_id="create_mental_model",
2324
+ tags=["Mental Models"],
2325
+ )
2326
+ async def api_create_mental_model(
2327
+ bank_id: str,
2328
+ body: CreateMentalModelRequest,
2329
+ request_context: RequestContext = Depends(get_request_context),
2330
+ ):
2331
+ """Create a mental model (async - returns operation_id)."""
2332
+ try:
2333
+ # 1. Create the mental model with placeholder content
2334
+ mental_model = await app.state.memory.create_mental_model(
2335
+ bank_id=bank_id,
2336
+ name=body.name,
2337
+ source_query=body.source_query,
2338
+ content="Generating content...",
2339
+ tags=body.tags if body.tags else None,
2340
+ max_tokens=body.max_tokens,
2341
+ trigger=body.trigger.model_dump() if body.trigger else None,
2342
+ request_context=request_context,
2343
+ )
2344
+ # 2. Schedule a refresh to generate the actual content
2345
+ result = await app.state.memory.submit_async_refresh_mental_model(
2346
+ bank_id=bank_id,
2347
+ mental_model_id=mental_model["id"],
2348
+ request_context=request_context,
2349
+ )
2350
+ return CreateMentalModelResponse(operation_id=result["operation_id"])
2351
+ except ValueError as e:
2352
+ raise HTTPException(status_code=400, detail=str(e))
2353
+ except (AuthenticationError, HTTPException):
2354
+ raise
2355
+ except Exception as e:
2356
+ import traceback
2357
+
2358
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
2359
+ logger.error(f"Error in POST /v1/default/banks/{bank_id}/mental-models: {error_detail}")
2360
+ raise HTTPException(status_code=500, detail=str(e))
2361
+
2362
+ @app.post(
2363
+ "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/refresh",
2364
+ response_model=AsyncOperationSubmitResponse,
2365
+ summary="Refresh mental model",
2366
+ description="Submit an async task to re-run the source query through reflect and update the content.",
2367
+ operation_id="refresh_mental_model",
2368
+ tags=["Mental Models"],
2369
+ )
2370
+ async def api_refresh_mental_model(
2371
+ bank_id: str,
2372
+ mental_model_id: str,
2373
+ request_context: RequestContext = Depends(get_request_context),
2374
+ ):
2375
+ """Refresh a mental model by re-running its source query (async)."""
2376
+ try:
2377
+ result = await app.state.memory.submit_async_refresh_mental_model(
2378
+ bank_id=bank_id,
2379
+ mental_model_id=mental_model_id,
2380
+ request_context=request_context,
2381
+ )
2382
+ return AsyncOperationSubmitResponse(operation_id=result["operation_id"], status="queued")
2383
+ except ValueError as e:
2384
+ raise HTTPException(status_code=404, detail=str(e))
2385
+ except (AuthenticationError, HTTPException):
2386
+ raise
2387
+ except Exception as e:
2388
+ import traceback
2389
+
2390
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
2391
+ logger.error(
2392
+ f"Error in POST /v1/default/banks/{bank_id}/mental-models/{mental_model_id}/refresh: {error_detail}"
2393
+ )
2394
+ raise HTTPException(status_code=500, detail=str(e))
2395
+
2396
+ @app.patch(
2397
+ "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}",
2398
+ response_model=MentalModelResponse,
2399
+ summary="Update mental model",
2400
+ description="Update a mental model's name and/or source query.",
2401
+ operation_id="update_mental_model",
2402
+ tags=["Mental Models"],
2403
+ )
2404
+ async def api_update_mental_model(
2405
+ bank_id: str,
2406
+ mental_model_id: str,
2407
+ body: UpdateMentalModelRequest,
2408
+ request_context: RequestContext = Depends(get_request_context),
2409
+ ):
2410
+ """Update a mental model."""
2411
+ try:
2412
+ mental_model = await app.state.memory.update_mental_model(
2413
+ bank_id=bank_id,
2414
+ mental_model_id=mental_model_id,
2415
+ name=body.name,
2416
+ source_query=body.source_query,
2417
+ max_tokens=body.max_tokens,
2418
+ tags=body.tags,
2419
+ trigger=body.trigger.model_dump() if body.trigger else None,
2420
+ request_context=request_context,
2421
+ )
2422
+ if mental_model is None:
2423
+ raise HTTPException(status_code=404, detail=f"Mental model '{mental_model_id}' not found")
2424
+ return MentalModelResponse(**mental_model)
2425
+ except (AuthenticationError, HTTPException):
2426
+ raise
2427
+ except Exception as e:
2428
+ import traceback
2429
+
2430
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
2431
+ logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/mental-models/{mental_model_id}: {error_detail}")
2432
+ raise HTTPException(status_code=500, detail=str(e))
2433
+
2434
+ @app.delete(
2435
+ "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}",
2436
+ summary="Delete mental model",
2437
+ description="Delete a mental model.",
2438
+ operation_id="delete_mental_model",
2439
+ tags=["Mental Models"],
2440
+ )
2441
+ async def api_delete_mental_model(
2442
+ bank_id: str,
2443
+ mental_model_id: str,
2444
+ request_context: RequestContext = Depends(get_request_context),
2445
+ ):
2446
+ """Delete a mental model."""
2447
+ try:
2448
+ deleted = await app.state.memory.delete_mental_model(
2449
+ bank_id=bank_id,
2450
+ mental_model_id=mental_model_id,
2451
+ request_context=request_context,
2452
+ )
2453
+ if not deleted:
2454
+ raise HTTPException(status_code=404, detail=f"Mental model '{mental_model_id}' not found")
2455
+ return {"status": "deleted"}
2456
+ except (AuthenticationError, HTTPException):
2457
+ raise
2458
+ except Exception as e:
2459
+ import traceback
2460
+
2461
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
2462
+ logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/mental-models/{mental_model_id}: {error_detail}")
2463
+ raise HTTPException(status_code=500, detail=str(e))
2464
+
2465
+ # =========================================================================
2466
+ # DIRECTIVES ENDPOINTS
2467
+ # =========================================================================
2468
+
2469
+ @app.get(
2470
+ "/v1/default/banks/{bank_id}/directives",
2471
+ response_model=DirectiveListResponse,
2472
+ summary="List directives",
2473
+ description="List hard rules that are injected into prompts.",
2474
+ operation_id="list_directives",
2475
+ tags=["Directives"],
2476
+ )
2477
+ async def api_list_directives(
2478
+ bank_id: str,
2479
+ tags_filter: list[str] | None = Query(None, alias="tags", description="Filter by tags"),
2480
+ tags_match: Literal["any", "all", "exact"] = Query("any", description="How to match tags"),
2481
+ active_only: bool = Query(True, description="Only return active directives"),
2482
+ limit: int = Query(100, ge=1, le=1000),
2483
+ offset: int = Query(0, ge=0),
2484
+ request_context: RequestContext = Depends(get_request_context),
2485
+ ):
2486
+ """List directives for a bank."""
2487
+ try:
2488
+ directives = await app.state.memory.list_directives(
2489
+ bank_id=bank_id,
2490
+ tags=tags_filter,
2491
+ tags_match=tags_match,
2492
+ active_only=active_only,
2493
+ limit=limit,
2494
+ offset=offset,
2495
+ request_context=request_context,
2496
+ )
2497
+ return DirectiveListResponse(items=[DirectiveResponse(**d) for d in directives])
2498
+ except (AuthenticationError, HTTPException):
2499
+ raise
2500
+ except Exception as e:
2501
+ import traceback
2502
+
2503
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
2504
+ logger.error(f"Error in GET /v1/default/banks/{bank_id}/directives: {error_detail}")
2505
+ raise HTTPException(status_code=500, detail=str(e))
2506
+
2507
+ @app.get(
2508
+ "/v1/default/banks/{bank_id}/directives/{directive_id}",
2509
+ response_model=DirectiveResponse,
2510
+ summary="Get directive",
2511
+ description="Get a specific directive by ID.",
2512
+ operation_id="get_directive",
2513
+ tags=["Directives"],
2514
+ )
2515
+ async def api_get_directive(
2516
+ bank_id: str,
2517
+ directive_id: str,
2518
+ request_context: RequestContext = Depends(get_request_context),
2519
+ ):
2520
+ """Get a directive by ID."""
2521
+ try:
2522
+ directive = await app.state.memory.get_directive(
2523
+ bank_id=bank_id,
2524
+ directive_id=directive_id,
2525
+ request_context=request_context,
2526
+ )
2527
+ if directive is None:
2528
+ raise HTTPException(status_code=404, detail=f"Directive '{directive_id}' not found")
2529
+ return DirectiveResponse(**directive)
2530
+ except (AuthenticationError, HTTPException):
2531
+ raise
2532
+ except Exception as e:
2533
+ import traceback
2534
+
2535
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
2536
+ logger.error(f"Error in GET /v1/default/banks/{bank_id}/directives/{directive_id}: {error_detail}")
2537
+ raise HTTPException(status_code=500, detail=str(e))
2538
+
2539
+ @app.post(
2540
+ "/v1/default/banks/{bank_id}/directives",
2541
+ response_model=DirectiveResponse,
2542
+ summary="Create directive",
2543
+ description="Create a hard rule that will be injected into prompts.",
2544
+ operation_id="create_directive",
2545
+ tags=["Directives"],
2546
+ )
2547
+ async def api_create_directive(
2548
+ bank_id: str,
2549
+ body: CreateDirectiveRequest,
2550
+ request_context: RequestContext = Depends(get_request_context),
2551
+ ):
2552
+ """Create a directive."""
2553
+ try:
2554
+ directive = await app.state.memory.create_directive(
2555
+ bank_id=bank_id,
2556
+ name=body.name,
2557
+ content=body.content,
2558
+ priority=body.priority,
2559
+ is_active=body.is_active,
2560
+ tags=body.tags,
2561
+ request_context=request_context,
2562
+ )
2563
+ return DirectiveResponse(**directive)
2564
+ except ValueError as e:
2565
+ raise HTTPException(status_code=400, detail=str(e))
2566
+ except (AuthenticationError, HTTPException):
2567
+ raise
2568
+ except Exception as e:
2569
+ import traceback
2570
+
2571
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
2572
+ logger.error(f"Error in POST /v1/default/banks/{bank_id}/directives: {error_detail}")
2573
+ raise HTTPException(status_code=500, detail=str(e))
2574
+
2575
+ @app.patch(
2576
+ "/v1/default/banks/{bank_id}/directives/{directive_id}",
2577
+ response_model=DirectiveResponse,
2578
+ summary="Update directive",
2579
+ description="Update a directive's properties.",
2580
+ operation_id="update_directive",
2581
+ tags=["Directives"],
2582
+ )
2583
+ async def api_update_directive(
2584
+ bank_id: str,
2585
+ directive_id: str,
2586
+ body: UpdateDirectiveRequest,
2587
+ request_context: RequestContext = Depends(get_request_context),
2588
+ ):
2589
+ """Update a directive."""
2590
+ try:
2591
+ directive = await app.state.memory.update_directive(
2592
+ bank_id=bank_id,
2593
+ directive_id=directive_id,
2594
+ name=body.name,
2595
+ content=body.content,
2596
+ priority=body.priority,
2597
+ is_active=body.is_active,
2598
+ tags=body.tags,
2599
+ request_context=request_context,
2600
+ )
2601
+ if directive is None:
2602
+ raise HTTPException(status_code=404, detail=f"Directive '{directive_id}' not found")
2603
+ return DirectiveResponse(**directive)
2604
+ except (AuthenticationError, HTTPException):
2605
+ raise
2606
+ except Exception as e:
2607
+ import traceback
2608
+
2609
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
2610
+ logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/directives/{directive_id}: {error_detail}")
2611
+ raise HTTPException(status_code=500, detail=str(e))
2612
+
2613
+ @app.delete(
2614
+ "/v1/default/banks/{bank_id}/directives/{directive_id}",
2615
+ summary="Delete directive",
2616
+ description="Delete a directive.",
2617
+ operation_id="delete_directive",
2618
+ tags=["Directives"],
2619
+ )
2620
+ async def api_delete_directive(
2621
+ bank_id: str,
2622
+ directive_id: str,
2623
+ request_context: RequestContext = Depends(get_request_context),
2624
+ ):
2625
+ """Delete a directive."""
2626
+ try:
2627
+ deleted = await app.state.memory.delete_directive(
2628
+ bank_id=bank_id,
2629
+ directive_id=directive_id,
2630
+ request_context=request_context,
1761
2631
  )
2632
+ if not deleted:
2633
+ raise HTTPException(status_code=404, detail=f"Directive '{directive_id}' not found")
2634
+ return {"status": "deleted"}
1762
2635
  except (AuthenticationError, HTTPException):
1763
2636
  raise
1764
2637
  except Exception as e:
1765
2638
  import traceback
1766
2639
 
1767
2640
  error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
1768
- logger.error(f"Error in /v1/default/banks/{bank_id}/entities/{entity_id}/regenerate: {error_detail}")
2641
+ logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/directives/{directive_id}: {error_detail}")
1769
2642
  raise HTTPException(status_code=500, detail=str(e))
1770
2643
 
1771
2644
  @app.get(
@@ -1968,17 +2841,28 @@ def _register_routes(app: FastAPI):
1968
2841
  "/v1/default/banks/{bank_id}/operations",
1969
2842
  response_model=OperationsListResponse,
1970
2843
  summary="List async operations",
1971
- description="Get a list of all async operations (pending and failed) for a specific agent, including error messages for failed operations",
2844
+ description="Get a list of async operations for a specific agent, with optional filtering by status. Results are sorted by most recent first.",
1972
2845
  operation_id="list_operations",
1973
2846
  tags=["Operations"],
1974
2847
  )
1975
- async def api_list_operations(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
1976
- """List all async operations (pending and failed) for a memory bank."""
2848
+ async def api_list_operations(
2849
+ bank_id: str,
2850
+ status: str | None = Query(default=None, description="Filter by status: pending, completed, or failed"),
2851
+ limit: int = Query(default=20, ge=1, le=100, description="Maximum number of operations to return"),
2852
+ offset: int = Query(default=0, ge=0, description="Number of operations to skip"),
2853
+ request_context: RequestContext = Depends(get_request_context),
2854
+ ):
2855
+ """List async operations for a memory bank with optional filtering and pagination."""
1977
2856
  try:
1978
- operations = await app.state.memory.list_operations(bank_id, request_context=request_context)
2857
+ result = await app.state.memory.list_operations(
2858
+ bank_id, status=status, limit=limit, offset=offset, request_context=request_context
2859
+ )
1979
2860
  return OperationsListResponse(
1980
2861
  bank_id=bank_id,
1981
- operations=[OperationResponse(**op) for op in operations],
2862
+ total=result["total"],
2863
+ limit=limit,
2864
+ offset=offset,
2865
+ operations=[OperationResponse(**op) for op in result["operations"]],
1982
2866
  )
1983
2867
  except (AuthenticationError, HTTPException):
1984
2868
  raise
@@ -1989,6 +2873,37 @@ def _register_routes(app: FastAPI):
1989
2873
  logger.error(f"Error in /v1/default/banks/{bank_id}/operations: {error_detail}")
1990
2874
  raise HTTPException(status_code=500, detail=str(e))
1991
2875
 
2876
+ @app.get(
2877
+ "/v1/default/banks/{bank_id}/operations/{operation_id}",
2878
+ response_model=OperationStatusResponse,
2879
+ summary="Get operation status",
2880
+ description="Get the status of a specific async operation. Returns 'pending', 'completed', or 'failed'. "
2881
+ "Completed operations are removed from storage, so 'completed' means the operation finished successfully.",
2882
+ operation_id="get_operation_status",
2883
+ tags=["Operations"],
2884
+ )
2885
+ async def api_get_operation_status(
2886
+ bank_id: str, operation_id: str, request_context: RequestContext = Depends(get_request_context)
2887
+ ):
2888
+ """Get the status of an async operation."""
2889
+ try:
2890
+ # Validate UUID format
2891
+ try:
2892
+ uuid.UUID(operation_id)
2893
+ except ValueError:
2894
+ raise HTTPException(status_code=400, detail=f"Invalid operation_id format: {operation_id}")
2895
+
2896
+ result = await app.state.memory.get_operation_status(bank_id, operation_id, request_context=request_context)
2897
+ return OperationStatusResponse(**result)
2898
+ except (AuthenticationError, HTTPException):
2899
+ raise
2900
+ except Exception as e:
2901
+ import traceback
2902
+
2903
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
2904
+ logger.error(f"Error in GET /v1/default/banks/{bank_id}/operations/{operation_id}: {error_detail}")
2905
+ raise HTTPException(status_code=500, detail=str(e))
2906
+
1992
2907
  @app.delete(
1993
2908
  "/v1/default/banks/{bank_id}/operations/{operation_id}",
1994
2909
  response_model=CancelOperationResponse,
@@ -2025,12 +2940,12 @@ def _register_routes(app: FastAPI):
2025
2940
  "/v1/default/banks/{bank_id}/profile",
2026
2941
  response_model=BankProfileResponse,
2027
2942
  summary="Get memory bank profile",
2028
- description="Get disposition traits and background for a memory bank. Auto-creates agent with defaults if not exists.",
2943
+ description="Get disposition traits and mission for a memory bank. Auto-creates agent with defaults if not exists.",
2029
2944
  operation_id="get_bank_profile",
2030
2945
  tags=["Banks"],
2031
2946
  )
2032
2947
  async def api_get_bank_profile(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
2033
- """Get memory bank profile (disposition + background)."""
2948
+ """Get memory bank profile (disposition + mission)."""
2034
2949
  try:
2035
2950
  profile = await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
2036
2951
  # Convert DispositionTraits object to dict for Pydantic
@@ -2039,11 +2954,13 @@ def _register_routes(app: FastAPI):
2039
2954
  if hasattr(profile["disposition"], "model_dump")
2040
2955
  else dict(profile["disposition"])
2041
2956
  )
2957
+ mission = profile.get("mission") or ""
2042
2958
  return BankProfileResponse(
2043
2959
  bank_id=bank_id,
2044
2960
  name=profile["name"],
2045
2961
  disposition=DispositionTraits(**disposition_dict),
2046
- background=profile["background"],
2962
+ mission=mission,
2963
+ background=mission, # Backwards compat
2047
2964
  )
2048
2965
  except (AuthenticationError, HTTPException):
2049
2966
  raise
@@ -2079,11 +2996,13 @@ def _register_routes(app: FastAPI):
2079
2996
  if hasattr(profile["disposition"], "model_dump")
2080
2997
  else dict(profile["disposition"])
2081
2998
  )
2999
+ mission = profile.get("mission") or ""
2082
3000
  return BankProfileResponse(
2083
3001
  bank_id=bank_id,
2084
3002
  name=profile["name"],
2085
3003
  disposition=DispositionTraits(**disposition_dict),
2086
- background=profile["background"],
3004
+ mission=mission,
3005
+ background=mission, # Backwards compat
2087
3006
  )
2088
3007
  except (AuthenticationError, HTTPException):
2089
3008
  raise
@@ -2097,25 +3016,22 @@ def _register_routes(app: FastAPI):
2097
3016
  @app.post(
2098
3017
  "/v1/default/banks/{bank_id}/background",
2099
3018
  response_model=BackgroundResponse,
2100
- summary="Add/merge memory bank background",
2101
- description="Add new background information or merge with existing. LLM intelligently resolves conflicts, normalizes to first person, and optionally infers disposition traits.",
3019
+ summary="Add/merge memory bank background (deprecated)",
3020
+ description="Deprecated: Use PUT /mission instead. This endpoint now updates the mission field.",
2102
3021
  operation_id="add_bank_background",
2103
3022
  tags=["Banks"],
3023
+ deprecated=True,
2104
3024
  )
2105
3025
  async def api_add_bank_background(
2106
3026
  bank_id: str, request: AddBackgroundRequest, request_context: RequestContext = Depends(get_request_context)
2107
3027
  ):
2108
- """Add or merge bank background information. Optionally infer disposition traits."""
3028
+ """Deprecated: Add or merge bank background. Now updates mission field."""
2109
3029
  try:
2110
- result = await app.state.memory.merge_bank_background(
2111
- bank_id, request.content, update_disposition=request.update_disposition, request_context=request_context
3030
+ result = await app.state.memory.merge_bank_mission(
3031
+ bank_id, request.content, request_context=request_context
2112
3032
  )
2113
-
2114
- response = BackgroundResponse(background=result["background"])
2115
- if "disposition" in result:
2116
- response.disposition = DispositionTraits(**result["disposition"])
2117
-
2118
- return response
3033
+ mission = result.get("mission") or ""
3034
+ return BackgroundResponse(mission=mission, background=mission)
2119
3035
  except (AuthenticationError, HTTPException):
2120
3036
  raise
2121
3037
  except Exception as e:
@@ -2129,24 +3045,25 @@ def _register_routes(app: FastAPI):
2129
3045
  "/v1/default/banks/{bank_id}",
2130
3046
  response_model=BankProfileResponse,
2131
3047
  summary="Create or update memory bank",
2132
- description="Create a new agent or update existing agent with disposition and background. Auto-fills missing fields with defaults.",
3048
+ description="Create a new agent or update existing agent with disposition and mission. Auto-fills missing fields with defaults.",
2133
3049
  operation_id="create_or_update_bank",
2134
3050
  tags=["Banks"],
2135
3051
  )
2136
3052
  async def api_create_or_update_bank(
2137
3053
  bank_id: str, request: CreateBankRequest, request_context: RequestContext = Depends(get_request_context)
2138
3054
  ):
2139
- """Create or update an agent with disposition and background."""
3055
+ """Create or update an agent with disposition and mission."""
2140
3056
  try:
2141
3057
  # Ensure bank exists by getting profile (auto-creates with defaults)
2142
3058
  await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
2143
3059
 
2144
- # Update name and/or background if provided
2145
- if request.name is not None or request.background is not None:
3060
+ # Update name and/or mission if provided (support both mission and deprecated background)
3061
+ mission_value = request.mission or request.background
3062
+ if request.name is not None or mission_value is not None:
2146
3063
  await app.state.memory.update_bank(
2147
3064
  bank_id,
2148
3065
  name=request.name,
2149
- background=request.background,
3066
+ mission=mission_value,
2150
3067
  request_context=request_context,
2151
3068
  )
2152
3069
 
@@ -2163,11 +3080,13 @@ def _register_routes(app: FastAPI):
2163
3080
  if hasattr(final_profile["disposition"], "model_dump")
2164
3081
  else dict(final_profile["disposition"])
2165
3082
  )
3083
+ mission = final_profile.get("mission") or ""
2166
3084
  return BankProfileResponse(
2167
3085
  bank_id=bank_id,
2168
3086
  name=final_profile["name"],
2169
3087
  disposition=DispositionTraits(**disposition_dict),
2170
- background=final_profile["background"],
3088
+ mission=mission,
3089
+ background=mission, # Backwards compat
2171
3090
  )
2172
3091
  except (AuthenticationError, HTTPException):
2173
3092
  raise
@@ -2178,6 +3097,62 @@ def _register_routes(app: FastAPI):
2178
3097
  logger.error(f"Error in /v1/default/banks/{bank_id}: {error_detail}")
2179
3098
  raise HTTPException(status_code=500, detail=str(e))
2180
3099
 
3100
+ @app.patch(
3101
+ "/v1/default/banks/{bank_id}",
3102
+ response_model=BankProfileResponse,
3103
+ summary="Partial update memory bank",
3104
+ description="Partially update an agent's profile. Only provided fields will be updated.",
3105
+ operation_id="update_bank",
3106
+ tags=["Banks"],
3107
+ )
3108
+ async def api_update_bank(
3109
+ bank_id: str, request: CreateBankRequest, request_context: RequestContext = Depends(get_request_context)
3110
+ ):
3111
+ """Partially update an agent's profile (name, mission, disposition)."""
3112
+ try:
3113
+ # Ensure bank exists
3114
+ await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
3115
+
3116
+ # Update name and/or mission if provided
3117
+ mission_value = request.mission or request.background
3118
+ if request.name is not None or mission_value is not None:
3119
+ await app.state.memory.update_bank(
3120
+ bank_id,
3121
+ name=request.name,
3122
+ mission=mission_value,
3123
+ request_context=request_context,
3124
+ )
3125
+
3126
+ # Update disposition if provided
3127
+ if request.disposition is not None:
3128
+ await app.state.memory.update_bank_disposition(
3129
+ bank_id, request.disposition.model_dump(), request_context=request_context
3130
+ )
3131
+
3132
+ # Get final profile
3133
+ final_profile = await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
3134
+ disposition_dict = (
3135
+ final_profile["disposition"].model_dump()
3136
+ if hasattr(final_profile["disposition"], "model_dump")
3137
+ else dict(final_profile["disposition"])
3138
+ )
3139
+ mission = final_profile.get("mission") or ""
3140
+ return BankProfileResponse(
3141
+ bank_id=bank_id,
3142
+ name=final_profile["name"],
3143
+ disposition=DispositionTraits(**disposition_dict),
3144
+ mission=mission,
3145
+ background=mission, # Backwards compat
3146
+ )
3147
+ except (AuthenticationError, HTTPException):
3148
+ raise
3149
+ except Exception as e:
3150
+ import traceback
3151
+
3152
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
3153
+ logger.error(f"Error in PATCH /v1/default/banks/{bank_id}: {error_detail}")
3154
+ raise HTTPException(status_code=500, detail=str(e))
3155
+
2181
3156
  @app.delete(
2182
3157
  "/v1/default/banks/{bank_id}",
2183
3158
  response_model=DeleteResponse,
@@ -2207,6 +3182,57 @@ def _register_routes(app: FastAPI):
2207
3182
  logger.error(f"Error in DELETE /v1/default/banks/{bank_id}: {error_detail}")
2208
3183
  raise HTTPException(status_code=500, detail=str(e))
2209
3184
 
3185
+ @app.delete(
3186
+ "/v1/default/banks/{bank_id}/observations",
3187
+ response_model=DeleteResponse,
3188
+ summary="Clear all observations",
3189
+ description="Delete all observations for a memory bank. This is useful for resetting the consolidated knowledge.",
3190
+ operation_id="clear_observations",
3191
+ tags=["Banks"],
3192
+ )
3193
+ async def api_clear_observations(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
3194
+ """Clear all observations for a bank."""
3195
+ try:
3196
+ result = await app.state.memory.clear_observations(bank_id, request_context=request_context)
3197
+ return DeleteResponse(
3198
+ success=True,
3199
+ message=f"Cleared {result.get('deleted_count', 0)} observations",
3200
+ deleted_count=result.get("deleted_count", 0),
3201
+ )
3202
+ except (AuthenticationError, HTTPException):
3203
+ raise
3204
+ except Exception as e:
3205
+ import traceback
3206
+
3207
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
3208
+ logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/observations: {error_detail}")
3209
+ raise HTTPException(status_code=500, detail=str(e))
3210
+
3211
+ @app.post(
3212
+ "/v1/default/banks/{bank_id}/consolidate",
3213
+ response_model=ConsolidationResponse,
3214
+ summary="Trigger consolidation",
3215
+ description="Run memory consolidation to create/update observations from recent memories.",
3216
+ operation_id="trigger_consolidation",
3217
+ tags=["Banks"],
3218
+ )
3219
+ async def api_trigger_consolidation(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
3220
+ """Trigger consolidation for a bank (async)."""
3221
+ try:
3222
+ result = await app.state.memory.submit_async_consolidation(bank_id=bank_id, request_context=request_context)
3223
+ return ConsolidationResponse(
3224
+ operation_id=result["operation_id"],
3225
+ deduplicated=result.get("deduplicated", False),
3226
+ )
3227
+ except (AuthenticationError, HTTPException):
3228
+ raise
3229
+ except Exception as e:
3230
+ import traceback
3231
+
3232
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
3233
+ logger.error(f"Error in POST /v1/default/banks/{bank_id}/consolidate: {error_detail}")
3234
+ raise HTTPException(status_code=500, detail=str(e))
3235
+
2210
3236
  @app.post(
2211
3237
  "/v1/default/banks/{bank_id}/memories",
2212
3238
  response_model=RetainResponse,