hindsight-api 0.3.0__py3-none-any.whl → 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. hindsight_api/__init__.py +1 -1
  2. hindsight_api/admin/cli.py +59 -0
  3. hindsight_api/alembic/versions/h3c4d5e6f7g8_mental_models_v4.py +112 -0
  4. hindsight_api/alembic/versions/i4d5e6f7g8h9_delete_opinions.py +41 -0
  5. hindsight_api/alembic/versions/j5e6f7g8h9i0_mental_model_versions.py +95 -0
  6. hindsight_api/alembic/versions/k6f7g8h9i0j1_add_directive_subtype.py +58 -0
  7. hindsight_api/alembic/versions/l7g8h9i0j1k2_add_worker_columns.py +109 -0
  8. hindsight_api/alembic/versions/m8h9i0j1k2l3_mental_model_id_to_text.py +41 -0
  9. hindsight_api/alembic/versions/n9i0j1k2l3m4_learnings_and_pinned_reflections.py +134 -0
  10. hindsight_api/alembic/versions/o0j1k2l3m4n5_migrate_mental_models_data.py +113 -0
  11. hindsight_api/alembic/versions/p1k2l3m4n5o6_new_knowledge_architecture.py +194 -0
  12. hindsight_api/alembic/versions/q2l3m4n5o6p7_fix_mental_model_fact_type.py +50 -0
  13. hindsight_api/alembic/versions/r3m4n5o6p7q8_add_reflect_response_to_reflections.py +47 -0
  14. hindsight_api/alembic/versions/s4n5o6p7q8r9_add_consolidated_at_to_memory_units.py +53 -0
  15. hindsight_api/alembic/versions/t5o6p7q8r9s0_rename_mental_models_to_observations.py +134 -0
  16. hindsight_api/alembic/versions/u6p7q8r9s0t1_mental_models_text_id.py +41 -0
  17. hindsight_api/alembic/versions/v7q8r9s0t1u2_add_max_tokens_to_mental_models.py +50 -0
  18. hindsight_api/api/http.py +1120 -93
  19. hindsight_api/api/mcp.py +11 -191
  20. hindsight_api/config.py +174 -46
  21. hindsight_api/engine/consolidation/__init__.py +5 -0
  22. hindsight_api/engine/consolidation/consolidator.py +926 -0
  23. hindsight_api/engine/consolidation/prompts.py +77 -0
  24. hindsight_api/engine/cross_encoder.py +153 -22
  25. hindsight_api/engine/directives/__init__.py +5 -0
  26. hindsight_api/engine/directives/models.py +37 -0
  27. hindsight_api/engine/embeddings.py +136 -13
  28. hindsight_api/engine/interface.py +32 -13
  29. hindsight_api/engine/llm_wrapper.py +505 -43
  30. hindsight_api/engine/memory_engine.py +2101 -1094
  31. hindsight_api/engine/mental_models/__init__.py +14 -0
  32. hindsight_api/engine/mental_models/models.py +53 -0
  33. hindsight_api/engine/reflect/__init__.py +18 -0
  34. hindsight_api/engine/reflect/agent.py +933 -0
  35. hindsight_api/engine/reflect/models.py +109 -0
  36. hindsight_api/engine/reflect/observations.py +186 -0
  37. hindsight_api/engine/reflect/prompts.py +483 -0
  38. hindsight_api/engine/reflect/tools.py +437 -0
  39. hindsight_api/engine/reflect/tools_schema.py +250 -0
  40. hindsight_api/engine/response_models.py +130 -4
  41. hindsight_api/engine/retain/bank_utils.py +79 -201
  42. hindsight_api/engine/retain/fact_extraction.py +81 -48
  43. hindsight_api/engine/retain/fact_storage.py +5 -8
  44. hindsight_api/engine/retain/link_utils.py +5 -8
  45. hindsight_api/engine/retain/orchestrator.py +1 -55
  46. hindsight_api/engine/retain/types.py +2 -2
  47. hindsight_api/engine/search/graph_retrieval.py +2 -2
  48. hindsight_api/engine/search/link_expansion_retrieval.py +164 -29
  49. hindsight_api/engine/search/mpfp_retrieval.py +1 -1
  50. hindsight_api/engine/search/retrieval.py +14 -14
  51. hindsight_api/engine/search/think_utils.py +41 -140
  52. hindsight_api/engine/search/trace.py +0 -1
  53. hindsight_api/engine/search/tracer.py +2 -5
  54. hindsight_api/engine/search/types.py +0 -3
  55. hindsight_api/engine/task_backend.py +112 -196
  56. hindsight_api/engine/utils.py +0 -151
  57. hindsight_api/extensions/__init__.py +10 -1
  58. hindsight_api/extensions/builtin/tenant.py +11 -4
  59. hindsight_api/extensions/operation_validator.py +81 -4
  60. hindsight_api/extensions/tenant.py +26 -0
  61. hindsight_api/main.py +28 -5
  62. hindsight_api/mcp_local.py +12 -53
  63. hindsight_api/mcp_tools.py +494 -0
  64. hindsight_api/models.py +0 -2
  65. hindsight_api/worker/__init__.py +11 -0
  66. hindsight_api/worker/main.py +296 -0
  67. hindsight_api/worker/poller.py +486 -0
  68. {hindsight_api-0.3.0.dist-info → hindsight_api-0.4.1.dist-info}/METADATA +12 -6
  69. hindsight_api-0.4.1.dist-info/RECORD +112 -0
  70. {hindsight_api-0.3.0.dist-info → hindsight_api-0.4.1.dist-info}/entry_points.txt +1 -0
  71. hindsight_api/engine/retain/observation_regeneration.py +0 -254
  72. hindsight_api/engine/search/observation_utils.py +0 -125
  73. hindsight_api/engine/search/scoring.py +0 -159
  74. hindsight_api-0.3.0.dist-info/RECORD +0 -82
  75. {hindsight_api-0.3.0.dist-info → hindsight_api-0.4.1.dist-info}/WHEEL +0 -0
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": "0.4.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,35 @@ 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 import __version__
1571
+ from hindsight_api.config import get_config
1572
+
1573
+ config = get_config()
1574
+ return VersionResponse(
1575
+ api_version=__version__,
1576
+ features=FeaturesInfo(
1577
+ observations=config.enable_observations,
1578
+ mcp=config.mcp_enabled,
1579
+ worker=config.worker_enabled,
1580
+ ),
1581
+ )
1582
+
1161
1583
  @app.get(
1162
1584
  "/metrics",
1163
1585
  summary="Prometheus metrics endpoint",
@@ -1301,8 +1723,10 @@ def _register_routes(app: FastAPI):
1301
1723
  metrics = get_metrics_collector()
1302
1724
 
1303
1725
  try:
1304
- # Default to world, experience, opinion if not specified (exclude observation by default)
1726
+ # Default to world and experience if not specified (exclude observation and opinion)
1727
+ # Filter out 'opinion' even if requested - opinions are excluded from recall
1305
1728
  fact_types = request.types if request.types else list(VALID_RECALL_FACT_TYPES)
1729
+ fact_types = [ft for ft in fact_types if ft != "opinion"]
1306
1730
 
1307
1731
  # Parse query_timestamp if provided
1308
1732
  question_date = None
@@ -1391,7 +1815,10 @@ def _register_routes(app: FastAPI):
1391
1815
  )
1392
1816
 
1393
1817
  response = RecallResponse(
1394
- results=recall_results, trace=core_result.trace, entities=entities_response, chunks=chunks_response
1818
+ results=recall_results,
1819
+ trace=core_result.trace,
1820
+ entities=entities_response,
1821
+ chunks=chunks_response,
1395
1822
  )
1396
1823
 
1397
1824
  handler_duration = time.time() - handler_start
@@ -1442,13 +1869,18 @@ def _register_routes(app: FastAPI):
1442
1869
  metrics = get_metrics_collector()
1443
1870
 
1444
1871
  try:
1872
+ # Handle deprecated context field by concatenating with query
1873
+ query = request.query
1874
+ if request.context:
1875
+ query = f"{request.query}\n\nAdditional context: {request.context}"
1876
+
1445
1877
  # Use the memory system's reflect_async method (record metrics)
1446
1878
  with metrics.record_operation("reflect", bank_id=bank_id, source="api", budget=request.budget.value):
1447
1879
  core_result = await app.state.memory.reflect_async(
1448
1880
  bank_id=bank_id,
1449
- query=request.query,
1881
+ query=query,
1450
1882
  budget=request.budget,
1451
- context=request.context,
1883
+ context=None, # Deprecated, now concatenated with query
1452
1884
  max_tokens=request.max_tokens,
1453
1885
  response_schema=request.response_schema,
1454
1886
  request_context=request_context,
@@ -1456,27 +1888,73 @@ def _register_routes(app: FastAPI):
1456
1888
  tags_match=request.tags_match,
1457
1889
  )
1458
1890
 
1459
- # Convert core MemoryFact objects to API ReflectFact objects if facts are requested
1460
- based_on_facts = []
1891
+ # Build based_on (memories + mental_models + directives) if facts are requested
1892
+ based_on_result: ReflectBasedOn | None = None
1461
1893
  if request.include.facts is not None:
1894
+ memories = []
1895
+ mental_models = []
1896
+ directives = []
1462
1897
  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,
1898
+ if fact_type == "directives":
1899
+ # Directives have different structure (id, name, content)
1900
+ for directive in facts:
1901
+ directives.append(
1902
+ ReflectDirective(
1903
+ id=directive.id,
1904
+ name=directive.name,
1905
+ content=directive.content,
1906
+ )
1907
+ )
1908
+ elif fact_type == "mental_models":
1909
+ # Mental models are MemoryFact with type "mental_models"
1910
+ for fact in facts:
1911
+ mental_models.append(
1912
+ ReflectMentalModel(
1913
+ id=fact.id,
1914
+ text=fact.text,
1915
+ context=fact.context,
1916
+ )
1472
1917
  )
1473
- )
1918
+ else:
1919
+ for fact in facts:
1920
+ memories.append(
1921
+ ReflectFact(
1922
+ id=fact.id,
1923
+ text=fact.text,
1924
+ type=fact.fact_type,
1925
+ context=fact.context,
1926
+ occurred_start=fact.occurred_start,
1927
+ occurred_end=fact.occurred_end,
1928
+ )
1929
+ )
1930
+ based_on_result = ReflectBasedOn(memories=memories, mental_models=mental_models, directives=directives)
1931
+
1932
+ # Build trace (tool_calls + llm_calls + observations) if tool_calls is requested
1933
+ trace_result: ReflectTrace | None = None
1934
+ if request.include.tool_calls is not None:
1935
+ include_output = request.include.tool_calls.output
1936
+ tool_calls = [
1937
+ ReflectToolCall(
1938
+ tool=tc.tool,
1939
+ input=tc.input,
1940
+ output=tc.output if include_output else None,
1941
+ duration_ms=tc.duration_ms,
1942
+ iteration=tc.iteration,
1943
+ )
1944
+ for tc in core_result.tool_trace
1945
+ ]
1946
+ llm_calls = [ReflectLLMCall(scope=lc.scope, duration_ms=lc.duration_ms) for lc in core_result.llm_trace]
1947
+ trace_result = ReflectTrace(
1948
+ tool_calls=tool_calls,
1949
+ llm_calls=llm_calls,
1950
+ )
1474
1951
 
1475
1952
  return ReflectResponse(
1476
1953
  text=core_result.text,
1477
- based_on=based_on_facts,
1954
+ based_on=based_on_result,
1478
1955
  structured_output=core_result.structured_output,
1479
1956
  usage=core_result.usage,
1957
+ trace=trace_result,
1480
1958
  )
1481
1959
 
1482
1960
  except OperationValidationError as e:
@@ -1602,6 +2080,31 @@ def _register_routes(app: FastAPI):
1602
2080
  )
1603
2081
  total_documents = doc_count_result["count"] if doc_count_result else 0
1604
2082
 
2083
+ # Get consolidation stats from memory-level tracking
2084
+ consolidation_stats = await conn.fetchrow(
2085
+ f"""
2086
+ SELECT
2087
+ MAX(consolidated_at) as last_consolidated_at,
2088
+ COUNT(*) FILTER (WHERE consolidated_at IS NULL AND fact_type IN ('experience', 'world')) as pending
2089
+ FROM {fq_table("memory_units")}
2090
+ WHERE bank_id = $1
2091
+ """,
2092
+ bank_id,
2093
+ )
2094
+ last_consolidated_at = consolidation_stats["last_consolidated_at"] if consolidation_stats else None
2095
+ pending_consolidation = consolidation_stats["pending"] if consolidation_stats else 0
2096
+
2097
+ # Count total observations (consolidated knowledge)
2098
+ observation_count_result = await conn.fetchrow(
2099
+ f"""
2100
+ SELECT COUNT(*) as count
2101
+ FROM {fq_table("memory_units")}
2102
+ WHERE bank_id = $1 AND fact_type = 'observation'
2103
+ """,
2104
+ bank_id,
2105
+ )
2106
+ total_observations = observation_count_result["count"] if observation_count_result else 0
2107
+
1605
2108
  # Format results
1606
2109
  nodes_by_type = {row["fact_type"]: row["count"] for row in node_stats}
1607
2110
  links_by_type = {row["link_type"]: row["count"] for row in link_stats}
@@ -1631,6 +2134,9 @@ def _register_routes(app: FastAPI):
1631
2134
  links_breakdown=links_breakdown,
1632
2135
  pending_operations=pending_operations,
1633
2136
  failed_operations=failed_operations,
2137
+ last_consolidated_at=(last_consolidated_at.isoformat() if last_consolidated_at else None),
2138
+ pending_consolidation=pending_consolidation,
2139
+ total_observations=total_observations,
1634
2140
  )
1635
2141
 
1636
2142
  except (AuthenticationError, HTTPException):
@@ -1718,54 +2224,422 @@ def _register_routes(app: FastAPI):
1718
2224
  @app.post(
1719
2225
  "/v1/default/banks/{bank_id}/entities/{entity_id}/regenerate",
1720
2226
  response_model=EntityDetailResponse,
1721
- summary="Regenerate entity observations",
1722
- description="Regenerate observations for an entity based on all facts mentioning it.",
2227
+ summary="Regenerate entity observations (deprecated)",
2228
+ description="This endpoint is deprecated. Entity observations have been replaced by mental models.",
1723
2229
  operation_id="regenerate_entity_observations",
1724
2230
  tags=["Entities"],
2231
+ deprecated=True,
1725
2232
  )
1726
2233
  async def api_regenerate_entity_observations(
1727
2234
  bank_id: str,
1728
2235
  entity_id: str,
1729
2236
  request_context: RequestContext = Depends(get_request_context),
1730
2237
  ):
1731
- """Regenerate observations for an entity."""
2238
+ """Regenerate observations for an entity. DEPRECATED."""
2239
+ raise HTTPException(
2240
+ status_code=410,
2241
+ detail="This endpoint is deprecated. Entity observations are no longer supported.",
2242
+ )
2243
+
2244
+ # =========================================================================
2245
+ # =========================================================================
2246
+ # MENTAL MODELS ENDPOINTS (stored reflect responses)
2247
+ # =========================================================================
2248
+
2249
+ @app.get(
2250
+ "/v1/default/banks/{bank_id}/mental-models",
2251
+ response_model=MentalModelListResponse,
2252
+ summary="List mental models",
2253
+ description="List user-curated living documents that stay current.",
2254
+ operation_id="list_mental_models",
2255
+ tags=["Mental Models"],
2256
+ )
2257
+ async def api_list_mental_models(
2258
+ bank_id: str,
2259
+ tags_filter: list[str] | None = Query(None, alias="tags", description="Filter by tags"),
2260
+ tags_match: Literal["any", "all", "exact"] = Query("any", description="How to match tags"),
2261
+ limit: int = Query(100, ge=1, le=1000),
2262
+ offset: int = Query(0, ge=0),
2263
+ request_context: RequestContext = Depends(get_request_context),
2264
+ ):
2265
+ """List mental models for a bank."""
1732
2266
  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)
2267
+ mental_models = await app.state.memory.list_mental_models(
2268
+ bank_id=bank_id,
2269
+ tags=tags_filter,
2270
+ tags_match=tags_match,
2271
+ limit=limit,
2272
+ offset=offset,
2273
+ request_context=request_context,
2274
+ )
2275
+ return MentalModelListResponse(items=[MentalModelResponse(**m) for m in mental_models])
2276
+ except (AuthenticationError, HTTPException):
2277
+ raise
2278
+ except Exception as e:
2279
+ import traceback
1735
2280
 
1736
- if entity is None:
1737
- raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
2281
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
2282
+ logger.error(f"Error in GET /v1/default/banks/{bank_id}/mental-models: {error_detail}")
2283
+ raise HTTPException(status_code=500, detail=str(e))
1738
2284
 
1739
- # Regenerate observations
1740
- await app.state.memory.regenerate_entity_observations(
2285
+ @app.get(
2286
+ "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}",
2287
+ response_model=MentalModelResponse,
2288
+ summary="Get mental model",
2289
+ description="Get a specific mental model by ID.",
2290
+ operation_id="get_mental_model",
2291
+ tags=["Mental Models"],
2292
+ )
2293
+ async def api_get_mental_model(
2294
+ bank_id: str,
2295
+ mental_model_id: str,
2296
+ request_context: RequestContext = Depends(get_request_context),
2297
+ ):
2298
+ """Get a mental model by ID."""
2299
+ try:
2300
+ mental_model = await app.state.memory.get_mental_model(
1741
2301
  bank_id=bank_id,
1742
- entity_id=entity_id,
1743
- entity_name=entity["canonical_name"],
2302
+ mental_model_id=mental_model_id,
1744
2303
  request_context=request_context,
1745
2304
  )
2305
+ if mental_model is None:
2306
+ raise HTTPException(status_code=404, detail=f"Mental model '{mental_model_id}' not found")
2307
+ return MentalModelResponse(**mental_model)
2308
+ except (AuthenticationError, HTTPException):
2309
+ raise
2310
+ except Exception as e:
2311
+ import traceback
1746
2312
 
1747
- # Get updated entity with new observations
1748
- entity = await app.state.memory.get_entity(bank_id, entity_id, request_context=request_context)
2313
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
2314
+ logger.error(f"Error in GET /v1/default/banks/{bank_id}/mental-models/{mental_model_id}: {error_detail}")
2315
+ raise HTTPException(status_code=500, detail=str(e))
1749
2316
 
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
- ],
2317
+ @app.post(
2318
+ "/v1/default/banks/{bank_id}/mental-models",
2319
+ response_model=CreateMentalModelResponse,
2320
+ summary="Create mental model",
2321
+ description="Create a mental model by running reflect with the source query in the background. "
2322
+ "Returns an operation ID to track progress. The content is auto-generated by the reflect endpoint. "
2323
+ "Use the operations endpoint to check completion status.",
2324
+ operation_id="create_mental_model",
2325
+ tags=["Mental Models"],
2326
+ )
2327
+ async def api_create_mental_model(
2328
+ bank_id: str,
2329
+ body: CreateMentalModelRequest,
2330
+ request_context: RequestContext = Depends(get_request_context),
2331
+ ):
2332
+ """Create a mental model (async - returns operation_id)."""
2333
+ try:
2334
+ # 1. Create the mental model with placeholder content
2335
+ mental_model = await app.state.memory.create_mental_model(
2336
+ bank_id=bank_id,
2337
+ name=body.name,
2338
+ source_query=body.source_query,
2339
+ content="Generating content...",
2340
+ tags=body.tags if body.tags else None,
2341
+ max_tokens=body.max_tokens,
2342
+ trigger=body.trigger.model_dump() if body.trigger else None,
2343
+ request_context=request_context,
2344
+ )
2345
+ # 2. Schedule a refresh to generate the actual content
2346
+ result = await app.state.memory.submit_async_refresh_mental_model(
2347
+ bank_id=bank_id,
2348
+ mental_model_id=mental_model["id"],
2349
+ request_context=request_context,
2350
+ )
2351
+ return CreateMentalModelResponse(operation_id=result["operation_id"])
2352
+ except ValueError as e:
2353
+ raise HTTPException(status_code=400, detail=str(e))
2354
+ except (AuthenticationError, HTTPException):
2355
+ raise
2356
+ except Exception as e:
2357
+ import traceback
2358
+
2359
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
2360
+ logger.error(f"Error in POST /v1/default/banks/{bank_id}/mental-models: {error_detail}")
2361
+ raise HTTPException(status_code=500, detail=str(e))
2362
+
2363
+ @app.post(
2364
+ "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/refresh",
2365
+ response_model=AsyncOperationSubmitResponse,
2366
+ summary="Refresh mental model",
2367
+ description="Submit an async task to re-run the source query through reflect and update the content.",
2368
+ operation_id="refresh_mental_model",
2369
+ tags=["Mental Models"],
2370
+ )
2371
+ async def api_refresh_mental_model(
2372
+ bank_id: str,
2373
+ mental_model_id: str,
2374
+ request_context: RequestContext = Depends(get_request_context),
2375
+ ):
2376
+ """Refresh a mental model by re-running its source query (async)."""
2377
+ try:
2378
+ result = await app.state.memory.submit_async_refresh_mental_model(
2379
+ bank_id=bank_id,
2380
+ mental_model_id=mental_model_id,
2381
+ request_context=request_context,
2382
+ )
2383
+ return AsyncOperationSubmitResponse(operation_id=result["operation_id"], status="queued")
2384
+ except ValueError as e:
2385
+ raise HTTPException(status_code=404, detail=str(e))
2386
+ except (AuthenticationError, HTTPException):
2387
+ raise
2388
+ except Exception as e:
2389
+ import traceback
2390
+
2391
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
2392
+ logger.error(
2393
+ f"Error in POST /v1/default/banks/{bank_id}/mental-models/{mental_model_id}/refresh: {error_detail}"
2394
+ )
2395
+ raise HTTPException(status_code=500, detail=str(e))
2396
+
2397
+ @app.patch(
2398
+ "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}",
2399
+ response_model=MentalModelResponse,
2400
+ summary="Update mental model",
2401
+ description="Update a mental model's name and/or source query.",
2402
+ operation_id="update_mental_model",
2403
+ tags=["Mental Models"],
2404
+ )
2405
+ async def api_update_mental_model(
2406
+ bank_id: str,
2407
+ mental_model_id: str,
2408
+ body: UpdateMentalModelRequest,
2409
+ request_context: RequestContext = Depends(get_request_context),
2410
+ ):
2411
+ """Update a mental model."""
2412
+ try:
2413
+ mental_model = await app.state.memory.update_mental_model(
2414
+ bank_id=bank_id,
2415
+ mental_model_id=mental_model_id,
2416
+ name=body.name,
2417
+ source_query=body.source_query,
2418
+ max_tokens=body.max_tokens,
2419
+ tags=body.tags,
2420
+ trigger=body.trigger.model_dump() if body.trigger else None,
2421
+ request_context=request_context,
2422
+ )
2423
+ if mental_model is None:
2424
+ raise HTTPException(status_code=404, detail=f"Mental model '{mental_model_id}' not found")
2425
+ return MentalModelResponse(**mental_model)
2426
+ except (AuthenticationError, HTTPException):
2427
+ raise
2428
+ except Exception as e:
2429
+ import traceback
2430
+
2431
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
2432
+ logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/mental-models/{mental_model_id}: {error_detail}")
2433
+ raise HTTPException(status_code=500, detail=str(e))
2434
+
2435
+ @app.delete(
2436
+ "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}",
2437
+ summary="Delete mental model",
2438
+ description="Delete a mental model.",
2439
+ operation_id="delete_mental_model",
2440
+ tags=["Mental Models"],
2441
+ )
2442
+ async def api_delete_mental_model(
2443
+ bank_id: str,
2444
+ mental_model_id: str,
2445
+ request_context: RequestContext = Depends(get_request_context),
2446
+ ):
2447
+ """Delete a mental model."""
2448
+ try:
2449
+ deleted = await app.state.memory.delete_mental_model(
2450
+ bank_id=bank_id,
2451
+ mental_model_id=mental_model_id,
2452
+ request_context=request_context,
2453
+ )
2454
+ if not deleted:
2455
+ raise HTTPException(status_code=404, detail=f"Mental model '{mental_model_id}' not found")
2456
+ return {"status": "deleted"}
2457
+ except (AuthenticationError, HTTPException):
2458
+ raise
2459
+ except Exception as e:
2460
+ import traceback
2461
+
2462
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
2463
+ logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/mental-models/{mental_model_id}: {error_detail}")
2464
+ raise HTTPException(status_code=500, detail=str(e))
2465
+
2466
+ # =========================================================================
2467
+ # DIRECTIVES ENDPOINTS
2468
+ # =========================================================================
2469
+
2470
+ @app.get(
2471
+ "/v1/default/banks/{bank_id}/directives",
2472
+ response_model=DirectiveListResponse,
2473
+ summary="List directives",
2474
+ description="List hard rules that are injected into prompts.",
2475
+ operation_id="list_directives",
2476
+ tags=["Directives"],
2477
+ )
2478
+ async def api_list_directives(
2479
+ bank_id: str,
2480
+ tags_filter: list[str] | None = Query(None, alias="tags", description="Filter by tags"),
2481
+ tags_match: Literal["any", "all", "exact"] = Query("any", description="How to match tags"),
2482
+ active_only: bool = Query(True, description="Only return active directives"),
2483
+ limit: int = Query(100, ge=1, le=1000),
2484
+ offset: int = Query(0, ge=0),
2485
+ request_context: RequestContext = Depends(get_request_context),
2486
+ ):
2487
+ """List directives for a bank."""
2488
+ try:
2489
+ directives = await app.state.memory.list_directives(
2490
+ bank_id=bank_id,
2491
+ tags=tags_filter,
2492
+ tags_match=tags_match,
2493
+ active_only=active_only,
2494
+ limit=limit,
2495
+ offset=offset,
2496
+ request_context=request_context,
2497
+ )
2498
+ return DirectiveListResponse(items=[DirectiveResponse(**d) for d in directives])
2499
+ except (AuthenticationError, HTTPException):
2500
+ raise
2501
+ except Exception as e:
2502
+ import traceback
2503
+
2504
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
2505
+ logger.error(f"Error in GET /v1/default/banks/{bank_id}/directives: {error_detail}")
2506
+ raise HTTPException(status_code=500, detail=str(e))
2507
+
2508
+ @app.get(
2509
+ "/v1/default/banks/{bank_id}/directives/{directive_id}",
2510
+ response_model=DirectiveResponse,
2511
+ summary="Get directive",
2512
+ description="Get a specific directive by ID.",
2513
+ operation_id="get_directive",
2514
+ tags=["Directives"],
2515
+ )
2516
+ async def api_get_directive(
2517
+ bank_id: str,
2518
+ directive_id: str,
2519
+ request_context: RequestContext = Depends(get_request_context),
2520
+ ):
2521
+ """Get a directive by ID."""
2522
+ try:
2523
+ directive = await app.state.memory.get_directive(
2524
+ bank_id=bank_id,
2525
+ directive_id=directive_id,
2526
+ request_context=request_context,
2527
+ )
2528
+ if directive is None:
2529
+ raise HTTPException(status_code=404, detail=f"Directive '{directive_id}' not found")
2530
+ return DirectiveResponse(**directive)
2531
+ except (AuthenticationError, HTTPException):
2532
+ raise
2533
+ except Exception as e:
2534
+ import traceback
2535
+
2536
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
2537
+ logger.error(f"Error in GET /v1/default/banks/{bank_id}/directives/{directive_id}: {error_detail}")
2538
+ raise HTTPException(status_code=500, detail=str(e))
2539
+
2540
+ @app.post(
2541
+ "/v1/default/banks/{bank_id}/directives",
2542
+ response_model=DirectiveResponse,
2543
+ summary="Create directive",
2544
+ description="Create a hard rule that will be injected into prompts.",
2545
+ operation_id="create_directive",
2546
+ tags=["Directives"],
2547
+ )
2548
+ async def api_create_directive(
2549
+ bank_id: str,
2550
+ body: CreateDirectiveRequest,
2551
+ request_context: RequestContext = Depends(get_request_context),
2552
+ ):
2553
+ """Create a directive."""
2554
+ try:
2555
+ directive = await app.state.memory.create_directive(
2556
+ bank_id=bank_id,
2557
+ name=body.name,
2558
+ content=body.content,
2559
+ priority=body.priority,
2560
+ is_active=body.is_active,
2561
+ tags=body.tags,
2562
+ request_context=request_context,
2563
+ )
2564
+ return DirectiveResponse(**directive)
2565
+ except ValueError as e:
2566
+ raise HTTPException(status_code=400, detail=str(e))
2567
+ except (AuthenticationError, HTTPException):
2568
+ raise
2569
+ except Exception as e:
2570
+ import traceback
2571
+
2572
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
2573
+ logger.error(f"Error in POST /v1/default/banks/{bank_id}/directives: {error_detail}")
2574
+ raise HTTPException(status_code=500, detail=str(e))
2575
+
2576
+ @app.patch(
2577
+ "/v1/default/banks/{bank_id}/directives/{directive_id}",
2578
+ response_model=DirectiveResponse,
2579
+ summary="Update directive",
2580
+ description="Update a directive's properties.",
2581
+ operation_id="update_directive",
2582
+ tags=["Directives"],
2583
+ )
2584
+ async def api_update_directive(
2585
+ bank_id: str,
2586
+ directive_id: str,
2587
+ body: UpdateDirectiveRequest,
2588
+ request_context: RequestContext = Depends(get_request_context),
2589
+ ):
2590
+ """Update a directive."""
2591
+ try:
2592
+ directive = await app.state.memory.update_directive(
2593
+ bank_id=bank_id,
2594
+ directive_id=directive_id,
2595
+ name=body.name,
2596
+ content=body.content,
2597
+ priority=body.priority,
2598
+ is_active=body.is_active,
2599
+ tags=body.tags,
2600
+ request_context=request_context,
2601
+ )
2602
+ if directive is None:
2603
+ raise HTTPException(status_code=404, detail=f"Directive '{directive_id}' not found")
2604
+ return DirectiveResponse(**directive)
2605
+ except (AuthenticationError, HTTPException):
2606
+ raise
2607
+ except Exception as e:
2608
+ import traceback
2609
+
2610
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
2611
+ logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/directives/{directive_id}: {error_detail}")
2612
+ raise HTTPException(status_code=500, detail=str(e))
2613
+
2614
+ @app.delete(
2615
+ "/v1/default/banks/{bank_id}/directives/{directive_id}",
2616
+ summary="Delete directive",
2617
+ description="Delete a directive.",
2618
+ operation_id="delete_directive",
2619
+ tags=["Directives"],
2620
+ )
2621
+ async def api_delete_directive(
2622
+ bank_id: str,
2623
+ directive_id: str,
2624
+ request_context: RequestContext = Depends(get_request_context),
2625
+ ):
2626
+ """Delete a directive."""
2627
+ try:
2628
+ deleted = await app.state.memory.delete_directive(
2629
+ bank_id=bank_id,
2630
+ directive_id=directive_id,
2631
+ request_context=request_context,
1761
2632
  )
2633
+ if not deleted:
2634
+ raise HTTPException(status_code=404, detail=f"Directive '{directive_id}' not found")
2635
+ return {"status": "deleted"}
1762
2636
  except (AuthenticationError, HTTPException):
1763
2637
  raise
1764
2638
  except Exception as e:
1765
2639
  import traceback
1766
2640
 
1767
2641
  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}")
2642
+ logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/directives/{directive_id}: {error_detail}")
1769
2643
  raise HTTPException(status_code=500, detail=str(e))
1770
2644
 
1771
2645
  @app.get(
@@ -1968,17 +2842,28 @@ def _register_routes(app: FastAPI):
1968
2842
  "/v1/default/banks/{bank_id}/operations",
1969
2843
  response_model=OperationsListResponse,
1970
2844
  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",
2845
+ description="Get a list of async operations for a specific agent, with optional filtering by status. Results are sorted by most recent first.",
1972
2846
  operation_id="list_operations",
1973
2847
  tags=["Operations"],
1974
2848
  )
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."""
2849
+ async def api_list_operations(
2850
+ bank_id: str,
2851
+ status: str | None = Query(default=None, description="Filter by status: pending, completed, or failed"),
2852
+ limit: int = Query(default=20, ge=1, le=100, description="Maximum number of operations to return"),
2853
+ offset: int = Query(default=0, ge=0, description="Number of operations to skip"),
2854
+ request_context: RequestContext = Depends(get_request_context),
2855
+ ):
2856
+ """List async operations for a memory bank with optional filtering and pagination."""
1977
2857
  try:
1978
- operations = await app.state.memory.list_operations(bank_id, request_context=request_context)
2858
+ result = await app.state.memory.list_operations(
2859
+ bank_id, status=status, limit=limit, offset=offset, request_context=request_context
2860
+ )
1979
2861
  return OperationsListResponse(
1980
2862
  bank_id=bank_id,
1981
- operations=[OperationResponse(**op) for op in operations],
2863
+ total=result["total"],
2864
+ limit=limit,
2865
+ offset=offset,
2866
+ operations=[OperationResponse(**op) for op in result["operations"]],
1982
2867
  )
1983
2868
  except (AuthenticationError, HTTPException):
1984
2869
  raise
@@ -1989,6 +2874,37 @@ def _register_routes(app: FastAPI):
1989
2874
  logger.error(f"Error in /v1/default/banks/{bank_id}/operations: {error_detail}")
1990
2875
  raise HTTPException(status_code=500, detail=str(e))
1991
2876
 
2877
+ @app.get(
2878
+ "/v1/default/banks/{bank_id}/operations/{operation_id}",
2879
+ response_model=OperationStatusResponse,
2880
+ summary="Get operation status",
2881
+ description="Get the status of a specific async operation. Returns 'pending', 'completed', or 'failed'. "
2882
+ "Completed operations are removed from storage, so 'completed' means the operation finished successfully.",
2883
+ operation_id="get_operation_status",
2884
+ tags=["Operations"],
2885
+ )
2886
+ async def api_get_operation_status(
2887
+ bank_id: str, operation_id: str, request_context: RequestContext = Depends(get_request_context)
2888
+ ):
2889
+ """Get the status of an async operation."""
2890
+ try:
2891
+ # Validate UUID format
2892
+ try:
2893
+ uuid.UUID(operation_id)
2894
+ except ValueError:
2895
+ raise HTTPException(status_code=400, detail=f"Invalid operation_id format: {operation_id}")
2896
+
2897
+ result = await app.state.memory.get_operation_status(bank_id, operation_id, request_context=request_context)
2898
+ return OperationStatusResponse(**result)
2899
+ except (AuthenticationError, HTTPException):
2900
+ raise
2901
+ except Exception as e:
2902
+ import traceback
2903
+
2904
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
2905
+ logger.error(f"Error in GET /v1/default/banks/{bank_id}/operations/{operation_id}: {error_detail}")
2906
+ raise HTTPException(status_code=500, detail=str(e))
2907
+
1992
2908
  @app.delete(
1993
2909
  "/v1/default/banks/{bank_id}/operations/{operation_id}",
1994
2910
  response_model=CancelOperationResponse,
@@ -2025,12 +2941,12 @@ def _register_routes(app: FastAPI):
2025
2941
  "/v1/default/banks/{bank_id}/profile",
2026
2942
  response_model=BankProfileResponse,
2027
2943
  summary="Get memory bank profile",
2028
- description="Get disposition traits and background for a memory bank. Auto-creates agent with defaults if not exists.",
2944
+ description="Get disposition traits and mission for a memory bank. Auto-creates agent with defaults if not exists.",
2029
2945
  operation_id="get_bank_profile",
2030
2946
  tags=["Banks"],
2031
2947
  )
2032
2948
  async def api_get_bank_profile(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
2033
- """Get memory bank profile (disposition + background)."""
2949
+ """Get memory bank profile (disposition + mission)."""
2034
2950
  try:
2035
2951
  profile = await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
2036
2952
  # Convert DispositionTraits object to dict for Pydantic
@@ -2039,11 +2955,13 @@ def _register_routes(app: FastAPI):
2039
2955
  if hasattr(profile["disposition"], "model_dump")
2040
2956
  else dict(profile["disposition"])
2041
2957
  )
2958
+ mission = profile.get("mission") or ""
2042
2959
  return BankProfileResponse(
2043
2960
  bank_id=bank_id,
2044
2961
  name=profile["name"],
2045
2962
  disposition=DispositionTraits(**disposition_dict),
2046
- background=profile["background"],
2963
+ mission=mission,
2964
+ background=mission, # Backwards compat
2047
2965
  )
2048
2966
  except (AuthenticationError, HTTPException):
2049
2967
  raise
@@ -2079,11 +2997,13 @@ def _register_routes(app: FastAPI):
2079
2997
  if hasattr(profile["disposition"], "model_dump")
2080
2998
  else dict(profile["disposition"])
2081
2999
  )
3000
+ mission = profile.get("mission") or ""
2082
3001
  return BankProfileResponse(
2083
3002
  bank_id=bank_id,
2084
3003
  name=profile["name"],
2085
3004
  disposition=DispositionTraits(**disposition_dict),
2086
- background=profile["background"],
3005
+ mission=mission,
3006
+ background=mission, # Backwards compat
2087
3007
  )
2088
3008
  except (AuthenticationError, HTTPException):
2089
3009
  raise
@@ -2097,25 +3017,22 @@ def _register_routes(app: FastAPI):
2097
3017
  @app.post(
2098
3018
  "/v1/default/banks/{bank_id}/background",
2099
3019
  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.",
3020
+ summary="Add/merge memory bank background (deprecated)",
3021
+ description="Deprecated: Use PUT /mission instead. This endpoint now updates the mission field.",
2102
3022
  operation_id="add_bank_background",
2103
3023
  tags=["Banks"],
3024
+ deprecated=True,
2104
3025
  )
2105
3026
  async def api_add_bank_background(
2106
3027
  bank_id: str, request: AddBackgroundRequest, request_context: RequestContext = Depends(get_request_context)
2107
3028
  ):
2108
- """Add or merge bank background information. Optionally infer disposition traits."""
3029
+ """Deprecated: Add or merge bank background. Now updates mission field."""
2109
3030
  try:
2110
- result = await app.state.memory.merge_bank_background(
2111
- bank_id, request.content, update_disposition=request.update_disposition, request_context=request_context
3031
+ result = await app.state.memory.merge_bank_mission(
3032
+ bank_id, request.content, request_context=request_context
2112
3033
  )
2113
-
2114
- response = BackgroundResponse(background=result["background"])
2115
- if "disposition" in result:
2116
- response.disposition = DispositionTraits(**result["disposition"])
2117
-
2118
- return response
3034
+ mission = result.get("mission") or ""
3035
+ return BackgroundResponse(mission=mission, background=mission)
2119
3036
  except (AuthenticationError, HTTPException):
2120
3037
  raise
2121
3038
  except Exception as e:
@@ -2129,24 +3046,25 @@ def _register_routes(app: FastAPI):
2129
3046
  "/v1/default/banks/{bank_id}",
2130
3047
  response_model=BankProfileResponse,
2131
3048
  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.",
3049
+ description="Create a new agent or update existing agent with disposition and mission. Auto-fills missing fields with defaults.",
2133
3050
  operation_id="create_or_update_bank",
2134
3051
  tags=["Banks"],
2135
3052
  )
2136
3053
  async def api_create_or_update_bank(
2137
3054
  bank_id: str, request: CreateBankRequest, request_context: RequestContext = Depends(get_request_context)
2138
3055
  ):
2139
- """Create or update an agent with disposition and background."""
3056
+ """Create or update an agent with disposition and mission."""
2140
3057
  try:
2141
3058
  # Ensure bank exists by getting profile (auto-creates with defaults)
2142
3059
  await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
2143
3060
 
2144
- # Update name and/or background if provided
2145
- if request.name is not None or request.background is not None:
3061
+ # Update name and/or mission if provided (support both mission and deprecated background)
3062
+ mission_value = request.mission or request.background
3063
+ if request.name is not None or mission_value is not None:
2146
3064
  await app.state.memory.update_bank(
2147
3065
  bank_id,
2148
3066
  name=request.name,
2149
- background=request.background,
3067
+ mission=mission_value,
2150
3068
  request_context=request_context,
2151
3069
  )
2152
3070
 
@@ -2163,11 +3081,13 @@ def _register_routes(app: FastAPI):
2163
3081
  if hasattr(final_profile["disposition"], "model_dump")
2164
3082
  else dict(final_profile["disposition"])
2165
3083
  )
3084
+ mission = final_profile.get("mission") or ""
2166
3085
  return BankProfileResponse(
2167
3086
  bank_id=bank_id,
2168
3087
  name=final_profile["name"],
2169
3088
  disposition=DispositionTraits(**disposition_dict),
2170
- background=final_profile["background"],
3089
+ mission=mission,
3090
+ background=mission, # Backwards compat
2171
3091
  )
2172
3092
  except (AuthenticationError, HTTPException):
2173
3093
  raise
@@ -2178,6 +3098,62 @@ def _register_routes(app: FastAPI):
2178
3098
  logger.error(f"Error in /v1/default/banks/{bank_id}: {error_detail}")
2179
3099
  raise HTTPException(status_code=500, detail=str(e))
2180
3100
 
3101
+ @app.patch(
3102
+ "/v1/default/banks/{bank_id}",
3103
+ response_model=BankProfileResponse,
3104
+ summary="Partial update memory bank",
3105
+ description="Partially update an agent's profile. Only provided fields will be updated.",
3106
+ operation_id="update_bank",
3107
+ tags=["Banks"],
3108
+ )
3109
+ async def api_update_bank(
3110
+ bank_id: str, request: CreateBankRequest, request_context: RequestContext = Depends(get_request_context)
3111
+ ):
3112
+ """Partially update an agent's profile (name, mission, disposition)."""
3113
+ try:
3114
+ # Ensure bank exists
3115
+ await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
3116
+
3117
+ # Update name and/or mission if provided
3118
+ mission_value = request.mission or request.background
3119
+ if request.name is not None or mission_value is not None:
3120
+ await app.state.memory.update_bank(
3121
+ bank_id,
3122
+ name=request.name,
3123
+ mission=mission_value,
3124
+ request_context=request_context,
3125
+ )
3126
+
3127
+ # Update disposition if provided
3128
+ if request.disposition is not None:
3129
+ await app.state.memory.update_bank_disposition(
3130
+ bank_id, request.disposition.model_dump(), request_context=request_context
3131
+ )
3132
+
3133
+ # Get final profile
3134
+ final_profile = await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
3135
+ disposition_dict = (
3136
+ final_profile["disposition"].model_dump()
3137
+ if hasattr(final_profile["disposition"], "model_dump")
3138
+ else dict(final_profile["disposition"])
3139
+ )
3140
+ mission = final_profile.get("mission") or ""
3141
+ return BankProfileResponse(
3142
+ bank_id=bank_id,
3143
+ name=final_profile["name"],
3144
+ disposition=DispositionTraits(**disposition_dict),
3145
+ mission=mission,
3146
+ background=mission, # Backwards compat
3147
+ )
3148
+ except (AuthenticationError, HTTPException):
3149
+ raise
3150
+ except Exception as e:
3151
+ import traceback
3152
+
3153
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
3154
+ logger.error(f"Error in PATCH /v1/default/banks/{bank_id}: {error_detail}")
3155
+ raise HTTPException(status_code=500, detail=str(e))
3156
+
2181
3157
  @app.delete(
2182
3158
  "/v1/default/banks/{bank_id}",
2183
3159
  response_model=DeleteResponse,
@@ -2207,6 +3183,57 @@ def _register_routes(app: FastAPI):
2207
3183
  logger.error(f"Error in DELETE /v1/default/banks/{bank_id}: {error_detail}")
2208
3184
  raise HTTPException(status_code=500, detail=str(e))
2209
3185
 
3186
+ @app.delete(
3187
+ "/v1/default/banks/{bank_id}/observations",
3188
+ response_model=DeleteResponse,
3189
+ summary="Clear all observations",
3190
+ description="Delete all observations for a memory bank. This is useful for resetting the consolidated knowledge.",
3191
+ operation_id="clear_observations",
3192
+ tags=["Banks"],
3193
+ )
3194
+ async def api_clear_observations(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
3195
+ """Clear all observations for a bank."""
3196
+ try:
3197
+ result = await app.state.memory.clear_observations(bank_id, request_context=request_context)
3198
+ return DeleteResponse(
3199
+ success=True,
3200
+ message=f"Cleared {result.get('deleted_count', 0)} observations",
3201
+ deleted_count=result.get("deleted_count", 0),
3202
+ )
3203
+ except (AuthenticationError, HTTPException):
3204
+ raise
3205
+ except Exception as e:
3206
+ import traceback
3207
+
3208
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
3209
+ logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/observations: {error_detail}")
3210
+ raise HTTPException(status_code=500, detail=str(e))
3211
+
3212
+ @app.post(
3213
+ "/v1/default/banks/{bank_id}/consolidate",
3214
+ response_model=ConsolidationResponse,
3215
+ summary="Trigger consolidation",
3216
+ description="Run memory consolidation to create/update observations from recent memories.",
3217
+ operation_id="trigger_consolidation",
3218
+ tags=["Banks"],
3219
+ )
3220
+ async def api_trigger_consolidation(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
3221
+ """Trigger consolidation for a bank (async)."""
3222
+ try:
3223
+ result = await app.state.memory.submit_async_consolidation(bank_id=bank_id, request_context=request_context)
3224
+ return ConsolidationResponse(
3225
+ operation_id=result["operation_id"],
3226
+ deduplicated=result.get("deduplicated", False),
3227
+ )
3228
+ except (AuthenticationError, HTTPException):
3229
+ raise
3230
+ except Exception as e:
3231
+ import traceback
3232
+
3233
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
3234
+ logger.error(f"Error in POST /v1/default/banks/{bank_id}/consolidate: {error_detail}")
3235
+ raise HTTPException(status_code=500, detail=str(e))
3236
+
2210
3237
  @app.post(
2211
3238
  "/v1/default/banks/{bank_id}/memories",
2212
3239
  response_model=RetainResponse,