feat: observation history tracking and diff UI (#513)

* feat: add source facts token limits to consolidation and recall

- Add two new configurable (per-bank) parameters:
  - consolidation_source_facts_max_tokens: total token budget for source
    facts across all observations in the consolidation prompt (-1 = unlimited)
  - consolidation_source_facts_max_tokens_per_observation: per-observation
    cap so each observation gets a fair share of source facts (-1 = unlimited,
    default 256)
- Both are also exposed as recall API parameters via SourceFactsIncludeOptions
  (max_tokens and max_tokens_per_observation)
- Consolidation now uses resolve_full_config to respect bank-level overrides
- Improve consolidation prompt: temporal metadata (occurred_start=, | Involving:)
  is now clearly separated from observation text, with a concrete example showing
  the expected synthesis style and explicit rules not to copy raw fact lines
- Add tests for recall source facts capping and consolidation config forwarding
- Expose all three new fields in the control plane bank config UI
- Document new env vars in configuration.md
- Regenerate OpenAPI spec and all SDK clients

* fix: reorder observations UI fields and rename Label Groups to Entity Labels

* fix: revert Entities section title (only rename inner label)

* doc: add consolidation source facts and batch size fields to memory-banks docs

* feat: add observation history tracking and UI diff view

- Track observation changes over time in a JSONB history column,
  appending each update's previous state (text, tags, dates, sources)
  instead of overwriting
- Add HINDSIGHT_API_ENABLE_OBSERVATION_HISTORY config flag (default: true)
  to toggle history recording
- Expose history field in get_memory_unit for observations
- Fix observations/[modelId] route that was proxying to wrong endpoint
- Add History tab in observation modal and History section in panel,
  showing word-level and tag diffs between each change (newest first)
- Extract shared ObservationHistoryView component used by both modal and panel
- Add --random-port flag to start.sh to run multiple dev instances
- Scope Next.js distDir by port to prevent lock file collisions between instances
- Restyle consolidation pending badge (rounded-md with border) and add
  inline refresh button; fix loading flicker on data refresh

* feat: dedicated observation history endpoint with source facts diff

- Add GET /memories/{id}/history endpoint returning enriched history with
  resolved source fact texts and is_new flags per change
- Deprecate history field in GET /memories/{id} (always returns empty list)
- Reconstruct cumulative source facts per history entry by working backwards
  from current state, marking newly added facts with is_new
- Replace inline history panel with "View History" button opening modal
- History modal fetches from dedicated endpoint lazily on tab switch
- Timeline view now opens MemoryDetailModal instead of side panel
- History view uses prev/next navigation (left = older, right = newer)
- Fix --random-port: pass dynamic API_PORT as HINDSIGHT_CP_DATAPLANE_API_URL
  to control plane, preserving caller values over .env
This commit is contained in:
Nicolò Boschi 2026-03-06 16:16:05 +01:00 committed by GitHub
parent 99220d0527
commit 576473b6aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1459 additions and 216 deletions

View file

@ -2137,7 +2137,7 @@ def _register_routes(app: FastAPI):
@app.get(
"/v1/default/banks/{bank_id}/memories/{memory_id}",
summary="Get memory unit",
description="Get a single memory unit by ID with all its metadata including entities and tags.",
description="Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead.",
operation_id="get_memory",
tags=["Memory"],
)
@ -2167,6 +2167,39 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in /v1/default/banks/{bank_id}/memories/{memory_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/memories/{memory_id}/history",
summary="Get observation history",
description="Get the full history of an observation, with each change's source facts resolved to their text.",
operation_id="get_observation_history",
tags=["Memory"],
)
async def api_get_observation_history(
bank_id: str,
memory_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Get the history of a single observation by ID."""
try:
data = await app.state.memory.get_observation_history(
bank_id=bank_id,
memory_id=memory_id,
request_context=request_context,
)
if data is None:
raise HTTPException(status_code=404, detail=f"Memory unit '{memory_id}' not found")
return data
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in /v1/default/banks/{bank_id}/memories/{memory_id}/history: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/memories/recall",
response_model=RecallResponse,

View file

@ -298,6 +298,7 @@ ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION = (
"HINDSIGHT_API_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION"
)
ENV_OBSERVATIONS_MISSION = "HINDSIGHT_API_OBSERVATIONS_MISSION"
ENV_ENABLE_OBSERVATION_HISTORY = "HINDSIGHT_API_ENABLE_OBSERVATION_HISTORY"
# Webhook configuration (global, static - server-level only)
ENV_WEBHOOK_URL = "HINDSIGHT_API_WEBHOOK_URL"
@ -449,6 +450,7 @@ DEFAULT_FILE_DELETE_AFTER_RETAIN = True # Delete file bytes after retain (saves
# Observations defaults (consolidated knowledge from facts)
DEFAULT_ENABLE_OBSERVATIONS = True # Observations enabled by default
DEFAULT_ENABLE_OBSERVATION_HISTORY = True # Observation history tracking enabled by default
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_MAX_TOKENS = 512 # Max tokens for recall when finding related observations
@ -727,6 +729,7 @@ class HindsightConfig:
# Observations settings (consolidated knowledge from facts)
enable_observations: bool
enable_observation_history: bool
consolidation_batch_size: int
consolidation_llm_batch_size: int
consolidation_max_tokens: int
@ -1179,6 +1182,10 @@ class HindsightConfig:
== "true",
# Observations settings (consolidated knowledge from facts)
enable_observations=os.getenv(ENV_ENABLE_OBSERVATIONS, str(DEFAULT_ENABLE_OBSERVATIONS)).lower() == "true",
enable_observation_history=os.getenv(
ENV_ENABLE_OBSERVATION_HISTORY, str(DEFAULT_ENABLE_OBSERVATION_HISTORY)
).lower()
== "true",
consolidation_batch_size=int(
os.getenv(ENV_CONSOLIDATION_BATCH_SIZE, str(DEFAULT_CONSOLIDATION_BATCH_SIZE))
),

View file

@ -766,13 +766,17 @@ async def _execute_update_action(
logger.debug(f"Update skipped: observation {observation_id} not found in recall results")
return
history = [
{
from ...config import get_config
history_entry = {
"previous_text": model.text,
"previous_tags": list(model.tags or []),
"previous_occurred_start": model.occurred_start,
"previous_occurred_end": model.occurred_end,
"previous_mentioned_at": model.mentioned_at,
"changed_at": datetime.now(timezone.utc).isoformat(),
"source_memory_ids": [str(mid) for mid in source_memory_ids],
"new_source_memory_ids": [str(mid) for mid in source_memory_ids],
}
]
source_ids = list(model.source_fact_ids or []) + source_memory_ids
@ -787,13 +791,18 @@ async def _execute_update_action(
if perf:
perf.record_timing("embedding", time.time() - t0)
config = get_config()
history_clause = (
"history = COALESCE(history, '[]'::jsonb) || $3::jsonb," if config.enable_observation_history else ""
)
t0 = time.time()
await conn.execute(
f"""
UPDATE {fq_table("memory_units")}
SET text = $1,
embedding = $2::vector,
history = $3,
{history_clause}
source_memory_ids = $4,
proof_count = $5,
tags = $10,
@ -805,7 +814,7 @@ async def _execute_update_action(
""",
new_text,
embedding_str,
json.dumps(history),
json.dumps([history_entry]),
source_ids,
len(source_ids),
uuid.UUID(observation_id),

View file

@ -4345,7 +4345,11 @@ class MemoryEngine(MemoryEngineInterface):
"observation_scopes": row["observation_scopes"] if row["observation_scopes"] else None,
}
# For observations, include source_memory_ids and fetch source_memories
# For observations, include source_memory_ids
# history is deprecated here - use GET /memories/{id}/history instead
if row["fact_type"] == "observation":
result["history"] = []
if row["fact_type"] == "observation" and row["source_memory_ids"]:
source_ids = row["source_memory_ids"]
result["source_memory_ids"] = [str(sid) for sid in source_ids]
@ -4374,6 +4378,95 @@ class MemoryEngine(MemoryEngineInterface):
return result
async def get_observation_history(
self,
bank_id: str,
memory_id: str,
request_context: "RequestContext",
) -> list[dict] | None:
"""
Get the history of an observation, with source facts resolved to their text.
Returns None if the memory is not found or is not an observation.
Returns a list of history entries (most recent first), each with source_facts resolved.
"""
await self._authenticate_tenant(request_context)
if self._operation_validator:
from hindsight_api.extensions import BankReadContext
ctx = BankReadContext(bank_id=bank_id, operation="get_observation_history", request_context=request_context)
await self._validate_operation(self._operation_validator.validate_bank_read(ctx))
pool = await self._get_pool()
async with acquire_with_retry(pool) as conn:
row = await conn.fetchrow(
f"""
SELECT fact_type, history, source_memory_ids
FROM {fq_table("memory_units")}
WHERE id = $1 AND bank_id = $2
""",
uuid.UUID(memory_id),
bank_id,
)
if not row:
return None
if row["fact_type"] != "observation":
return []
raw_history = row["history"]
if isinstance(raw_history, str):
raw_history = json.loads(raw_history)
if not raw_history:
return []
# Collect all source memory IDs (current full set + all historical new ones)
current_source_ids: list[str] = [str(sid) for sid in (row["source_memory_ids"] or [])]
all_source_ids: set[uuid.UUID] = set(uuid.UUID(sid) for sid in current_source_ids)
for entry in raw_history:
for sid in entry.get("new_source_memory_ids", []):
try:
all_source_ids.add(uuid.UUID(sid))
except (ValueError, AttributeError):
pass
# Resolve all source memories in one query
source_map: dict[str, dict] = {}
if all_source_ids:
source_rows = await conn.fetch(
f"""
SELECT id, text, fact_type, context
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
""",
list(all_source_ids),
)
for r in source_rows:
source_map[str(r["id"])] = {
"id": str(r["id"]),
"text": r["text"],
"type": r["fact_type"],
"context": r["context"] or None,
}
# Reconstruct cumulative source IDs per change by working backwards from current state.
# Source IDs are only ever accumulated (never removed), so:
# after_change_N = before_change_N + new_source_memory_ids_N
cumulative_ids: list[str] = list(current_source_ids)
enriched: list[dict] = []
for entry in reversed(raw_history):
new_ids_in_entry: set[str] = set(entry.get("new_source_memory_ids", []))
source_facts = []
for sid in cumulative_ids:
fact = source_map.get(sid, {"id": sid, "text": None, "type": None, "context": None})
source_facts.append({**fact, "is_new": sid in new_ids_in_entry})
enriched_entry = dict(entry)
enriched_entry["source_facts"] = source_facts
enriched.append(enriched_entry)
# Step back: remove the new IDs added by this change to get the prior state
cumulative_ids = [sid for sid in cumulative_ids if sid not in new_ids_in_entry]
enriched.reverse()
return enriched
async def list_documents(
self,
bank_id: str,

View file

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

View file

@ -210,8 +210,9 @@ paths:
- Memory
/v1/default/banks/{bank_id}/memories/{memory_id}:
get:
description: Get a single memory unit by ID with all its metadata including
entities and tags.
description: "Get a single memory unit by ID with all its metadata including\
\ entities and tags. Note: the 'history' field is deprecated and always returns\
\ an empty list - use GET /memories/{memory_id}/history instead."
operationId: get_memory
parameters:
- explode: false
@ -253,6 +254,51 @@ paths:
summary: Get memory unit
tags:
- Memory
/v1/default/banks/{bank_id}/memories/{memory_id}/history:
get:
description: "Get the full history of an observation, with each change's source\
\ facts resolved to their text."
operationId: get_observation_history
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: path
name: memory_id
required: true
schema:
title: Memory 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 observation history
tags:
- Memory
/v1/default/banks/{bank_id}/memories/recall:
post:
description: |-

View file

@ -483,7 +483,7 @@ func (r ApiGetMemoryRequest) Execute() (interface{}, *http.Response, error) {
/*
GetMemory Get memory unit
Get a single memory unit by ID with all its metadata including entities and tags.
Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@ -589,6 +589,132 @@ func (a *MemoryAPIService) GetMemoryExecute(r ApiGetMemoryRequest) (interface{},
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiGetObservationHistoryRequest struct {
ctx context.Context
ApiService *MemoryAPIService
bankId string
memoryId string
authorization *string
}
func (r ApiGetObservationHistoryRequest) Authorization(authorization string) ApiGetObservationHistoryRequest {
r.authorization = &authorization
return r
}
func (r ApiGetObservationHistoryRequest) Execute() (interface{}, *http.Response, error) {
return r.ApiService.GetObservationHistoryExecute(r)
}
/*
GetObservationHistory Get observation history
Get the full history of an observation, with each change's source facts resolved to their text.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@param memoryId
@return ApiGetObservationHistoryRequest
*/
func (a *MemoryAPIService) GetObservationHistory(ctx context.Context, bankId string, memoryId string) ApiGetObservationHistoryRequest {
return ApiGetObservationHistoryRequest{
ApiService: a,
ctx: ctx,
bankId: bankId,
memoryId: memoryId,
}
}
// Execute executes the request
// @return interface{}
func (a *MemoryAPIService) GetObservationHistoryExecute(r ApiGetObservationHistoryRequest) (interface{}, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodGet
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue interface{}
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "MemoryAPIService.GetObservationHistory")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/v1/default/banks/{bank_id}/memories/{memory_id}/history"
localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1)
localVarPath = strings.Replace(localVarPath, "{"+"memory_id"+"}", url.PathEscape(parameterValueToString(r.memoryId, "memoryId")), -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 ApiListMemoriesRequest struct {
ctx context.Context
ApiService *MemoryAPIService

View file

@ -1022,7 +1022,7 @@ class MemoryApi:
) -> object:
"""Get memory unit
Get a single memory unit by ID with all its metadata including entities and tags.
Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead.
:param bank_id: (required)
:type bank_id: str
@ -1098,7 +1098,7 @@ class MemoryApi:
) -> ApiResponse[object]:
"""Get memory unit
Get a single memory unit by ID with all its metadata including entities and tags.
Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead.
:param bank_id: (required)
:type bank_id: str
@ -1174,7 +1174,7 @@ class MemoryApi:
) -> RESTResponseType:
"""Get memory unit
Get a single memory unit by ID with all its metadata including entities and tags.
Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead.
:param bank_id: (required)
:type bank_id: str
@ -1294,6 +1294,299 @@ class MemoryApi:
@validate_call
async def get_observation_history(
self,
bank_id: StrictStr,
memory_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 observation history
Get the full history of an observation, with each change's source facts resolved to their text.
:param bank_id: (required)
:type bank_id: str
:param memory_id: (required)
:type memory_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_observation_history_serialize(
bank_id=bank_id,
memory_id=memory_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_observation_history_with_http_info(
self,
bank_id: StrictStr,
memory_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 observation history
Get the full history of an observation, with each change's source facts resolved to their text.
:param bank_id: (required)
:type bank_id: str
:param memory_id: (required)
:type memory_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_observation_history_serialize(
bank_id=bank_id,
memory_id=memory_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_observation_history_without_preload_content(
self,
bank_id: StrictStr,
memory_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 observation history
Get the full history of an observation, with each change's source facts resolved to their text.
:param bank_id: (required)
:type bank_id: str
:param memory_id: (required)
:type memory_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_observation_history_serialize(
bank_id=bank_id,
memory_id=memory_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_observation_history_serialize(
self,
bank_id,
memory_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 memory_id is not None:
_path_params['memory_id'] = memory_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}/memories/{memory_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
async def list_memories(
self,

View file

@ -83,6 +83,9 @@ import type {
GetMentalModelData,
GetMentalModelErrors,
GetMentalModelResponses,
GetObservationHistoryData,
GetObservationHistoryErrors,
GetObservationHistoryResponses,
GetOperationStatusData,
GetOperationStatusErrors,
GetOperationStatusResponses,
@ -252,7 +255,7 @@ export const listMemories = <ThrowOnError extends boolean = false>(
/**
* Get memory unit
*
* Get a single memory unit by ID with all its metadata including entities and tags.
* Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead.
*/
export const getMemory = <ThrowOnError extends boolean = false>(
options: Options<GetMemoryData, ThrowOnError>,
@ -263,6 +266,23 @@ export const getMemory = <ThrowOnError extends boolean = false>(
ThrowOnError
>({ url: "/v1/default/banks/{bank_id}/memories/{memory_id}", ...options });
/**
* Get observation history
*
* Get the full history of an observation, with each change's source facts resolved to their text.
*/
export const getObservationHistory = <ThrowOnError extends boolean = false>(
options: Options<GetObservationHistoryData, ThrowOnError>,
) =>
(options.client ?? client).get<
GetObservationHistoryResponses,
GetObservationHistoryErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/memories/{memory_id}/history",
...options,
});
/**
* Recall memory
*

View file

@ -2549,6 +2549,45 @@ export type GetMemoryResponses = {
200: unknown;
};
export type GetObservationHistoryData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
/**
* Memory Id
*/
memory_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/memories/{memory_id}/history";
};
export type GetObservationHistoryErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetObservationHistoryError =
GetObservationHistoryErrors[keyof GetObservationHistoryErrors];
export type GetObservationHistoryResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type RecallMemoriesData = {
body: RecallRequest;
headers?: {

View file

@ -10,6 +10,7 @@
# next.js
/.next/
/.next-*/
/out/
# production

View file

@ -3,8 +3,14 @@ import path from "path";
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '';
// Use a port-scoped distDir so multiple dev instances don't collide on the lock file
const distDir = process.env.PORT && process.env.PORT !== '9999'
? `.next-${process.env.PORT}`
: '.next';
const nextConfig: NextConfig = {
output: 'standalone',
distDir,
basePath: basePath,
assetPrefix: basePath,
// Disable request logging in production

View file

@ -13,17 +13,14 @@ export async function GET(
}
const response = await fetch(
`${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models/${modelId}`,
`${DATAPLANE_URL}/v1/default/banks/${bankId}/memories/${modelId}`,
{ method: "GET", headers: getDataplaneHeaders() }
);
if (!response.ok) {
const errorText = await response.text();
console.error("API error getting mental model:", errorText);
return NextResponse.json(
{ error: "Failed to get mental model" },
{ status: response.status }
);
console.error("API error getting observation:", errorText);
return NextResponse.json({ error: "Failed to get observation" }, { status: response.status });
}
const data = await response.json();

View file

@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { DATAPLANE_URL, getDataplaneHeaders } from "@/lib/hindsight-client";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ memoryId: string }> }
) {
try {
const { memoryId } = await params;
const searchParams = request.nextUrl.searchParams;
const bankId = searchParams.get("bank_id");
if (!bankId) {
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
}
const response = await fetch(
`${DATAPLANE_URL}/v1/default/banks/${bankId}/memories/${memoryId}/history`,
{
method: "GET",
headers: getDataplaneHeaders({ "Content-Type": "application/json" }),
}
);
if (!response.ok) {
if (response.status === 404) {
return NextResponse.json({ error: "Memory 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 observation history:", error);
return NextResponse.json({ error: "Failed to fetch observation history" }, { status: 500 });
}
}

View file

@ -280,7 +280,7 @@ export function DataView({ factType }: DataViewProps) {
return (
<div>
{loading ? (
{loading && !data ? (
<div className="text-center py-12">
<RefreshCw className="w-8 h-8 mx-auto mb-3 text-muted-foreground animate-spin" />
<p className="text-muted-foreground">Loading memories...</p>
@ -370,11 +370,11 @@ export function DataView({ factType }: DataViewProps) {
{/* Consolidation status for observations */}
{factType === "observation" && consolidationStatus && (
<div
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium border ${
consolidationStatus.pending_consolidation === 0
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20"
: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20"
? "bg-green-500/10 text-green-700 dark:text-green-400 border-green-500/20"
: "bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20"
}`}
title={
consolidationStatus.pending_consolidation === 0
@ -391,9 +391,23 @@ export function DataView({ factType }: DataViewProps) {
<>
<Clock className="w-3 h-3" />
{consolidationStatus.pending_consolidation} Pending
<button
onClick={() =>
loadData(
fetchLimit,
searchQuery || undefined,
tagFilters.length > 0 ? tagFilters : undefined
)
}
disabled={loading}
className="ml-0.5 opacity-70 hover:opacity-100 disabled:opacity-40 transition-opacity"
title="Refresh observations"
>
<RefreshCw className={`w-3 h-3 ${loading ? "animate-spin" : ""}`} />
</button>
</>
)}
</div>
</span>
)}
</div>
<div className="flex items-center gap-2 bg-muted rounded-lg p-1">
@ -874,6 +888,7 @@ export function DataView({ factType }: DataViewProps) {
data={data}
filteredRows={filteredTableRows}
bankId={currentBank || undefined}
onMemoryClick={(id) => setModalMemoryId(id)}
/>
)}
</>
@ -899,12 +914,13 @@ function TimelineView({
data,
filteredRows,
bankId,
onMemoryClick,
}: {
data: any;
filteredRows: any[];
bankId?: string;
onMemoryClick: (id: string) => void;
}) {
const [selectedItem, setSelectedItem] = useState<any>(null);
const [granularity, setGranularity] = useState<Granularity>("month");
const [currentIndex, setCurrentIndex] = useState(0);
const timelineRef = useRef<HTMLDivElement>(null);
@ -1182,10 +1198,8 @@ function TimelineView({
{group.items.map((item: any, idx: number) => (
<div
key={item.id || idx}
onClick={() => setSelectedItem(item)}
className={`flex items-start cursor-pointer group ${
selectedItem?.id === item.id ? "opacity-100" : "hover:opacity-80"
}`}
onClick={() => onMemoryClick(item.id)}
className={`flex items-start cursor-pointer group ${"hover:opacity-80"}`}
>
{/* Date & Time */}
<div className="w-[60px] text-right pr-3 pt-1 flex-shrink-0">
@ -1200,21 +1214,13 @@ function TimelineView({
{/* Connector dot */}
<div className="flex-shrink-0 pt-2">
<div
className={`w-1.5 h-1.5 rounded-full z-10 ${
selectedItem?.id === item.id
? "bg-primary"
: "bg-muted-foreground/50 group-hover:bg-primary"
}`}
className={`w-1.5 h-1.5 rounded-full z-10 ${"bg-muted-foreground/50 group-hover:bg-primary"}`}
/>
</div>
{/* Card */}
<div
className={`ml-3 flex-1 p-2 rounded border transition-colors ${
selectedItem?.id === item.id
? "bg-primary/10 border-primary"
: "bg-card border-border hover:border-primary/50"
}`}
className={`ml-3 flex-1 p-2 rounded border transition-colors ${"bg-card border-border hover:border-primary/50"}`}
>
<p className="text-xs text-foreground line-clamp-2 leading-relaxed">
{item.text}
@ -1252,18 +1258,6 @@ function TimelineView({
))}
</div>
</div>
{/* Detail Panel - Fixed on Right */}
{selectedItem && (
<div className="fixed right-0 top-0 h-screen w-[420px] bg-card border-l-2 border-primary shadow-2xl z-50 overflow-y-auto animate-in slide-in-from-right duration-300 ease-out">
<MemoryDetailPanel
memory={selectedItem}
onClose={() => setSelectedItem(null)}
inPanel
bankId={bankId}
/>
</div>
)}
</div>
);
}

View file

@ -5,9 +5,10 @@ import { client } from "@/lib/api";
import { useBank } from "@/lib/bank-context";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Loader2, Calendar, Users, FileText, Layers, Tag } from "lucide-react";
import { Loader2, Calendar, Users, FileText, Layers, Tag, History } from "lucide-react";
import { TagList } from "@/components/ui/tag-list";
import { Button } from "@/components/ui/button";
import { ObservationHistoryView, type HistoryEntry } from "@/components/observation-history-view";
interface SourceMemory {
id: string;
@ -38,14 +39,15 @@ interface MemoryDetail {
interface MemoryDetailModalProps {
memoryId: string | null;
onClose: () => void;
initialTab?: string;
}
export function MemoryDetailModal({ memoryId, onClose }: MemoryDetailModalProps) {
export function MemoryDetailModal({ memoryId, onClose, initialTab }: MemoryDetailModalProps) {
const { currentBank } = useBank();
const [memory, setMemory] = useState<MemoryDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState("memory");
const [activeTab, setActiveTab] = useState(initialTab ?? "memory");
// Document and chunk data
const [document, setDocument] = useState<any>(null);
@ -53,6 +55,10 @@ export function MemoryDetailModal({ memoryId, onClose }: MemoryDetailModalProps)
const [loadingDocument, setLoadingDocument] = useState(false);
const [loadingChunk, setLoadingChunk] = useState(false);
// History data (fetched lazily from dedicated endpoint)
const [history, setHistory] = useState<HistoryEntry[] | null>(null);
const [loadingHistory, setLoadingHistory] = useState(false);
// Source memory modal (for viewing source memories of observations)
const [sourceMemoryModalId, setSourceMemoryModalId] = useState<string | null>(null);
@ -66,7 +72,8 @@ export function MemoryDetailModal({ memoryId, onClose }: MemoryDetailModalProps)
setMemory(null);
setDocument(null);
setChunk(null);
setActiveTab("memory");
setHistory(null);
setActiveTab(initialTab ?? "memory");
try {
const data = await client.getMemory(memoryId, currentBank);
@ -82,6 +89,33 @@ export function MemoryDetailModal({ memoryId, onClose }: MemoryDetailModalProps)
loadMemory();
}, [memoryId, currentBank]);
// Load history lazily when history tab is selected
useEffect(() => {
if (
activeTab !== "history" ||
!memory ||
memory.type !== "observation" ||
!currentBank ||
history !== null
)
return;
const loadHistory = async () => {
setLoadingHistory(true);
try {
const data = await client.getObservationHistory(memory.id, currentBank);
setHistory(data);
} catch (err) {
console.error("Error loading history:", err);
setHistory([]);
} finally {
setLoadingHistory(false);
}
};
loadHistory();
}, [activeTab, memory, currentBank, history]);
// Load document when tab is selected
useEffect(() => {
if (activeTab !== "document" || !memory?.document_id || !currentBank || document) return;
@ -152,11 +186,31 @@ export function MemoryDetailModal({ memoryId, onClose }: MemoryDetailModalProps)
</div>
) : memory ? (
isObservation ? (
/* Observation view - no tabs since chunk/document don't apply */
<div className="flex-1 overflow-y-auto space-y-4">
/* Observation view - tabs for Info and History */
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex-1 flex flex-col overflow-hidden"
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="memory" className="flex items-center gap-1.5">
<FileText className="w-3.5 h-3.5" />
Observation
</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="memory" className="mt-0 space-y-4">
{/* Text */}
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">Text</div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Text
</div>
<p className="text-sm text-foreground leading-relaxed">{memory.text}</p>
</div>
@ -170,7 +224,8 @@ export function MemoryDetailModal({ memoryId, onClose }: MemoryDetailModalProps)
<Calendar className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span>
{new Date(memory.occurred_start).toLocaleString()}
{memory.occurred_end && memory.occurred_end !== memory.occurred_start && (
{memory.occurred_end &&
memory.occurred_end !== memory.occurred_start && (
<>
<span className="text-muted-foreground mx-1"></span>
{new Date(memory.occurred_end).toLocaleString()}
@ -307,7 +362,32 @@ export function MemoryDetailModal({ memoryId, onClose }: MemoryDetailModalProps)
{memory.id}
</code>
</div>
</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 ? (
<ObservationHistoryView
history={history}
current={{
text: memory.text,
tags: memory.tags,
occurred_start: memory.occurred_start,
occurred_end: memory.occurred_end,
mentioned_at: memory.mentioned_at,
}}
/>
) : (
<p className="text-sm text-muted-foreground italic">
No history recorded yet.
</p>
)}
</TabsContent>
</div>
</Tabs>
) : (
/* World/Experience view - with tabs */
<Tabs

View file

@ -3,7 +3,7 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { TagList } from "@/components/ui/tag-list";
import { Copy, Check, X, Loader2, Calendar } from "lucide-react";
import { Copy, Check, X, Loader2, Calendar, History } from "lucide-react";
import { DocumentChunkModal } from "./document-chunk-modal";
import { MemoryDetailModal } from "./memory-detail-modal";
import { client } from "@/lib/api";
@ -29,6 +29,7 @@ export function MemoryDetailPanel({
const [fullMemory, setFullMemory] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [sourceMemoryModalId, setSourceMemoryModalId] = useState<string | null>(null);
const [historyModalOpen, setHistoryModalOpen] = useState(false);
// Fetch full memory data when panel opens
// For mental models, use getMentalModel to get source memories
@ -296,6 +297,20 @@ export function MemoryDetailPanel({
</div>
)}
{/* View History button (observations only) */}
{isObservation && (
<div className="border-t border-border pt-5">
<Button
variant="outline"
className="w-full flex items-center gap-2"
onClick={() => setHistoryModalOpen(true)}
>
<History className="h-4 w-4" />
View History
</Button>
</div>
)}
{/* Memory ID */}
{memoryId && (
<div>
@ -333,6 +348,15 @@ export function MemoryDetailPanel({
memoryId={sourceMemoryModalId}
onClose={() => setSourceMemoryModalId(null)}
/>
{/* History Modal */}
{historyModalOpen && memoryId && bankId && (
<MemoryDetailModal
memoryId={memoryId}
onClose={() => setHistoryModalOpen(false)}
initialTab="history"
/>
)}
</>
);
}

View file

@ -0,0 +1,296 @@
"use client";
import { useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
export interface HistoryEntry {
previous_text: string;
previous_tags: string[];
previous_occurred_start: string | null;
previous_occurred_end: string | null;
previous_mentioned_at: string | null;
changed_at: string;
new_source_memory_ids: string[];
source_facts?: {
id: string;
text: string | null;
type: string | null;
context: string | null;
is_new: boolean;
}[];
}
interface CurrentState {
text: string;
tags: string[];
occurred_start: string | null;
occurred_end: string | null;
mentioned_at: string | null;
}
function diffWords(a: string, b: string): { type: "same" | "removed" | "added"; text: string }[] {
const aWords = a.split(/(\s+)/);
const bWords = b.split(/(\s+)/);
const m = aWords.length;
const n = bWords.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] =
aWords[i - 1] === bWords[j - 1]
? dp[i - 1][j - 1] + 1
: Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
let i = m,
j = n;
const ops: { type: "same" | "removed" | "added"; text: string }[] = [];
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && aWords[i - 1] === bWords[j - 1]) {
ops.push({ type: "same", text: aWords[i - 1] });
i--;
j--;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
ops.push({ type: "added", text: bWords[j - 1] });
j--;
} else {
ops.push({ type: "removed", text: aWords[i - 1] });
i--;
}
}
return ops.reverse();
}
function TextDiff({ before, after }: { before: string; after: string }) {
const parts = diffWords(before, after);
const hasChanges = parts.some((p) => p.type !== "same");
if (!hasChanges) return <span className="text-sm text-muted-foreground italic">unchanged</span>;
return (
<span className="text-sm leading-relaxed">
{parts.map((part, idx) =>
part.type === "same" ? (
<span key={idx}>{part.text}</span>
) : part.type === "removed" ? (
<span
key={idx}
className="bg-red-500/15 text-red-700 dark:text-red-400 line-through rounded-sm px-0.5"
>
{part.text}
</span>
) : (
<span
key={idx}
className="bg-green-500/15 text-green-700 dark:text-green-400 rounded-sm px-0.5"
>
{part.text}
</span>
)
)}
</span>
);
}
function TagsDiff({ before, after }: { before: string[]; after: string[] }) {
const removed = before.filter((t) => !after.includes(t));
const added = after.filter((t) => !before.includes(t));
const kept = before.filter((t) => after.includes(t));
if (removed.length === 0 && added.length === 0)
return <span className="text-sm text-muted-foreground italic">unchanged</span>;
return (
<div className="flex gap-1 flex-wrap">
{kept.map((t, idx) => (
<span
key={idx}
className="text-[10px] px-1.5 py-0.5 rounded-md bg-amber-500/10 text-amber-700 border border-amber-500/20 font-mono"
>
#{t}
</span>
))}
{removed.map((t, idx) => (
<span
key={idx}
className="text-[10px] px-1.5 py-0.5 rounded-md bg-red-500/15 text-red-700 dark:text-red-400 border border-red-500/20 font-mono line-through"
>
#{t}
</span>
))}
{added.map((t, idx) => (
<span
key={idx}
className="text-[10px] px-1.5 py-0.5 rounded-md bg-green-500/15 text-green-700 dark:text-green-400 border border-green-500/20 font-mono"
>
+#{t}
</span>
))}
</div>
);
}
function DateDiff({
label,
before,
after,
}: {
label: string;
before: string | null;
after: string | null;
}) {
if (!before && !after) return null;
const changed = before !== after;
return (
<div>
<span className="text-xs text-muted-foreground">{label}: </span>
{changed ? (
<>
<span className="text-xs bg-red-500/15 text-red-700 dark:text-red-400 line-through rounded-sm px-0.5">
{before ? new Date(before).toLocaleString() : "—"}
</span>
{" → "}
<span className="text-xs bg-green-500/15 text-green-700 dark:text-green-400 rounded-sm px-0.5">
{after ? new Date(after).toLocaleString() : "—"}
</span>
</>
) : (
<span className="text-xs">{after ? new Date(after).toLocaleString() : "—"}</span>
)}
</div>
);
}
function SourceFactItem({ fact }: { fact: NonNullable<HistoryEntry["source_facts"]>[number] }) {
const typeColors =
fact.type === "experience"
? "bg-green-500/10 text-green-700 dark:text-green-400"
: "bg-blue-500/10 text-blue-700 dark:text-blue-400";
return (
<div
className={`p-2 rounded border space-y-1 ${
fact.is_new ? "border-green-500/40 bg-green-500/5" : "border-border/50 bg-muted/30"
}`}
>
<div className="flex items-center gap-1.5">
{fact.type && (
<span
className={`text-[10px] px-1.5 py-0.5 rounded font-medium flex-shrink-0 ${typeColors}`}
>
{fact.type}
</span>
)}
{fact.is_new && (
<span className="text-[10px] px-1.5 py-0.5 rounded font-medium bg-green-500/15 text-green-700 dark:text-green-400 border border-green-500/30">
new
</span>
)}
{fact.context && (
<span className="text-[10px] text-muted-foreground italic truncate">{fact.context}</span>
)}
</div>
{fact.text ? (
<p className="text-xs text-foreground leading-relaxed">{fact.text}</p>
) : (
<p className="text-xs text-muted-foreground italic">(memory no longer available)</p>
)}
</div>
);
}
export function ObservationHistoryView({
history,
current,
}: {
history: HistoryEntry[];
current: CurrentState;
}) {
// index 0 = most recent change
const entries = [...history].reverse();
const [idx, setIdx] = useState(0);
const entry = entries[idx];
const isLatest = idx === 0;
const afterText = isLatest ? current.text : entries[idx - 1].previous_text;
const afterTags = isLatest ? current.tags : entries[idx - 1].previous_tags;
const afterOccurredStart = isLatest
? current.occurred_start
: entries[idx - 1].previous_occurred_start;
const afterOccurredEnd = isLatest ? current.occurred_end : entries[idx - 1].previous_occurred_end;
const afterMentionedAt = isLatest ? current.mentioned_at : entries[idx - 1].previous_mentioned_at;
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 === entries.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 */}
<div className="border border-border rounded-lg p-3 space-y-3">
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">Text</div>
<TextDiff before={entry.previous_text} after={afterText} />
</div>
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">Tags</div>
<TagsDiff before={entry.previous_tags} after={afterTags} />
</div>
<div className="space-y-1">
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">Dates</div>
<DateDiff
label="Occurred start"
before={entry.previous_occurred_start}
after={afterOccurredStart}
/>
<DateDiff
label="Occurred end"
before={entry.previous_occurred_end}
after={afterOccurredEnd}
/>
<DateDiff
label="Mentioned at"
before={entry.previous_mentioned_at}
after={afterMentionedAt}
/>
</div>
{entry.source_facts && entry.source_facts.length > 0 && (
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Source Facts ({entry.source_facts.length})
</div>
<div className="space-y-1.5">
{entry.source_facts.map((fact) => (
<SourceFactItem key={fact.id} fact={fact} />
))}
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -413,9 +413,42 @@ export class ControlPlaneClient {
chunk_id: string | null;
tags: string[];
observation_scopes: string | string[][] | null;
history?: {
previous_text: string;
previous_tags: string[];
previous_occurred_start: string | null;
previous_occurred_end: string | null;
previous_mentioned_at: string | null;
changed_at: string;
new_source_memory_ids: string[];
}[];
}>(`/api/memories/${memoryId}?bank_id=${bankId}`);
}
/**
* Get the history of an observation with resolved source facts
*/
async getObservationHistory(memoryId: string, bankId: string) {
return this.fetchApi<
{
previous_text: string;
previous_tags: string[];
previous_occurred_start: string | null;
previous_occurred_end: string | null;
previous_mentioned_at: string | null;
changed_at: string;
new_source_memory_ids: string[];
source_facts: {
id: string;
text: string | null;
type: string | null;
context: string | null;
is_new: boolean;
}[];
}[]
>(`/api/memories/${memoryId}/history?bank_id=${bankId}`);
}
/**
* Get bank profile
*/

View file

@ -34,7 +34,23 @@
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"types/**/*.d.ts"
"types/**/*.d.ts",
".next-*/types/**/*.ts",
".next-*/dev/types/**/*.ts",
".next-49944/types/**/*.ts",
".next-49944/dev/types/**/*.ts",
".next-50612/types/**/*.ts",
".next-50612/dev/types/**/*.ts",
".next-54508/types/**/*.ts",
".next-54508/dev/types/**/*.ts",
".next-55630/types/**/*.ts",
".next-55630/dev/types/**/*.ts",
".next-58976/types/**/*.ts",
".next-58976/dev/types/**/*.ts",
".next-64080/types/**/*.ts",
".next-64080/dev/types/**/*.ts",
".next-50432/types/**/*.ts",
".next-50432/dev/types/**/*.ts"
],
"exclude": [
"node_modules"

View file

@ -322,7 +322,7 @@
"Memory"
],
"summary": "Get memory unit",
"description": "Get a single memory unit by ID with all its metadata including entities and tags.",
"description": "Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead.",
"operationId": "get_memory",
"parameters": [
{
@ -382,6 +382,72 @@
}
}
},
"/v1/default/banks/{bank_id}/memories/{memory_id}/history": {
"get": {
"tags": [
"Memory"
],
"summary": "Get observation history",
"description": "Get the full history of an observation, with each change's source facts resolved to their text.",
"operationId": "get_observation_history",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "memory_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Memory 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}/memories/recall": {
"post": {
"tags": [

View file

@ -18,6 +18,10 @@ echo "✅ SDK built successfully"
echo ""
echo "🚀 Starting Control Plane (Next.js dev server)..."
# Save caller-provided values before .env can overwrite them
_CALLER_PORT="${PORT:-}"
_CALLER_DATAPLANE_URL="${HINDSIGHT_CP_DATAPLANE_API_URL:-}"
if [ -f "$ROOT_DIR/.env" ]; then
echo "📄 Loading environment from $ROOT_DIR/.env"
# Load env vars from root .env file
@ -28,7 +32,9 @@ fi
# Map prefixed env vars to Next.js standard vars
export HOSTNAME="${HINDSIGHT_CP_HOSTNAME:-0.0.0.0}"
export PORT="${HINDSIGHT_CP_PORT:-9999}"
# Caller-provided values take priority over .env
export PORT="${_CALLER_PORT:-${HINDSIGHT_CP_PORT:-9999}}"
export HINDSIGHT_CP_DATAPLANE_API_URL="${_CALLER_DATAPLANE_URL:-${HINDSIGHT_CP_DATAPLANE_API_URL:-http://localhost:8888}}"
# Run dev server
npm run dev -w @vectorize-io/hindsight-control-plane

View file

@ -3,6 +3,14 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Parse --random-port flag
RANDOM_PORT=false
for arg in "$@"; do
if [ "$arg" = "--random-port" ]; then
RANDOM_PORT=true
fi
done
# Load .env to pick up HINDSIGHT_API_PORT if set
ROOT_DIR="$(git rev-parse --show-toplevel)"
if [ -f "$ROOT_DIR/.env" ]; then
@ -10,8 +18,19 @@ if [ -f "$ROOT_DIR/.env" ]; then
source "$ROOT_DIR/.env"
set +a
fi
API_PORT="${HINDSIGHT_API_PORT:-8888}"
CP_PORT="${HINDSIGHT_CP_PORT:-9999}"
get_free_port() {
python3 -c "import socket; s=socket.socket(); s.bind(('', 0)); print(s.getsockname()[1]); s.close()"
}
if [ "$RANDOM_PORT" = true ]; then
API_PORT="$(get_free_port)"
CP_PORT="$(get_free_port)"
echo "Using random ports — API: $API_PORT, Control Plane: $CP_PORT"
else
API_PORT="${HINDSIGHT_API_PORT:-8888}"
CP_PORT="${HINDSIGHT_CP_PORT:-9999}"
fi
PIDS=()
@ -37,7 +56,7 @@ trap cleanup EXIT INT TERM
# Start API
echo "Starting API server..."
"$SCRIPT_DIR/start-api.sh" &
"$SCRIPT_DIR/start-api.sh" --port "$API_PORT" &
API_PID=$!
PIDS+=($API_PID)
@ -63,7 +82,7 @@ fi
# Start Control Plane
echo ""
"$SCRIPT_DIR/start-control-plane.sh" &
PORT="$CP_PORT" HINDSIGHT_CP_DATAPLANE_API_URL="http://localhost:${API_PORT}" "$SCRIPT_DIR/start-control-plane.sh" &
CP_PID=$!
PIDS+=($CP_PID)