From 576473b6aab499fe4e587bb64a9a64a0f0f2a9ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Fri, 6 Mar 2026 16:16:05 +0100 Subject: [PATCH] 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 --- hindsight-api/hindsight_api/api/http.py | 35 +- hindsight-api/hindsight_api/config.py | 7 + .../engine/consolidation/consolidator.py | 27 +- .../hindsight_api/engine/memory_engine.py | 95 ++++- hindsight-api/hindsight_api/main.py | 1 + hindsight-clients/go/api/openapi.yaml | 50 ++- hindsight-clients/go/api_memory.go | 128 +++++- .../hindsight_client_api/api/memory_api.py | 299 +++++++++++++- .../typescript/generated/sdk.gen.ts | 22 +- .../typescript/generated/types.gen.ts | 39 ++ hindsight-control-plane/.gitignore | 1 + hindsight-control-plane/next.config.ts | 6 + .../[bankId]/observations/[modelId]/route.ts | 9 +- .../api/memories/[memoryId]/history/route.ts | 38 ++ .../src/components/data-view.tsx | 60 ++- .../src/components/memory-detail-modal.tsx | 382 +++++++++++------- .../src/components/memory-detail-panel.tsx | 26 +- .../components/observation-history-view.tsx | 296 ++++++++++++++ hindsight-control-plane/src/lib/api.ts | 33 ++ hindsight-control-plane/tsconfig.json | 18 +- hindsight-docs/static/openapi.json | 68 +++- scripts/dev/start-control-plane.sh | 8 +- scripts/dev/start.sh | 27 +- 23 files changed, 1459 insertions(+), 216 deletions(-) create mode 100644 hindsight-control-plane/src/app/api/memories/[memoryId]/history/route.ts create mode 100644 hindsight-control-plane/src/components/observation-history-view.tsx diff --git a/hindsight-api/hindsight_api/api/http.py b/hindsight-api/hindsight_api/api/http.py index eef3dcc3..6704e29f 100644 --- a/hindsight-api/hindsight_api/api/http.py +++ b/hindsight-api/hindsight_api/api/http.py @@ -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, diff --git a/hindsight-api/hindsight_api/config.py b/hindsight-api/hindsight_api/config.py index 19311ec6..a104ae30 100644 --- a/hindsight-api/hindsight_api/config.py +++ b/hindsight-api/hindsight_api/config.py @@ -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)) ), diff --git a/hindsight-api/hindsight_api/engine/consolidation/consolidator.py b/hindsight-api/hindsight_api/engine/consolidation/consolidator.py index f9a4c6cb..276b98d8 100644 --- a/hindsight-api/hindsight_api/engine/consolidation/consolidator.py +++ b/hindsight-api/hindsight_api/engine/consolidation/consolidator.py @@ -766,13 +766,17 @@ async def _execute_update_action( logger.debug(f"Update skipped: observation {observation_id} not found in recall results") return - history = [ - { - "previous_text": model.text, - "changed_at": datetime.now(timezone.utc).isoformat(), - "source_memory_ids": [str(mid) for mid in source_memory_ids], - } - ] + 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(), + "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), diff --git a/hindsight-api/hindsight_api/engine/memory_engine.py b/hindsight-api/hindsight_api/engine/memory_engine.py index 807e8bce..21ee90de 100644 --- a/hindsight-api/hindsight_api/engine/memory_engine.py +++ b/hindsight-api/hindsight_api/engine/memory_engine.py @@ -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, diff --git a/hindsight-api/hindsight_api/main.py b/hindsight-api/hindsight_api/main.py index 5794c241..dbd41501 100644 --- a/hindsight-api/hindsight_api/main.py +++ b/hindsight-api/hindsight_api/main.py @@ -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, diff --git a/hindsight-clients/go/api/openapi.yaml b/hindsight-clients/go/api/openapi.yaml index dfa62f53..cc49746f 100644 --- a/hindsight-clients/go/api/openapi.yaml +++ b/hindsight-clients/go/api/openapi.yaml @@ -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: |- diff --git a/hindsight-clients/go/api_memory.go b/hindsight-clients/go/api_memory.go index 9d9702a4..3352b7a6 100644 --- a/hindsight-clients/go/api_memory.go +++ b/hindsight-clients/go/api_memory.go @@ -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 diff --git a/hindsight-clients/python/hindsight_client_api/api/memory_api.py b/hindsight-clients/python/hindsight_client_api/api/memory_api.py index 8604fac7..f9d256af 100644 --- a/hindsight-clients/python/hindsight_client_api/api/memory_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/memory_api.py @@ -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, diff --git a/hindsight-clients/typescript/generated/sdk.gen.ts b/hindsight-clients/typescript/generated/sdk.gen.ts index 4ea3f3f5..eb7ecc14 100644 --- a/hindsight-clients/typescript/generated/sdk.gen.ts +++ b/hindsight-clients/typescript/generated/sdk.gen.ts @@ -83,6 +83,9 @@ import type { GetMentalModelData, GetMentalModelErrors, GetMentalModelResponses, + GetObservationHistoryData, + GetObservationHistoryErrors, + GetObservationHistoryResponses, GetOperationStatusData, GetOperationStatusErrors, GetOperationStatusResponses, @@ -252,7 +255,7 @@ export const listMemories = ( /** * 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 = ( options: Options, @@ -263,6 +266,23 @@ export const getMemory = ( 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 = ( + options: Options, +) => + (options.client ?? client).get< + GetObservationHistoryResponses, + GetObservationHistoryErrors, + ThrowOnError + >({ + url: "/v1/default/banks/{bank_id}/memories/{memory_id}/history", + ...options, + }); + /** * Recall memory * diff --git a/hindsight-clients/typescript/generated/types.gen.ts b/hindsight-clients/typescript/generated/types.gen.ts index feb5b54a..ca9f63de 100644 --- a/hindsight-clients/typescript/generated/types.gen.ts +++ b/hindsight-clients/typescript/generated/types.gen.ts @@ -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?: { diff --git a/hindsight-control-plane/.gitignore b/hindsight-control-plane/.gitignore index 1ac55d45..d07c312b 100644 --- a/hindsight-control-plane/.gitignore +++ b/hindsight-control-plane/.gitignore @@ -10,6 +10,7 @@ # next.js /.next/ +/.next-*/ /out/ # production diff --git a/hindsight-control-plane/next.config.ts b/hindsight-control-plane/next.config.ts index dff55dbb..2bca3a4e 100644 --- a/hindsight-control-plane/next.config.ts +++ b/hindsight-control-plane/next.config.ts @@ -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 diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/observations/[modelId]/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/observations/[modelId]/route.ts index ef9d8cfb..208f2afc 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/observations/[modelId]/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/observations/[modelId]/route.ts @@ -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(); diff --git a/hindsight-control-plane/src/app/api/memories/[memoryId]/history/route.ts b/hindsight-control-plane/src/app/api/memories/[memoryId]/history/route.ts new file mode 100644 index 00000000..cb06593b --- /dev/null +++ b/hindsight-control-plane/src/app/api/memories/[memoryId]/history/route.ts @@ -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 }); + } +} diff --git a/hindsight-control-plane/src/components/data-view.tsx b/hindsight-control-plane/src/components/data-view.tsx index 38710429..40b8d2c8 100644 --- a/hindsight-control-plane/src/components/data-view.tsx +++ b/hindsight-control-plane/src/components/data-view.tsx @@ -280,7 +280,7 @@ export function DataView({ factType }: DataViewProps) { return (
- {loading ? ( + {loading && !data ? (

Loading memories...

@@ -370,11 +370,11 @@ export function DataView({ factType }: DataViewProps) { {/* Consolidation status for observations */} {factType === "observation" && consolidationStatus && ( -
{consolidationStatus.pending_consolidation} Pending + )} -
+ )}
@@ -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(null); const [granularity, setGranularity] = useState("month"); const [currentIndex, setCurrentIndex] = useState(0); const timelineRef = useRef(null); @@ -1182,10 +1198,8 @@ function TimelineView({ {group.items.map((item: any, idx: number) => (
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 */}
@@ -1200,21 +1214,13 @@ function TimelineView({ {/* Connector dot */}
{/* Card */}

{item.text} @@ -1252,18 +1258,6 @@ function TimelineView({ ))}

- - {/* Detail Panel - Fixed on Right */} - {selectedItem && ( -
- setSelectedItem(null)} - inPanel - bankId={bankId} - /> -
- )}
); } diff --git a/hindsight-control-plane/src/components/memory-detail-modal.tsx b/hindsight-control-plane/src/components/memory-detail-modal.tsx index e1df1cd3..0d083195 100644 --- a/hindsight-control-plane/src/components/memory-detail-modal.tsx +++ b/hindsight-control-plane/src/components/memory-detail-modal.tsx @@ -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(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [activeTab, setActiveTab] = useState("memory"); + const [activeTab, setActiveTab] = useState(initialTab ?? "memory"); // Document and chunk data const [document, setDocument] = useState(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(null); + const [loadingHistory, setLoadingHistory] = useState(false); + // Source memory modal (for viewing source memories of observations) const [sourceMemoryModalId, setSourceMemoryModalId] = useState(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,162 +186,208 @@ export function MemoryDetailModal({ memoryId, onClose }: MemoryDetailModalProps)
) : memory ? ( isObservation ? ( - /* Observation view - no tabs since chunk/document don't apply */ -
- {/* Text */} -
-
Text
-

{memory.text}

-
+ /* Observation view - tabs for Info and History */ + + + + + Observation + + + + History + {history && history.length > 0 ? ` (${history.length})` : ""} + + - {/* Dates */} - {memory.occurred_start && ( -
-
- Occurred +
+ + {/* Text */} +
+
+ Text +
+

{memory.text}

-
- - - {new Date(memory.occurred_start).toLocaleString()} - {memory.occurred_end && memory.occurred_end !== memory.occurred_start && ( - <> - - {new Date(memory.occurred_end).toLocaleString()} - - )} - -
-
- )} - {memory.mentioned_at && ( -
-
- Mentioned -
-
- - {new Date(memory.mentioned_at).toLocaleString()} -
-
- )} - - {/* Entities */} - {memory.entities && memory.entities.length > 0 && ( -
-
- - Entities -
-
- {memory.entities.map((entity, idx) => ( - - {entity} - - ))} -
-
- )} - - {/* Tags */} - - - {/* Observation Scopes */} - {memory.observation_scopes && ( -
-
- - Observation Scopes -
- {typeof memory.observation_scopes === "string" ? ( - - {memory.observation_scopes} - - ) : ( -
- {(memory.observation_scopes as string[][]).map((scope, i) => ( - - ))} + {/* Dates */} + {memory.occurred_start && ( +
+
+ Occurred +
+
+ + + {new Date(memory.occurred_start).toLocaleString()} + {memory.occurred_end && + memory.occurred_end !== memory.occurred_start && ( + <> + + {new Date(memory.occurred_end).toLocaleString()} + + )} + +
)} -
- )} - {/* Source Memories */} - {memory.source_memories && memory.source_memories.length > 0 && ( -
-
- Source Memories ({memory.source_memories.length}) -
-
- {memory.source_memories.map((source, i) => ( -
-
- - {source.type} - - -
-

{source.text}

- {source.context && ( -

- Context: {source.context} -

- )} -
- {source.occurred_start && ( -
-
Occurred
-
- {new Date(source.occurred_start).toLocaleString()} -
-
- )} - {source.mentioned_at && ( -
-
Mentioned
-
- {new Date(source.mentioned_at).toLocaleString()} -
-
- )} -
+ {memory.mentioned_at && ( +
+
+ Mentioned
- ))} -
-
- )} +
+ + {new Date(memory.mentioned_at).toLocaleString()} +
+
+ )} - {/* ID */} -
-
- Memory ID -
- - {memory.id} - + {/* Entities */} + {memory.entities && memory.entities.length > 0 && ( +
+
+ + Entities +
+
+ {memory.entities.map((entity, idx) => ( + + {entity} + + ))} +
+
+ )} + + {/* Tags */} + + + {/* Observation Scopes */} + {memory.observation_scopes && ( +
+
+ + Observation Scopes +
+ {typeof memory.observation_scopes === "string" ? ( + + {memory.observation_scopes} + + ) : ( +
+ {(memory.observation_scopes as string[][]).map((scope, i) => ( + + ))} +
+ )} +
+ )} + + {/* Source Memories */} + {memory.source_memories && memory.source_memories.length > 0 && ( +
+
+ Source Memories ({memory.source_memories.length}) +
+
+ {memory.source_memories.map((source, i) => ( +
+
+ + {source.type} + + +
+

{source.text}

+ {source.context && ( +

+ Context: {source.context} +

+ )} +
+ {source.occurred_start && ( +
+
Occurred
+
+ {new Date(source.occurred_start).toLocaleString()} +
+
+ )} + {source.mentioned_at && ( +
+
Mentioned
+
+ {new Date(source.mentioned_at).toLocaleString()} +
+
+ )} +
+
+ ))} +
+
+ )} + + {/* ID */} +
+
+ Memory ID +
+ + {memory.id} + +
+ + + + {loadingHistory ? ( +
+ +
+ ) : history && history.length > 0 ? ( + + ) : ( +

+ No history recorded yet. +

+ )} +
-
+ ) : ( /* World/Experience view - with tabs */ (null); const [loading, setLoading] = useState(false); const [sourceMemoryModalId, setSourceMemoryModalId] = useState(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({
)} + {/* View History button (observations only) */} + {isObservation && ( +
+ +
+ )} + {/* Memory ID */} {memoryId && (
@@ -333,6 +348,15 @@ export function MemoryDetailPanel({ memoryId={sourceMemoryModalId} onClose={() => setSourceMemoryModalId(null)} /> + + {/* History Modal */} + {historyModalOpen && memoryId && bankId && ( + setHistoryModalOpen(false)} + initialTab="history" + /> + )} ); } diff --git a/hindsight-control-plane/src/components/observation-history-view.tsx b/hindsight-control-plane/src/components/observation-history-view.tsx new file mode 100644 index 00000000..4ff9cd17 --- /dev/null +++ b/hindsight-control-plane/src/components/observation-history-view.tsx @@ -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 unchanged; + return ( + + {parts.map((part, idx) => + part.type === "same" ? ( + {part.text} + ) : part.type === "removed" ? ( + + {part.text} + + ) : ( + + {part.text} + + ) + )} + + ); +} + +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 unchanged; + return ( +
+ {kept.map((t, idx) => ( + + #{t} + + ))} + {removed.map((t, idx) => ( + + #{t} + + ))} + {added.map((t, idx) => ( + + +#{t} + + ))} +
+ ); +} + +function DateDiff({ + label, + before, + after, +}: { + label: string; + before: string | null; + after: string | null; +}) { + if (!before && !after) return null; + const changed = before !== after; + return ( +
+ {label}: + {changed ? ( + <> + + {before ? new Date(before).toLocaleString() : "—"} + + {" → "} + + {after ? new Date(after).toLocaleString() : "—"} + + + ) : ( + {after ? new Date(after).toLocaleString() : "—"} + )} +
+ ); +} + +function SourceFactItem({ fact }: { fact: NonNullable[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 ( +
+
+ {fact.type && ( + + {fact.type} + + )} + {fact.is_new && ( + + new + + )} + {fact.context && ( + {fact.context} + )} +
+ {fact.text ? ( +

{fact.text}

+ ) : ( +

(memory no longer available)

+ )} +
+ ); +} + +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 ( +
+ {/* Navigation header */} +
+ + Change {history.length - idx} of{" "} + {history.length} · {new Date(entry.changed_at).toLocaleString()} + +
+ + +
+
+ + {/* Change card */} +
+
+
Text
+ +
+ +
+
Tags
+ +
+ +
+
Dates
+ + + +
+ + {entry.source_facts && entry.source_facts.length > 0 && ( +
+
+ Source Facts ({entry.source_facts.length}) +
+
+ {entry.source_facts.map((fact) => ( + + ))} +
+
+ )} +
+
+ ); +} diff --git a/hindsight-control-plane/src/lib/api.ts b/hindsight-control-plane/src/lib/api.ts index 22b48da5..2824c471 100644 --- a/hindsight-control-plane/src/lib/api.ts +++ b/hindsight-control-plane/src/lib/api.ts @@ -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 */ diff --git a/hindsight-control-plane/tsconfig.json b/hindsight-control-plane/tsconfig.json index 13a445fa..fda5a815 100644 --- a/hindsight-control-plane/tsconfig.json +++ b/hindsight-control-plane/tsconfig.json @@ -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" diff --git a/hindsight-docs/static/openapi.json b/hindsight-docs/static/openapi.json index 0a4e2a34..cfbe26a6 100644 --- a/hindsight-docs/static/openapi.json +++ b/hindsight-docs/static/openapi.json @@ -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": [ diff --git a/scripts/dev/start-control-plane.sh b/scripts/dev/start-control-plane.sh index cea65e59..2106fc3e 100755 --- a/scripts/dev/start-control-plane.sh +++ b/scripts/dev/start-control-plane.sh @@ -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 \ No newline at end of file diff --git a/scripts/dev/start.sh b/scripts/dev/start.sh index 36c932bc..1647a9a4 100755 --- a/scripts/dev/start.sh +++ b/scripts/dev/start.sh @@ -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)