diff --git a/hindsight-api-slim/hindsight_api/api/http.py b/hindsight-api-slim/hindsight_api/api/http.py index 7960abc6..5645ebce 100644 --- a/hindsight-api-slim/hindsight_api/api/http.py +++ b/hindsight-api-slim/hindsight_api/api/http.py @@ -463,6 +463,12 @@ class MemoryItem(BaseModel): description="Named retain strategy for this item. Overrides the bank's default strategy for this item only. " "Strategies are defined in the bank config under 'retain_strategies'.", ) + update_mode: Literal["replace", "append"] | None = Field( + default=None, + description="How to handle an existing document with the same document_id. " + "'replace' (default) deletes old data and reprocesses from scratch. " + "'append' concatenates new content to the existing document text and reprocesses.", + ) @field_validator("timestamp", mode="before") @classmethod @@ -5307,6 +5313,8 @@ def _register_routes(app: FastAPI): content_dict["tags"] = item.tags if item.observation_scopes is not None: content_dict["observation_scopes"] = item.observation_scopes + if item.update_mode is not None: + content_dict["update_mode"] = item.update_mode strategy_groups[effective].append(content_dict) if request.async_: diff --git a/hindsight-api-slim/hindsight_api/engine/memory_engine.py b/hindsight-api-slim/hindsight_api/engine/memory_engine.py index 90978bd7..b14da014 100644 --- a/hindsight-api-slim/hindsight_api/engine/memory_engine.py +++ b/hindsight-api-slim/hindsight_api/engine/memory_engine.py @@ -2146,6 +2146,11 @@ class MemoryEngine(MemoryEngineInterface): f"Each content item in a batch must have a unique document_id to avoid race conditions." ) + # Validate update_mode=append requires document_id + for item in contents: + if item.get("update_mode") == "append" and not item.get("document_id"): + raise ValueError("update_mode='append' requires a document_id") + # Auto-chunk large batches by token count to avoid timeouts and memory issues # Calculate total token count total_tokens = sum(count_tokens(item.get("content", "")) for item in contents) diff --git a/hindsight-api-slim/hindsight_api/engine/retain/fact_storage.py b/hindsight-api-slim/hindsight_api/engine/retain/fact_storage.py index 4e9d78b5..9002175b 100644 --- a/hindsight-api-slim/hindsight_api/engine/retain/fact_storage.py +++ b/hindsight-api-slim/hindsight_api/engine/retain/fact_storage.py @@ -17,6 +17,23 @@ from .types import ProcessedFact logger = logging.getLogger(__name__) +async def get_document_content( + conn, + bank_id: str, + document_id: str, +) -> str | None: + """Fetch the original_text of an existing document. + + Returns None if the document does not exist. + """ + row = await conn.fetchval( + f"SELECT original_text FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2", + document_id, + bank_id, + ) + return row + + async def insert_facts_batch( conn, bank_id: str, facts: list[ProcessedFact], document_id: str | None = None ) -> list[str]: diff --git a/hindsight-api-slim/hindsight_api/engine/retain/orchestrator.py b/hindsight-api-slim/hindsight_api/engine/retain/orchestrator.py index 9870553c..46bf4ebd 100644 --- a/hindsight-api-slim/hindsight_api/engine/retain/orchestrator.py +++ b/hindsight-api-slim/hindsight_api/engine/retain/orchestrator.py @@ -523,6 +523,35 @@ async def retain_batch( except Exception: logger.warning("Failed to persist generated document_id", exc_info=True) + # --- Append mode: prepend existing document content to new content --- + # When update_mode="append", fetch the existing document text and prepend it + # so the full document is reprocessed (delta retain will skip unchanged chunks). + update_mode = None + for item in contents_dicts: + item_mode = item.get("update_mode") + if item_mode: + update_mode = item_mode + break + + if update_mode == "append" and effective_doc_id and is_first_batch: + async with acquire_with_retry(pool) as conn: + existing_text = await fact_storage.get_document_content(conn, bank_id, effective_doc_id) + if existing_text: + # Prepend existing text as a new content item at the beginning + existing_content: RetainContentDict = {"content": existing_text} + # Copy context/tags from first item for consistency + first = contents_dicts[0] + if first.get("context"): + existing_content["context"] = first["context"] + if first.get("tags"): + existing_content["tags"] = first["tags"] + contents_dicts = [existing_content, *contents_dicts] + # Rebuild contents list to match + contents = _build_contents(contents_dicts, document_tags) + log_buffer.append( + f"[append] Prepended {len(existing_text):,} chars from existing document {effective_doc_id}" + ) + # --- Delta retain: check if we can skip unchanged chunks --- if is_first_batch: delta_result = await _try_delta_retain( diff --git a/hindsight-api-slim/hindsight_api/engine/retain/types.py b/hindsight-api-slim/hindsight_api/engine/retain/types.py index b27e0896..f7a4a428 100644 --- a/hindsight-api-slim/hindsight_api/engine/retain/types.py +++ b/hindsight-api-slim/hindsight_api/engine/retain/types.py @@ -25,6 +25,9 @@ class RetainContentDict(TypedDict, total=False): observation_scopes: How to scope observations for consolidation (optional). "per_tag" runs one pass per individual tag; "combined" (default) runs a single pass with all tags; a list[list[str]] specifies exact passes. + update_mode: How to handle existing documents with the same document_id (optional). + "replace" (default) deletes old data and reprocesses. "append" concatenates + new content to the existing document and reprocesses. """ content: str # Required @@ -37,6 +40,7 @@ class RetainContentDict(TypedDict, total=False): observation_scopes: ( Literal["per_tag", "combined", "all_combinations"] | list[list[str]] ) # Observation scopes for consolidation + update_mode: Literal["replace", "append"] @dataclass diff --git a/hindsight-api-slim/hindsight_api/mcp_tools.py b/hindsight-api-slim/hindsight_api/mcp_tools.py index 8be3c08b..ecf9f9dd 100644 --- a/hindsight-api-slim/hindsight_api/mcp_tools.py +++ b/hindsight-api-slim/hindsight_api/mcp_tools.py @@ -140,6 +140,7 @@ def build_content_dict( metadata: dict[str, str] | None = None, document_id: str | None = None, strategy: str | None = None, + update_mode: str | None = None, ) -> tuple[dict[str, Any], str | None]: """Build a content dict for retain operations. @@ -151,6 +152,7 @@ def build_content_dict( metadata: Optional key-value metadata to attach to the memory document_id: Optional document ID to associate the memory with strategy: Optional named retain strategy override (e.g., 'exact', 'verbose') + update_mode: How to handle existing documents ('replace' or 'append') Returns: Tuple of (content_dict, error_message). error_message is None if successful. @@ -185,6 +187,8 @@ def build_content_dict( content_dict["document_id"] = document_id if strategy is not None: content_dict["strategy"] = strategy + if update_mode is not None: + content_dict["update_mode"] = update_mode return content_dict, None @@ -544,6 +548,7 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) document_id: str | None = None, bank_id: str | None = None, strategy: str | None = None, + update_mode: str | None = None, ) -> dict: """ Args: @@ -555,12 +560,15 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) document_id: Optional document ID to associate this memory with bank_id: Optional bank to store in (defaults to session bank). Use for cross-bank operations. strategy: Optional named retain strategy (e.g., 'exact' for verbatim storage). Strategies are defined in the bank config. + update_mode: How to handle existing documents with the same document_id. 'replace' (default) or 'append' (concatenates new content to existing). """ target_bank = bank_id or config.bank_id_resolver() if target_bank is None: return {"status": "error", "message": "No bank_id configured"} - content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id, strategy) + content_dict, error = build_content_dict( + content, context, timestamp, tags, metadata, document_id, strategy, update_mode + ) if error: return {"status": "error", "message": error} @@ -595,6 +603,7 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) metadata: dict[str, str] | None = None, document_id: str | None = None, strategy: str | None = None, + update_mode: str | None = None, ) -> dict: """ Args: @@ -605,12 +614,15 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) metadata: Optional key-value metadata to attach (e.g., {'source': 'slack', 'channel': 'general'}) document_id: Optional document ID to associate this memory with strategy: Optional named retain strategy (e.g., 'exact' for verbatim storage). Strategies are defined in the bank config. + update_mode: How to handle existing documents with the same document_id. 'replace' (default) or 'append' (concatenates new content to existing). """ target_bank = config.bank_id_resolver() if target_bank is None: return {"status": "error", "message": "No bank_id configured"} - content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id, strategy) + content_dict, error = build_content_dict( + content, context, timestamp, tags, metadata, document_id, strategy, update_mode + ) if error: return {"status": "error", "message": error} diff --git a/hindsight-api-slim/tests/test_retain_append_mode.py b/hindsight-api-slim/tests/test_retain_append_mode.py new file mode 100644 index 00000000..5bb95671 --- /dev/null +++ b/hindsight-api-slim/tests/test_retain_append_mode.py @@ -0,0 +1,240 @@ +""" +Tests for retain update_mode='append' — appends new content to existing documents. +""" + +import logging +from datetime import datetime, timezone + +import pytest + +from hindsight_api.engine.memory_engine import Budget + +logger = logging.getLogger(__name__) + + +def _ts(): + return datetime.now(timezone.utc).timestamp() + + +@pytest.mark.asyncio +async def test_append_mode_concatenates_content(memory, request_context): + """ + When update_mode='append', new content should be appended to the existing + document and the full document should be reprocessed. Facts from both + old and new content should be recallable. + """ + bank_id = f"test_append_{_ts()}" + document_id = "conversation-append" + + try: + # First retain — initial content + v1_units = await memory.retain_async( + bank_id=bank_id, + content="Alice works at Google as a software engineer.", + context="team info", + document_id=document_id, + request_context=request_context, + ) + assert len(v1_units) > 0, "v1 should create facts" + + doc_v1 = await memory.get_document(document_id, bank_id, request_context=request_context) + v1_text = doc_v1["original_text"] + assert "Alice works at Google" in v1_text + + # Second retain with append — add new content + v2_units = await memory.retain_batch_async( + bank_id=bank_id, + contents=[ + { + "content": "Bob works at Microsoft as a data scientist.", + "context": "team info", + "document_id": document_id, + "update_mode": "append", + } + ], + request_context=request_context, + ) + + # Verify document now contains both old and new content + doc_v2 = await memory.get_document(document_id, bank_id, request_context=request_context) + v2_text = doc_v2["original_text"] + assert "Alice works at Google" in v2_text, "Original content should be preserved" + assert "Bob works at Microsoft" in v2_text, "New content should be appended" + + # Verify facts from both old and new content are recallable + result_alice = await memory.recall_async( + bank_id=bank_id, + query="Where does Alice work?", + budget=Budget.MID, + max_tokens=1000, + request_context=request_context, + ) + assert len(result_alice.results) > 0, "Should recall facts about Alice" + + result_bob = await memory.recall_async( + bank_id=bank_id, + query="Where does Bob work?", + budget=Budget.MID, + max_tokens=1000, + request_context=request_context, + ) + assert len(result_bob.results) > 0, "Should recall facts about Bob" + + finally: + await memory.delete_bank(bank_id, request_context=request_context) + + +@pytest.mark.asyncio +async def test_append_mode_no_existing_document(memory, request_context): + """ + When update_mode='append' but no existing document exists, + it should behave like a normal retain (no content to prepend). + """ + bank_id = f"test_append_new_{_ts()}" + document_id = "new-doc-append" + + try: + units = await memory.retain_batch_async( + bank_id=bank_id, + contents=[ + { + "content": "Charlie is a product manager at Stripe.", + "context": "team info", + "document_id": document_id, + "update_mode": "append", + } + ], + request_context=request_context, + ) + + assert len(units) > 0, "Should create facts even with no existing document" + # Flatten if nested + flat_units = units[0] if units and isinstance(units[0], list) else units + assert len(flat_units) > 0 + + doc = await memory.get_document(document_id, bank_id, request_context=request_context) + assert "Charlie is a product manager" in doc["original_text"] + + finally: + await memory.delete_bank(bank_id, request_context=request_context) + + +@pytest.mark.asyncio +async def test_append_mode_requires_document_id(memory, request_context): + """update_mode='append' without document_id should raise ValueError.""" + bank_id = f"test_append_no_docid_{_ts()}" + + with pytest.raises(ValueError, match="update_mode='append' requires a document_id"): + await memory.retain_batch_async( + bank_id=bank_id, + contents=[ + { + "content": "Some content", + "update_mode": "append", + } + ], + request_context=request_context, + ) + + +@pytest.mark.asyncio +async def test_append_mode_multiple_appends(memory, request_context): + """Multiple appends should accumulate content over successive retains.""" + bank_id = f"test_multi_append_{_ts()}" + document_id = "multi-append-doc" + + try: + # Initial retain + await memory.retain_async( + bank_id=bank_id, + content="Day 1: Alice joined the team.", + context="journal", + document_id=document_id, + request_context=request_context, + ) + + # First append + await memory.retain_batch_async( + bank_id=bank_id, + contents=[ + { + "content": "Day 2: Alice completed her onboarding.", + "context": "journal", + "document_id": document_id, + "update_mode": "append", + } + ], + request_context=request_context, + ) + + # Second append + await memory.retain_batch_async( + bank_id=bank_id, + contents=[ + { + "content": "Day 3: Alice shipped her first feature.", + "context": "journal", + "document_id": document_id, + "update_mode": "append", + } + ], + request_context=request_context, + ) + + # Verify all content is present + doc = await memory.get_document(document_id, bank_id, request_context=request_context) + text = doc["original_text"] + assert "Day 1" in text, "Original content should be present" + assert "Day 2" in text, "First append should be present" + assert "Day 3" in text, "Second append should be present" + + # All days should be recallable + result = await memory.recall_async( + bank_id=bank_id, + query="What happened on Alice's first days?", + budget=Budget.MID, + max_tokens=1000, + request_context=request_context, + ) + assert len(result.results) > 0, "Should recall facts from all appends" + + finally: + await memory.delete_bank(bank_id, request_context=request_context) + + +@pytest.mark.asyncio +async def test_replace_mode_is_default(memory, request_context): + """Without update_mode (or update_mode='replace'), retain should replace content.""" + bank_id = f"test_replace_default_{_ts()}" + document_id = "replace-doc" + + try: + await memory.retain_async( + bank_id=bank_id, + content="Alice works at Google.", + context="team info", + document_id=document_id, + request_context=request_context, + ) + + # Retain again without update_mode — should replace + await memory.retain_batch_async( + bank_id=bank_id, + contents=[ + { + "content": "Bob works at Microsoft.", + "context": "team info", + "document_id": document_id, + } + ], + request_context=request_context, + ) + + doc = await memory.get_document(document_id, bank_id, request_context=request_context) + text = doc["original_text"] + # With replace, only new content should remain + assert "Bob works at Microsoft" in text, "New content should be present" + assert "Alice works at Google" not in text, "Old content should be replaced" + + finally: + await memory.delete_bank(bank_id, request_context=request_context) diff --git a/hindsight-cli/src/commands/memory.rs b/hindsight-cli/src/commands/memory.rs index ac2c7b60..52820546 100644 --- a/hindsight-cli/src/commands/memory.rs +++ b/hindsight-cli/src/commands/memory.rs @@ -418,6 +418,7 @@ pub fn retain( tags: None, observation_scopes: None, strategy: None, + update_mode: None, }; let request = RetainRequest { diff --git a/hindsight-clients/go/api/openapi.yaml b/hindsight-clients/go/api/openapi.yaml index 75133d84..a5cecdbe 100644 --- a/hindsight-clients/go/api/openapi.yaml +++ b/hindsight-clients/go/api/openapi.yaml @@ -4784,6 +4784,12 @@ components: strategy: nullable: true type: string + update_mode: + enum: + - replace + - append + nullable: true + type: string required: - content title: MemoryItem diff --git a/hindsight-clients/go/model_memory_item.go b/hindsight-clients/go/model_memory_item.go index eba3424e..ed713f72 100644 --- a/hindsight-clients/go/model_memory_item.go +++ b/hindsight-clients/go/model_memory_item.go @@ -30,6 +30,7 @@ type MemoryItem struct { Tags []string `json:"tags,omitempty"` ObservationScopes NullableObservationScopes `json:"observation_scopes,omitempty"` Strategy NullableString `json:"strategy,omitempty"` + UpdateMode NullableString `json:"update_mode,omitempty"` } type _MemoryItem MemoryItem @@ -385,6 +386,48 @@ func (o *MemoryItem) UnsetStrategy() { o.Strategy.Unset() } +// GetUpdateMode returns the UpdateMode field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *MemoryItem) GetUpdateMode() string { + if o == nil || IsNil(o.UpdateMode.Get()) { + var ret string + return ret + } + return *o.UpdateMode.Get() +} + +// GetUpdateModeOk returns a tuple with the UpdateMode field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *MemoryItem) GetUpdateModeOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.UpdateMode.Get(), o.UpdateMode.IsSet() +} + +// HasUpdateMode returns a boolean if a field has been set. +func (o *MemoryItem) HasUpdateMode() bool { + if o != nil && o.UpdateMode.IsSet() { + return true + } + + return false +} + +// SetUpdateMode gets a reference to the given NullableString and assigns it to the UpdateMode field. +func (o *MemoryItem) SetUpdateMode(v string) { + o.UpdateMode.Set(&v) +} +// SetUpdateModeNil sets the value for UpdateMode to be an explicit nil +func (o *MemoryItem) SetUpdateModeNil() { + o.UpdateMode.Set(nil) +} + +// UnsetUpdateMode ensures that no value is present for UpdateMode, not even an explicit nil +func (o *MemoryItem) UnsetUpdateMode() { + o.UpdateMode.Unset() +} + func (o MemoryItem) MarshalJSON() ([]byte, error) { toSerialize,err := o.ToMap() if err != nil { @@ -420,6 +463,9 @@ func (o MemoryItem) ToMap() (map[string]interface{}, error) { if o.Strategy.IsSet() { toSerialize["strategy"] = o.Strategy.Get() } + if o.UpdateMode.IsSet() { + toSerialize["update_mode"] = o.UpdateMode.Get() + } return toSerialize, nil } diff --git a/hindsight-clients/python/hindsight_client/hindsight_client.py b/hindsight-clients/python/hindsight_client/hindsight_client.py index 08f2aa67..91c67542 100644 --- a/hindsight-clients/python/hindsight_client/hindsight_client.py +++ b/hindsight-clients/python/hindsight_client/hindsight_client.py @@ -238,6 +238,7 @@ class Hindsight: metadata: dict[str, str] | None = None, entities: list[dict[str, str]] | None = None, tags: list[str] | None = None, + update_mode: str | None = None, ) -> RetainResponse: """ Store a single memory (sync wrapper — prefer :meth:`aretain` in async code). @@ -251,22 +252,24 @@ class Hindsight: metadata: Optional user-defined metadata entities: Optional list of entities [{"text": "...", "type": "..."}] tags: Optional list of tags for filtering memories during recall/reflect + update_mode: How to handle existing documents ('replace' or 'append') Returns: RetainResponse with success status """ + item: dict[str, Any] = { + "content": content, + "timestamp": timestamp, + "context": context, + "metadata": metadata, + "entities": entities, + "tags": tags, + } + if update_mode is not None: + item["update_mode"] = update_mode return self.retain_batch( bank_id=bank_id, - items=[ - { - "content": content, - "timestamp": timestamp, - "context": context, - "metadata": metadata, - "entities": entities, - "tags": tags, - } - ], + items=[item], document_id=document_id, ) @@ -727,6 +730,7 @@ class Hindsight: tags=item.get("tags"), observation_scopes=obs_scopes, strategy=item.get("strategy"), + update_mode=item.get("update_mode"), ) ) @@ -748,6 +752,7 @@ class Hindsight: metadata: dict[str, str] | None = None, entities: list[dict[str, str]] | None = None, tags: list[str] | None = None, + update_mode: str | None = None, ) -> RetainResponse: """ Store a single memory (async — preferred over :meth:`retain`). @@ -761,22 +766,24 @@ class Hindsight: metadata: Optional user-defined metadata entities: Optional list of entities [{"text": "...", "type": "..."}] tags: Optional list of tags for filtering memories during recall/reflect + update_mode: How to handle existing documents ('replace' or 'append') Returns: RetainResponse with success status """ + item: dict[str, Any] = { + "content": content, + "timestamp": timestamp, + "context": context, + "metadata": metadata, + "entities": entities, + "tags": tags, + } + if update_mode is not None: + item["update_mode"] = update_mode return await self.aretain_batch( bank_id=bank_id, - items=[ - { - "content": content, - "timestamp": timestamp, - "context": context, - "metadata": metadata, - "entities": entities, - "tags": tags, - } - ], + items=[item], document_id=document_id, ) diff --git a/hindsight-clients/python/hindsight_client_api/models/memory_item.py b/hindsight-clients/python/hindsight_client_api/models/memory_item.py index 518700eb..746cf9aa 100644 --- a/hindsight-clients/python/hindsight_client_api/models/memory_item.py +++ b/hindsight-clients/python/hindsight_client_api/models/memory_item.py @@ -17,7 +17,7 @@ import pprint import re # noqa: F401 import json -from pydantic import BaseModel, ConfigDict, StrictStr +from pydantic import BaseModel, ConfigDict, StrictStr, field_validator from typing import Any, ClassVar, Dict, List, Optional from hindsight_client_api.models.entity_input import EntityInput from hindsight_client_api.models.observation_scopes import ObservationScopes @@ -38,7 +38,18 @@ class MemoryItem(BaseModel): tags: Optional[List[StrictStr]] = None observation_scopes: Optional[ObservationScopes] = None strategy: Optional[StrictStr] = None - __properties: ClassVar[List[str]] = ["content", "timestamp", "context", "metadata", "document_id", "entities", "tags", "observation_scopes", "strategy"] + update_mode: Optional[StrictStr] = None + __properties: ClassVar[List[str]] = ["content", "timestamp", "context", "metadata", "document_id", "entities", "tags", "observation_scopes", "strategy", "update_mode"] + + @field_validator('update_mode') + def update_mode_validate_enum(cls, value): + """Validates the enum""" + if value is None: + return value + + if value not in set(['replace', 'append']): + raise ValueError("must be one of enum values ('replace', 'append')") + return value model_config = ConfigDict( populate_by_name=True, @@ -132,6 +143,11 @@ class MemoryItem(BaseModel): if self.strategy is None and "strategy" in self.model_fields_set: _dict['strategy'] = None + # set to None if update_mode (nullable) is None + # and model_fields_set contains the field + if self.update_mode is None and "update_mode" in self.model_fields_set: + _dict['update_mode'] = None + return _dict @classmethod @@ -152,7 +168,8 @@ class MemoryItem(BaseModel): "entities": [EntityInput.from_dict(_item) for _item in obj["entities"]] if obj.get("entities") is not None else None, "tags": obj.get("tags"), "observation_scopes": ObservationScopes.from_dict(obj["observation_scopes"]) if obj.get("observation_scopes") is not None else None, - "strategy": obj.get("strategy") + "strategy": obj.get("strategy"), + "update_mode": obj.get("update_mode") }) return _obj diff --git a/hindsight-clients/rust/src/lib.rs b/hindsight-clients/rust/src/lib.rs index c416afbc..4f0544bf 100644 --- a/hindsight-clients/rust/src/lib.rs +++ b/hindsight-clients/rust/src/lib.rs @@ -73,6 +73,7 @@ mod tests { tags: None, observation_scopes: None, strategy: None, + update_mode: None, }, types::MemoryItem { content: "Bob works with Alice on the search team".to_string(), @@ -84,6 +85,7 @@ mod tests { tags: None, observation_scopes: None, strategy: None, + update_mode: None, }, ], document_tags: None, diff --git a/hindsight-clients/typescript/generated/types.gen.ts b/hindsight-clients/typescript/generated/types.gen.ts index 3220af60..4be6bde9 100644 --- a/hindsight-clients/typescript/generated/types.gen.ts +++ b/hindsight-clients/typescript/generated/types.gen.ts @@ -1657,6 +1657,12 @@ export type MemoryItem = { * Named retain strategy for this item. Overrides the bank's default strategy for this item only. Strategies are defined in the bank config under 'retain_strategies'. */ strategy?: string | null; + /** + * Update Mode + * + * How to handle an existing document with the same document_id. 'replace' (default) deletes old data and reprocesses from scratch. 'append' concatenates new content to the existing document text and reprocesses. + */ + update_mode?: "replace" | "append" | null; }; /** diff --git a/hindsight-clients/typescript/src/index.ts b/hindsight-clients/typescript/src/index.ts index 76d19120..2bed471c 100644 --- a/hindsight-clients/typescript/src/index.ts +++ b/hindsight-clients/typescript/src/index.ts @@ -83,6 +83,7 @@ export interface MemoryItemInput { tags?: string[]; observation_scopes?: "per_tag" | "combined" | "all_combinations" | string[][]; strategy?: string; + update_mode?: "replace" | "append"; } export class HindsightClient { @@ -137,6 +138,8 @@ export class HindsightClient { entities?: EntityInput[]; /** Optional list of tags for this memory */ tags?: string[]; + /** How to handle existing documents: 'replace' (default) or 'append' */ + updateMode?: "replace" | "append"; } ): Promise { const item: { @@ -147,6 +150,7 @@ export class HindsightClient { document_id?: string; entities?: EntityInput[]; tags?: string[]; + update_mode?: "replace" | "append"; } = { content }; if (options?.timestamp) { item.timestamp = @@ -169,6 +173,9 @@ export class HindsightClient { if (options?.tags) { item.tags = options.tags; } + if (options?.updateMode) { + item.update_mode = options.updateMode; + } const response = await sdk.retainMemories({ client: this.client, @@ -192,6 +199,7 @@ export class HindsightClient { tags: item.tags, observation_scopes: item.observation_scopes, strategy: item.strategy, + update_mode: item.update_mode, timestamp: item.timestamp instanceof Date ? item.timestamp.toISOString() diff --git a/hindsight-docs/static/openapi.json b/hindsight-docs/static/openapi.json index 740988d9..efac4477 100644 --- a/hindsight-docs/static/openapi.json +++ b/hindsight-docs/static/openapi.json @@ -7232,6 +7232,22 @@ ], "title": "Strategy", "description": "Named retain strategy for this item. Overrides the bank's default strategy for this item only. Strategies are defined in the bank config under 'retain_strategies'." + }, + "update_mode": { + "anyOf": [ + { + "type": "string", + "enum": [ + "replace", + "append" + ] + }, + { + "type": "null" + } + ], + "title": "Update Mode", + "description": "How to handle an existing document with the same document_id. 'replace' (default) deletes old data and reprocesses from scratch. 'append' concatenates new content to the existing document text and reprocesses." } }, "type": "object", diff --git a/skills/hindsight-docs/references/openapi.json b/skills/hindsight-docs/references/openapi.json index 740988d9..efac4477 100644 --- a/skills/hindsight-docs/references/openapi.json +++ b/skills/hindsight-docs/references/openapi.json @@ -7232,6 +7232,22 @@ ], "title": "Strategy", "description": "Named retain strategy for this item. Overrides the bank's default strategy for this item only. Strategies are defined in the bank config under 'retain_strategies'." + }, + "update_mode": { + "anyOf": [ + { + "type": "string", + "enum": [ + "replace", + "append" + ] + }, + { + "type": "null" + } + ], + "title": "Update Mode", + "description": "How to handle an existing document with the same document_id. 'replace' (default) deletes old data and reprocesses from scratch. 'append' concatenates new content to the existing document text and reprocesses." } }, "type": "object",