feat: mental model history tracking and UI diff view (#516)

* feat: mental model refresh history tracking and UI diff view

- DB migration: add history JSONB column to mental_models table
- Track previous content on each refresh in update_mental_model
- Add get_mental_model_history() engine method
- New GET /mental-models/{id}/history endpoint
- Control plane proxy route and getMentalModelHistory() in api.ts
- MentalModelDetailModal: add History tab with lazy loading, carousel
  navigation (left=older, right=newer), word-level content diff view

* fix: resolve alembic migration head conflict for mental model history

* feat: mental model history tracking, side-by-side diff UI, and config flag

- Track content changes on every mental model update/refresh (persisted in JSONB history column)
- New GET /mental-models/{id}/history endpoint returning changes most-recent-first
- Side-by-side diff view in History tab (Before/After columns, line-level highlights)
- Actions dropdown in detail panel (Edit, Refresh, View History, Delete)
- HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY config flag (default: true)
- Also adds missing HINDSIGHT_API_ENABLE_OBSERVATION_HISTORY to configuration docs
- Python client wrapper method get_mental_model_history()
- Tests for history persistence (recorded, ordered, name-only skipped, missing returns None)
- Fix NameError: timezone not imported in update_mental_model

* fix: call get_mental_model_history before delete in doc example
This commit is contained in:
Nicolò Boschi 2026-03-06 17:50:48 +01:00 committed by GitHub
parent 576473b6aa
commit e2baca8bfe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1214 additions and 35 deletions

View file

@ -0,0 +1,30 @@
"""Add history column to mental_models
Revision ID: c3d4e5f6g7h8
Revises: a2b3c4d5e6f7, a2b3c4d5e6f8
Create Date: 2026-03-06
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "c3d4e5f6g7h8"
down_revision: str | Sequence[str] | None = ("a2b3c4d5e6f7", "a2b3c4d5e6f8")
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}mental_models ADD COLUMN IF NOT EXISTS history JSONB DEFAULT '[]'::jsonb")
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}mental_models DROP COLUMN IF EXISTS history")

View file

@ -2748,6 +2748,41 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in GET /v1/default/banks/{bank_id}/mental-models/{mental_model_id}: {error_detail}") logger.error(f"Error in GET /v1/default/banks/{bank_id}/mental-models/{mental_model_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history",
summary="Get mental model history",
description="Get the refresh history of a mental model, showing content changes over time.",
operation_id="get_mental_model_history",
tags=["Mental Models"],
)
async def api_get_mental_model_history(
bank_id: str,
mental_model_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Get the refresh history of a mental model."""
try:
data = await app.state.memory.get_mental_model_history(
bank_id=bank_id,
mental_model_id=mental_model_id,
request_context=request_context,
)
if data is None:
raise HTTPException(status_code=404, detail=f"Mental model '{mental_model_id}' not found")
return data
except (AuthenticationError, HTTPException):
raise
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(
f"Error in GET /v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history: {error_detail}"
)
raise HTTPException(status_code=500, detail=str(e))
@app.post( @app.post(
"/v1/default/banks/{bank_id}/mental-models", "/v1/default/banks/{bank_id}/mental-models",
response_model=CreateMentalModelResponse, response_model=CreateMentalModelResponse,

View file

@ -299,6 +299,7 @@ ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION = (
) )
ENV_OBSERVATIONS_MISSION = "HINDSIGHT_API_OBSERVATIONS_MISSION" ENV_OBSERVATIONS_MISSION = "HINDSIGHT_API_OBSERVATIONS_MISSION"
ENV_ENABLE_OBSERVATION_HISTORY = "HINDSIGHT_API_ENABLE_OBSERVATION_HISTORY" ENV_ENABLE_OBSERVATION_HISTORY = "HINDSIGHT_API_ENABLE_OBSERVATION_HISTORY"
ENV_ENABLE_MENTAL_MODEL_HISTORY = "HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY"
# Webhook configuration (global, static - server-level only) # Webhook configuration (global, static - server-level only)
ENV_WEBHOOK_URL = "HINDSIGHT_API_WEBHOOK_URL" ENV_WEBHOOK_URL = "HINDSIGHT_API_WEBHOOK_URL"
@ -451,6 +452,7 @@ DEFAULT_FILE_DELETE_AFTER_RETAIN = True # Delete file bytes after retain (saves
# Observations defaults (consolidated knowledge from facts) # Observations defaults (consolidated knowledge from facts)
DEFAULT_ENABLE_OBSERVATIONS = True # Observations enabled by default DEFAULT_ENABLE_OBSERVATIONS = True # Observations enabled by default
DEFAULT_ENABLE_OBSERVATION_HISTORY = True # Observation history tracking enabled by default DEFAULT_ENABLE_OBSERVATION_HISTORY = True # Observation history tracking enabled by default
DEFAULT_ENABLE_MENTAL_MODEL_HISTORY = True # Mental model history tracking enabled by default
DEFAULT_CONSOLIDATION_BATCH_SIZE = 50 # Memories to load per batch (internal memory optimization) DEFAULT_CONSOLIDATION_BATCH_SIZE = 50 # Memories to load per batch (internal memory optimization)
DEFAULT_CONSOLIDATION_LLM_BATCH_SIZE = 8 # Facts per LLM call (1 = no batching; >1 = batch mode) DEFAULT_CONSOLIDATION_LLM_BATCH_SIZE = 8 # Facts per LLM call (1 = no batching; >1 = batch mode)
DEFAULT_CONSOLIDATION_MAX_TOKENS = 512 # Max tokens for recall when finding related observations DEFAULT_CONSOLIDATION_MAX_TOKENS = 512 # Max tokens for recall when finding related observations
@ -730,6 +732,7 @@ class HindsightConfig:
# Observations settings (consolidated knowledge from facts) # Observations settings (consolidated knowledge from facts)
enable_observations: bool enable_observations: bool
enable_observation_history: bool enable_observation_history: bool
enable_mental_model_history: bool
consolidation_batch_size: int consolidation_batch_size: int
consolidation_llm_batch_size: int consolidation_llm_batch_size: int
consolidation_max_tokens: int consolidation_max_tokens: int
@ -1186,6 +1189,10 @@ class HindsightConfig:
ENV_ENABLE_OBSERVATION_HISTORY, str(DEFAULT_ENABLE_OBSERVATION_HISTORY) ENV_ENABLE_OBSERVATION_HISTORY, str(DEFAULT_ENABLE_OBSERVATION_HISTORY)
).lower() ).lower()
== "true", == "true",
enable_mental_model_history=os.getenv(
ENV_ENABLE_MENTAL_MODEL_HISTORY, str(DEFAULT_ENABLE_MENTAL_MODEL_HISTORY)
).lower()
== "true",
consolidation_batch_size=int( consolidation_batch_size=int(
os.getenv(ENV_CONSOLIDATION_BATCH_SIZE, str(DEFAULT_CONSOLIDATION_BATCH_SIZE)) os.getenv(ENV_CONSOLIDATION_BATCH_SIZE, str(DEFAULT_CONSOLIDATION_BATCH_SIZE))
), ),

View file

@ -16,7 +16,7 @@ import logging
import time import time
import uuid import uuid
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import asyncpg import asyncpg
@ -5999,6 +5999,39 @@ class MemoryEngine(MemoryEngineInterface):
return result return result
async def get_mental_model_history(
self,
bank_id: str,
mental_model_id: str,
*,
request_context: "RequestContext",
) -> list[dict] | None:
"""Get the refresh history of a mental model.
Returns None if the mental model is not found.
Returns a list of history entries (most recent first), each with previous_content and changed_at.
"""
await self._authenticate_tenant(request_context)
pool = await self._get_pool()
async with acquire_with_retry(pool) as conn:
row = await conn.fetchrow(
f"""
SELECT history
FROM {fq_table("mental_models")}
WHERE bank_id = $1 AND id = $2
""",
bank_id,
mental_model_id,
)
if row is None:
return None
raw_history = row["history"]
if isinstance(raw_history, str):
raw_history = json.loads(raw_history)
if not raw_history:
return []
return list(reversed(raw_history))
async def create_mental_model( async def create_mental_model(
self, self,
bank_id: str, bank_id: str,
@ -6219,6 +6252,17 @@ class MemoryEngine(MemoryEngineInterface):
pool = await self._get_pool() pool = await self._get_pool()
async with acquire_with_retry(pool) as conn: async with acquire_with_retry(pool) as conn:
# If content is changing, fetch current content first to record history
previous_content: str | None = None
if content is not None:
current_row = await conn.fetchrow(
f"SELECT content FROM {fq_table('mental_models')} WHERE bank_id = $1 AND id = $2",
bank_id,
mental_model_id,
)
if current_row:
previous_content = current_row["content"]
# Build dynamic update # Build dynamic update
updates = [] updates = []
params: list[Any] = [bank_id, mental_model_id] params: list[Any] = [bank_id, mental_model_id]
@ -6234,6 +6278,14 @@ class MemoryEngine(MemoryEngineInterface):
params.append(content) params.append(content)
param_idx += 1 param_idx += 1
updates.append("last_refreshed_at = NOW()") updates.append("last_refreshed_at = NOW()")
# Record history entry with the previous content
if get_config().enable_mental_model_history:
history_entry = json.dumps(
[{"previous_content": previous_content, "changed_at": datetime.now(timezone.utc).isoformat()}]
)
updates.append(f"history = COALESCE(history, '[]'::jsonb) || ${param_idx}::jsonb")
params.append(history_entry)
param_idx += 1
# Also update embedding (convert to string for asyncpg vector type) # Also update embedding (convert to string for asyncpg vector type)
embedding_text = f"{name or ''} {content}" embedding_text = f"{name or ''} {content}"
embedding = await embedding_utils.generate_embeddings_batch(self.embeddings, [embedding_text]) embedding = await embedding_utils.generate_embeddings_batch(self.embeddings, [embedding_text])

View file

@ -277,6 +277,7 @@ def main():
file_delete_after_retain=config.file_delete_after_retain, file_delete_after_retain=config.file_delete_after_retain,
enable_observations=config.enable_observations, enable_observations=config.enable_observations,
enable_observation_history=config.enable_observation_history, enable_observation_history=config.enable_observation_history,
enable_mental_model_history=config.enable_mental_model_history,
consolidation_batch_size=config.consolidation_batch_size, consolidation_batch_size=config.consolidation_batch_size,
consolidation_llm_batch_size=config.consolidation_llm_batch_size, consolidation_llm_batch_size=config.consolidation_llm_batch_size,
consolidation_max_tokens=config.consolidation_max_tokens, consolidation_max_tokens=config.consolidation_max_tokens,

View file

@ -656,6 +656,113 @@ class TestDirectivesPromptInjection:
assert directives_pos < critical_rules_pos assert directives_pos < critical_rules_pos
class TestMentalModelHistory:
"""Test mental model history persistence."""
async def test_history_recorded_on_content_update(self, memory: MemoryEngine, request_context):
"""Test that updating content records a history entry."""
bank_id = f"test-mm-history-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id, request_context=request_context)
mm = await memory.create_mental_model(
bank_id=bank_id,
name="Test Model",
source_query="What is the test?",
content="Original content",
request_context=request_context,
)
# No history yet
history = await memory.get_mental_model_history(bank_id, mm["id"], request_context=request_context)
assert history == []
# Update content
await memory.update_mental_model(
bank_id=bank_id,
mental_model_id=mm["id"],
content="Updated content",
request_context=request_context,
)
history = await memory.get_mental_model_history(bank_id, mm["id"], request_context=request_context)
assert len(history) == 1
assert history[0]["previous_content"] == "Original content"
assert "changed_at" in history[0]
await memory.delete_bank(bank_id, request_context=request_context)
async def test_history_ordered_most_recent_first(self, memory: MemoryEngine, request_context):
"""Test that history is returned most recent first."""
bank_id = f"test-mm-history-order-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id, request_context=request_context)
mm = await memory.create_mental_model(
bank_id=bank_id,
name="Test Model",
source_query="What is the test?",
content="v1",
request_context=request_context,
)
await memory.update_mental_model(
bank_id=bank_id,
mental_model_id=mm["id"],
content="v2",
request_context=request_context,
)
await memory.update_mental_model(
bank_id=bank_id,
mental_model_id=mm["id"],
content="v3",
request_context=request_context,
)
history = await memory.get_mental_model_history(bank_id, mm["id"], request_context=request_context)
assert len(history) == 2
# Most recent first: second update recorded "v2" as previous, first recorded "v1"
assert history[0]["previous_content"] == "v2"
assert history[1]["previous_content"] == "v1"
await memory.delete_bank(bank_id, request_context=request_context)
async def test_history_not_recorded_on_name_only_update(self, memory: MemoryEngine, request_context):
"""Test that updating only name does not record history."""
bank_id = f"test-mm-history-name-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id, request_context=request_context)
mm = await memory.create_mental_model(
bank_id=bank_id,
name="Original Name",
source_query="What is the test?",
content="Content",
request_context=request_context,
)
await memory.update_mental_model(
bank_id=bank_id,
mental_model_id=mm["id"],
name="Updated Name",
request_context=request_context,
)
history = await memory.get_mental_model_history(bank_id, mm["id"], request_context=request_context)
assert history == []
await memory.delete_bank(bank_id, request_context=request_context)
async def test_history_returns_none_for_missing_model(self, memory: MemoryEngine, request_context):
"""Test that history returns None when mental model doesn't exist."""
bank_id = f"test-mm-history-missing-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id, request_context=request_context)
result = await memory.get_mental_model_history(
bank_id, "nonexistent-id", request_context=request_context
)
assert result is None
await memory.delete_bank(bank_id, request_context=request_context)
class TestMentalModelRefreshTagSecurity: class TestMentalModelRefreshTagSecurity:
"""Test that mental model refresh respects tag-based security boundaries.""" """Test that mental model refresh respects tag-based security boundaries."""

View file

@ -884,6 +884,51 @@ paths:
summary: Update mental model summary: Update mental model
tags: tags:
- Mental Models - Mental Models
/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history:
get:
description: "Get the refresh history of a mental model, showing content changes\
\ over time."
operationId: get_mental_model_history
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: path
name: mental_model_id
required: true
schema:
title: Mental Model Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema: {}
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Get mental model history
tags:
- Mental Models
/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/refresh: /v1/default/banks/{bank_id}/mental-models/{mental_model_id}/refresh:
post: post:
description: Submit an async task to re-run the source query through reflect description: Submit an async task to re-run the source query through reflect

View file

@ -409,6 +409,132 @@ func (a *MentalModelsAPIService) GetMentalModelExecute(r ApiGetMentalModelReques
return localVarReturnValue, localVarHTTPResponse, nil return localVarReturnValue, localVarHTTPResponse, nil
} }
type ApiGetMentalModelHistoryRequest struct {
ctx context.Context
ApiService *MentalModelsAPIService
bankId string
mentalModelId string
authorization *string
}
func (r ApiGetMentalModelHistoryRequest) Authorization(authorization string) ApiGetMentalModelHistoryRequest {
r.authorization = &authorization
return r
}
func (r ApiGetMentalModelHistoryRequest) Execute() (interface{}, *http.Response, error) {
return r.ApiService.GetMentalModelHistoryExecute(r)
}
/*
GetMentalModelHistory Get mental model history
Get the refresh history of a mental model, showing content changes over time.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@param mentalModelId
@return ApiGetMentalModelHistoryRequest
*/
func (a *MentalModelsAPIService) GetMentalModelHistory(ctx context.Context, bankId string, mentalModelId string) ApiGetMentalModelHistoryRequest {
return ApiGetMentalModelHistoryRequest{
ApiService: a,
ctx: ctx,
bankId: bankId,
mentalModelId: mentalModelId,
}
}
// Execute executes the request
// @return interface{}
func (a *MentalModelsAPIService) GetMentalModelHistoryExecute(r ApiGetMentalModelHistoryRequest) (interface{}, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodGet
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue interface{}
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "MentalModelsAPIService.GetMentalModelHistory")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history"
localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1)
localVarPath = strings.Replace(localVarPath, "{"+"mental_model_id"+"}", url.PathEscape(parameterValueToString(r.mentalModelId, "mentalModelId")), -1)
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
if r.authorization != nil {
parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "")
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
if localVarHTTPResponse.StatusCode == 422 {
var v HTTPValidationError
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v)
newErr.model = v
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiListMentalModelsRequest struct { type ApiListMentalModelsRequest struct {
ctx context.Context ctx context.Context
ApiService *MentalModelsAPIService ApiService *MentalModelsAPIService

View file

@ -913,6 +913,19 @@ class Hindsight:
""" """
return _run_async(self._mental_models_api.delete_mental_model(bank_id, mental_model_id, _request_timeout=self._timeout)) return _run_async(self._mental_models_api.delete_mental_model(bank_id, mental_model_id, _request_timeout=self._timeout))
def get_mental_model_history(self, bank_id: str, mental_model_id: str):
"""
Get the content change history of a mental model.
Returns a list of history entries (most recent first), each with
``previous_content`` and ``changed_at`` fields.
Args:
bank_id: The memory bank ID
mental_model_id: The mental model ID
"""
return _run_async(self._mental_models_api.get_mental_model_history(bank_id, mental_model_id, _request_timeout=self._timeout))
# Directives methods # Directives methods
def create_directive( def create_directive(

View file

@ -936,6 +936,299 @@ class MentalModelsApi:
@validate_call
async def get_mental_model_history(
self,
bank_id: StrictStr,
mental_model_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> object:
"""Get mental model history
Get the refresh history of a mental model, showing content changes over time.
:param bank_id: (required)
:type bank_id: str
:param mental_model_id: (required)
:type mental_model_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._get_mental_model_history_serialize(
bank_id=bank_id,
mental_model_id=mental_model_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "object",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
).data
@validate_call
async def get_mental_model_history_with_http_info(
self,
bank_id: StrictStr,
mental_model_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> ApiResponse[object]:
"""Get mental model history
Get the refresh history of a mental model, showing content changes over time.
:param bank_id: (required)
:type bank_id: str
:param mental_model_id: (required)
:type mental_model_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._get_mental_model_history_serialize(
bank_id=bank_id,
mental_model_id=mental_model_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "object",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
)
@validate_call
async def get_mental_model_history_without_preload_content(
self,
bank_id: StrictStr,
mental_model_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> RESTResponseType:
"""Get mental model history
Get the refresh history of a mental model, showing content changes over time.
:param bank_id: (required)
:type bank_id: str
:param mental_model_id: (required)
:type mental_model_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._get_mental_model_history_serialize(
bank_id=bank_id,
mental_model_id=mental_model_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "object",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
return response_data.response
def _get_mental_model_history_serialize(
self,
bank_id,
mental_model_id,
authorization,
_request_auth,
_content_type,
_headers,
_host_index,
) -> RequestSerialized:
_host = None
_collection_formats: Dict[str, str] = {
}
_path_params: Dict[str, str] = {}
_query_params: List[Tuple[str, str]] = []
_header_params: Dict[str, Optional[str]] = _headers or {}
_form_params: List[Tuple[str, str]] = []
_files: Dict[
str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]]
] = {}
_body_params: Optional[bytes] = None
# process the path parameters
if bank_id is not None:
_path_params['bank_id'] = bank_id
if mental_model_id is not None:
_path_params['mental_model_id'] = mental_model_id
# process the query parameters
# process the header parameters
if authorization is not None:
_header_params['authorization'] = authorization
# process the form parameters
# process the body parameter
# set the HTTP header `Accept`
if 'Accept' not in _header_params:
_header_params['Accept'] = self.api_client.select_header_accept(
[
'application/json'
]
)
# authentication setting
_auth_settings: List[str] = [
]
return self.api_client.param_serialize(
method='GET',
resource_path='/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history',
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
body=_body_params,
post_params=_form_params,
files=_files,
auth_settings=_auth_settings,
collection_formats=_collection_formats,
_host=_host,
_request_auth=_request_auth
)
@validate_call @validate_call
async def list_mental_models( async def list_mental_models(
self, self,

View file

@ -82,6 +82,9 @@ import type {
GetMemoryResponses, GetMemoryResponses,
GetMentalModelData, GetMentalModelData,
GetMentalModelErrors, GetMentalModelErrors,
GetMentalModelHistoryData,
GetMentalModelHistoryErrors,
GetMentalModelHistoryResponses,
GetMentalModelResponses, GetMentalModelResponses,
GetObservationHistoryData, GetObservationHistoryData,
GetObservationHistoryErrors, GetObservationHistoryErrors,
@ -503,6 +506,23 @@ export const updateMentalModel = <ThrowOnError extends boolean = false>(
}, },
}); });
/**
* Get mental model history
*
* Get the refresh history of a mental model, showing content changes over time.
*/
export const getMentalModelHistory = <ThrowOnError extends boolean = false>(
options: Options<GetMentalModelHistoryData, ThrowOnError>,
) =>
(options.client ?? client).get<
GetMentalModelHistoryResponses,
GetMentalModelHistoryErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history",
...options,
});
/** /**
* Refresh mental model * Refresh mental model
* *

View file

@ -3082,6 +3082,45 @@ export type UpdateMentalModelResponses = {
export type UpdateMentalModelResponse = export type UpdateMentalModelResponse =
UpdateMentalModelResponses[keyof UpdateMentalModelResponses]; UpdateMentalModelResponses[keyof UpdateMentalModelResponses];
export type GetMentalModelHistoryData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
/**
* Mental Model Id
*/
mental_model_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history";
};
export type GetMentalModelHistoryErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetMentalModelHistoryError =
GetMentalModelHistoryErrors[keyof GetMentalModelHistoryErrors];
export type GetMentalModelHistoryResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type RefreshMentalModelData = { export type RefreshMentalModelData = {
body?: never; body?: never;
headers?: { headers?: {

View file

@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { DATAPLANE_URL, getDataplaneHeaders } from "@/lib/hindsight-client";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ bankId: string; mentalModelId: string }> }
) {
try {
const { bankId, mentalModelId } = await params;
if (!bankId || !mentalModelId) {
return NextResponse.json(
{ error: "bank_id and mental_model_id are required" },
{ status: 400 }
);
}
const response = await fetch(
`${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models/${mentalModelId}/history`,
{ method: "GET", headers: getDataplaneHeaders() }
);
if (!response.ok) {
if (response.status === 404) {
return NextResponse.json({ error: "Mental model not found" }, { status: 404 });
}
throw new Error(`API returned ${response.status}`);
}
const data = await response.json();
return NextResponse.json(data, { status: 200 });
} catch (error) {
console.error("Error fetching mental model history:", error);
return NextResponse.json({ error: "Failed to fetch mental model history" }, { status: 500 });
}
}

View file

@ -4,8 +4,10 @@ import { useState, useEffect } from "react";
import { client, MentalModel } from "@/lib/api"; import { client, MentalModel } from "@/lib/api";
import { useBank } from "@/lib/bank-context"; import { useBank } from "@/lib/bank-context";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { Loader2, Zap } from "lucide-react"; import { Loader2, Zap, FileText, History, ChevronLeft, ChevronRight } from "lucide-react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
@ -100,20 +102,197 @@ export function MentalModelDetailContent({ mentalModel }: MentalModelDetailConte
); );
} }
type HistoryEntry = { previous_content: string | null; changed_at: string };
type LineDiff = { type: "same" | "removed" | "added"; text: string };
function diffLines(a: string, b: string): { left: LineDiff[]; right: LineDiff[] } {
const aLines = a.split("\n");
const bLines = b.split("\n");
const m = aLines.length;
const n = bLines.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 1; i <= m; i++)
for (let j = 1; j <= n; j++)
dp[i][j] =
aLines[i - 1] === bLines[j - 1]
? dp[i - 1][j - 1] + 1
: Math.max(dp[i - 1][j], dp[i][j - 1]);
const ops: LineDiff[] = [];
let i = m,
j = n;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && aLines[i - 1] === bLines[j - 1]) {
ops.push({ type: "same", text: aLines[i - 1] });
i--;
j--;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
ops.push({ type: "added", text: bLines[j - 1] });
j--;
} else {
ops.push({ type: "removed", text: aLines[i - 1] });
i--;
}
}
ops.reverse();
// Pair removed/added lines side-by-side; same lines appear on both sides
const left: LineDiff[] = [];
const right: LineDiff[] = [];
let k = 0;
while (k < ops.length) {
const op = ops[k];
if (op.type === "same") {
left.push(op);
right.push(op);
k++;
} else {
// collect a block of removed/added and align them
const removed: string[] = [];
const added: string[] = [];
while (k < ops.length && ops[k].type !== "same") {
if (ops[k].type === "removed") removed.push(ops[k].text);
else added.push(ops[k].text);
k++;
}
const maxLen = Math.max(removed.length, added.length);
for (let r = 0; r < maxLen; r++) {
left.push(
r < removed.length ? { type: "removed", text: removed[r] } : { type: "same", text: "" }
);
right.push(
r < added.length ? { type: "added", text: added[r] } : { type: "same", text: "" }
);
}
}
}
return { left, right };
}
function SideBySideDiff({ before, after }: { before: string; after: string }) {
const { left, right } = diffLines(before, after);
const hasChanges = left.some((l) => l.type !== "same") || right.some((r) => r.type !== "same");
if (!hasChanges) return <span className="text-sm text-muted-foreground italic">unchanged</span>;
return (
<div className="grid grid-cols-2 divide-x divide-border border border-border rounded-md overflow-hidden text-xs font-mono">
<div>
<div className="px-3 py-1.5 bg-muted text-muted-foreground font-sans font-semibold text-xs uppercase tracking-wide border-b border-border">
Before
</div>
{left.map((line, idx) => (
<div
key={idx}
className={`px-3 py-0.5 whitespace-pre-wrap leading-5 min-h-[1.25rem] ${
line.type === "removed"
? "bg-red-500/10 text-red-700 dark:text-red-400"
: "text-foreground"
}`}
>
{line.text}
</div>
))}
</div>
<div>
<div className="px-3 py-1.5 bg-muted text-muted-foreground font-sans font-semibold text-xs uppercase tracking-wide border-b border-border">
After
</div>
{right.map((line, idx) => (
<div
key={idx}
className={`px-3 py-0.5 whitespace-pre-wrap leading-5 min-h-[1.25rem] ${
line.type === "added"
? "bg-green-500/10 text-green-700 dark:text-green-400"
: "text-foreground"
}`}
>
{line.text}
</div>
))}
</div>
</div>
);
}
function MentalModelHistoryView({
history,
currentContent,
}: {
history: HistoryEntry[];
currentContent: string;
}) {
const [idx, setIdx] = useState(0);
const entry = history[idx];
const afterContent = idx === 0 ? currentContent : (history[idx - 1].previous_content ?? "");
return (
<div className="space-y-3">
{/* Navigation header */}
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
Change <span className="font-semibold text-foreground">{history.length - idx}</span> of{" "}
{history.length} &middot; {new Date(entry.changed_at).toLocaleString()}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="h-7 w-7 p-0"
disabled={idx === history.length - 1}
onClick={() => setIdx(idx + 1)}
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="h-7 w-7 p-0"
disabled={idx === 0}
onClick={() => setIdx(idx - 1)}
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* Change card */}
{entry.previous_content !== null ? (
<SideBySideDiff before={entry.previous_content} after={afterContent} />
) : (
<div className="border border-border rounded-lg p-3">
<span className="text-sm text-muted-foreground italic">
Previous content not available
</span>
</div>
)}
</div>
);
}
interface MentalModelDetailModalProps { interface MentalModelDetailModalProps {
mentalModelId: string | null; mentalModelId: string | null;
onClose: () => void; onClose: () => void;
initialTab?: string;
} }
/** /**
* Modal wrapper for MentalModelDetailContent. * Modal wrapper for MentalModelDetailContent.
* Fetches the mental model by ID and displays it in a dialog. * Fetches the mental model by ID and displays it in a dialog.
*/ */
export function MentalModelDetailModal({ mentalModelId, onClose }: MentalModelDetailModalProps) { export function MentalModelDetailModal({
mentalModelId,
onClose,
initialTab,
}: MentalModelDetailModalProps) {
const { currentBank } = useBank(); const { currentBank } = useBank();
const [mentalModel, setMentalModel] = useState<MentalModel | null>(null); const [mentalModel, setMentalModel] = useState<MentalModel | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState(initialTab ?? "model");
const [history, setHistory] = useState<HistoryEntry[] | null>(null);
const [loadingHistory, setLoadingHistory] = useState(false);
useEffect(() => { useEffect(() => {
if (!mentalModelId || !currentBank) return; if (!mentalModelId || !currentBank) return;
@ -122,6 +301,8 @@ export function MentalModelDetailModal({ mentalModelId, onClose }: MentalModelDe
setLoading(true); setLoading(true);
setError(null); setError(null);
setMentalModel(null); setMentalModel(null);
setHistory(null);
setActiveTab(initialTab ?? "model");
try { try {
const data = await client.getMentalModel(currentBank, mentalModelId); const data = await client.getMentalModel(currentBank, mentalModelId);
@ -137,6 +318,26 @@ export function MentalModelDetailModal({ mentalModelId, onClose }: MentalModelDe
loadMentalModel(); loadMentalModel();
}, [mentalModelId, currentBank]); }, [mentalModelId, currentBank]);
// Load history lazily when history tab is selected
useEffect(() => {
if (activeTab !== "history" || !mentalModel || !currentBank || history !== null) return;
const loadHistory = async () => {
setLoadingHistory(true);
try {
const data = await client.getMentalModelHistory(currentBank, mentalModel.id);
setHistory(data);
} catch (err) {
console.error("Error loading mental model history:", err);
setHistory([]);
} finally {
setLoadingHistory(false);
}
};
loadHistory();
}, [activeTab, mentalModel, currentBank, history]);
const isOpen = mentalModelId !== null; const isOpen = mentalModelId !== null;
return ( return (
@ -156,9 +357,41 @@ export function MentalModelDetailModal({ mentalModelId, onClose }: MentalModelDe
</div> </div>
</div> </div>
) : mentalModel ? ( ) : mentalModel ? (
<div className="flex-1 overflow-y-auto"> <Tabs
<MentalModelDetailContent mentalModel={mentalModel} /> value={activeTab}
</div> onValueChange={setActiveTab}
className="flex-1 flex flex-col overflow-hidden"
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="model" className="flex items-center gap-1.5">
<FileText className="w-3.5 h-3.5" />
Mental Model
</TabsTrigger>
<TabsTrigger value="history" className="flex items-center gap-1.5">
<History className="w-3.5 h-3.5" />
History
{history && history.length > 0 ? ` (${history.length})` : ""}
</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-y-auto mt-4">
<TabsContent value="model" className="mt-0">
<MentalModelDetailContent mentalModel={mentalModel} />
</TabsContent>
<TabsContent value="history" className="mt-0">
{loadingHistory ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : history && history.length > 0 ? (
<MentalModelHistoryView history={history} currentContent={mentalModel.content} />
) : (
<p className="text-sm text-muted-foreground italic">No history recorded yet.</p>
)}
</TabsContent>
</div>
</Tabs>
) : null} ) : null}
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View file

@ -50,9 +50,19 @@ import {
Pencil, Pencil,
LayoutGrid, LayoutGrid,
List, List,
History,
MoreVertical,
} from "lucide-react"; } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { MemoryDetailModal } from "./memory-detail-modal"; import { MemoryDetailModal } from "./memory-detail-modal";
import { DirectiveDetailModal } from "./directive-detail-modal"; import { DirectiveDetailModal } from "./directive-detail-modal";
import { MentalModelDetailModal } from "./mental-model-detail-modal";
interface ReflectResponseBasedOnFact { interface ReflectResponseBasedOnFact {
id: string; id: string;
@ -927,6 +937,7 @@ function MentalModelDetailPanel({
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [viewMemoryId, setViewMemoryId] = useState<string | null>(null); const [viewMemoryId, setViewMemoryId] = useState<string | null>(null);
const [viewDirectiveId, setViewDirectiveId] = useState<string | null>(null); const [viewDirectiveId, setViewDirectiveId] = useState<string | null>(null);
const [showHistoryModal, setShowHistoryModal] = useState(false);
const handleRefresh = async () => { const handleRefresh = async () => {
if (!currentBank) return; if (!currentBank) return;
@ -1031,27 +1042,44 @@ function MentalModelDetailPanel({
<div className="flex-1 mr-4"> <div className="flex-1 mr-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="text-xl font-bold text-foreground">{mentalModel.name}</h3> <h3 className="text-xl font-bold text-foreground">{mentalModel.name}</h3>
<Button variant="ghost" size="sm" onClick={onEdit} className="h-7 w-7 p-0">
<Pencil className="h-3.5 w-3.5" />
</Button>
</div> </div>
<p className="text-sm text-muted-foreground mt-1">{mentalModel.source_query}</p> <p className="text-sm text-muted-foreground mt-1">{mentalModel.source_query}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <DropdownMenu>
variant="outline" <DropdownMenuTrigger asChild>
size="sm" <Button variant="ghost" size="sm" className="h-8 px-2 gap-1" disabled={refreshing}>
onClick={handleRefresh} {refreshing ? (
disabled={refreshing} <Loader2 className="h-4 w-4 animate-spin" />
className="h-8" ) : (
> <MoreVertical className="h-4 w-4" />
{refreshing ? ( )}
<Loader2 className="w-4 h-4 animate-spin mr-1" /> <span className="text-xs">Actions</span>
) : ( </Button>
<RefreshCw className="w-4 h-4 mr-1" /> </DropdownMenuTrigger>
)} <DropdownMenuContent align="end">
Refresh <DropdownMenuItem onClick={onEdit}>
</Button> <Pencil className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShowHistoryModal(true)}>
<History className="h-4 w-4 mr-2" />
View History
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={onDelete}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="ghost" size="sm" onClick={onClose} className="h-8 w-8 p-0"> <Button variant="ghost" size="sm" onClick={onClose} className="h-8 w-8 p-0">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
@ -1222,18 +1250,6 @@ function MentalModelDetailPanel({
</p> </p>
</div> </div>
)} )}
<div className="pt-4 border-t border-border">
<Button
variant="outline"
size="sm"
onClick={onDelete}
className="text-muted-foreground hover:text-destructive hover:border-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
</div> </div>
</div> </div>
@ -1249,6 +1265,13 @@ function MentalModelDetailPanel({
onClose={() => setViewDirectiveId(null)} onClose={() => setViewDirectiveId(null)}
/> />
)} )}
{/* Mental Model History Modal */}
<MentalModelDetailModal
mentalModelId={showHistoryModal ? mentalModel.id : null}
onClose={() => setShowHistoryModal(false)}
initialTab="history"
/>
</div> </div>
); );
} }

View file

@ -813,6 +813,18 @@ export class ControlPlaneClient {
}); });
} }
/**
* Get the refresh history of a mental model
*/
async getMentalModelHistory(bankId: string, mentalModelId: string) {
return this.fetchApi<
{
previous_content: string | null;
changed_at: string;
}[]
>(`/api/banks/${bankId}/mental-models/${mentalModelId}/history`);
}
/** /**
* Get API version and feature flags * Get API version and feature flags
* Use this to check which capabilities are available in the dataplane * Use this to check which capabilities are available in the dataplane

View file

@ -272,6 +272,33 @@ For more details on tag matching modes (`any`, `any_strict`, `all`, `all_strict`
--- ---
## History
Every time a mental model's content changes (via refresh or manual update), the previous version is saved with a timestamp. You can retrieve the full change log with the history endpoint:
<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="get-mental-model-history" language="python" />
</TabItem>
</Tabs>
### Response
The endpoint returns a list of history entries, most recent first:
| Field | Type | Description |
|-------|------|-------------|
| `previous_content` | string \| null | The content before this change (`null` if not available) |
| `changed_at` | string | ISO 8601 timestamp of when the change occurred |
Each entry captures the **content before the change** and when it happened. The current content is returned by the standard [Get a Mental Model](#get-a-mental-model) endpoint.
:::note
History tracking is enabled by default. Set `HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY=false` to disable it.
:::
---
## Use Cases ## Use Cases
| Use Case | Example | | Use Case | Example |

View file

@ -531,6 +531,7 @@ For advanced authentication (JWT, OAuth, multi-tenant schemas), implement a cust
| `HINDSIGHT_API_RERANKER_MAX_CANDIDATES` | Max candidates to rerank per recall (RRF pre-filters the rest) | `300` | | `HINDSIGHT_API_RERANKER_MAX_CANDIDATES` | Max candidates to rerank per recall (RRF pre-filters the rest) | `300` |
| `HINDSIGHT_API_MPFP_TOP_K_NEIGHBORS` | Fan-out limit per node in MPFP graph traversal | `20` | | `HINDSIGHT_API_MPFP_TOP_K_NEIGHBORS` | Fan-out limit per node in MPFP graph traversal | `20` |
| `HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY` | Max concurrent mental model refreshes | `8` | | `HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY` | Max concurrent mental model refreshes | `8` |
| `HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY` | Track history of content changes to each mental model (previous content + timestamp). Disable to reduce storage if audit trails are not needed. | `true` |
#### Graph Retrieval Algorithms #### Graph Retrieval Algorithms
@ -762,6 +763,7 @@ Observations are consolidated knowledge synthesized from facts.
| Variable | Description | Default | | Variable | Description | Default |
|----------|-------------|---------| |----------|-------------|---------|
| `HINDSIGHT_API_ENABLE_OBSERVATIONS` | Enable observation consolidation | `true` | | `HINDSIGHT_API_ENABLE_OBSERVATIONS` | Enable observation consolidation | `true` |
| `HINDSIGHT_API_ENABLE_OBSERVATION_HISTORY` | Track history of changes to each observation (previous content + timestamp). Disable to reduce storage if audit trails are not needed. | `true` |
| `HINDSIGHT_API_CONSOLIDATION_BATCH_SIZE` | Memories to load per batch (internal optimization) | `50` | | `HINDSIGHT_API_CONSOLIDATION_BATCH_SIZE` | Memories to load per batch (internal optimization) | `50` |
| `HINDSIGHT_API_CONSOLIDATION_MAX_TOKENS` | Max tokens for recall when finding related observations during consolidation | `1024` | | `HINDSIGHT_API_CONSOLIDATION_MAX_TOKENS` | Max tokens for recall when finding related observations during consolidation | `1024` |
| `HINDSIGHT_API_CONSOLIDATION_LLM_BATCH_SIZE` | Number of facts sent to the LLM in a single consolidation call. Higher values reduce LLM calls and improve throughput at the cost of larger prompts. Set to `1` to disable batching. Configurable per bank. | `8` | | `HINDSIGHT_API_CONSOLIDATION_LLM_BATCH_SIZE` | Number of facts sent to the LLM in a single consolidation call. Higher values reduce LLM calls and improve throughput at the cost of larger prompts. Set to `1` to disable batching. Configurable per bank. | `8` |

View file

@ -110,6 +110,18 @@ if mental_model_id:
# [/docs:update-mental-model] # [/docs:update-mental-model]
# [docs:get-mental-model-history]
# Get the change history of a mental model
history = client.get_mental_model_history(
bank_id=BANK_ID,
mental_model_id=mental_model_id
)
for entry in history:
print(f"Changed at: {entry['changed_at']}")
print(f"Previous content: {entry['previous_content']}")
# [/docs:get-mental-model-history]
# [docs:delete-mental-model] # [docs:delete-mental-model]
# Delete a mental model # Delete a mental model
client.delete_mental_model( client.delete_mental_model(

View file

@ -1310,6 +1310,72 @@
} }
} }
}, },
"/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history": {
"get": {
"tags": [
"Mental Models"
],
"summary": "Get mental model history",
"description": "Get the refresh history of a mental model, showing content changes over time.",
"operationId": "get_mental_model_history",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "mental_model_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Mental Model Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/refresh": { "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/refresh": {
"post": { "post": {
"tags": [ "tags": [