feat(retain): verbatim, chunks modes and named retain strategies (#593)

* feat(retain): add verbatim extraction mode

Adds retain_extraction_mode="verbatim" that stores each chunk as-is
without LLM summarization. The LLM still runs to extract entities,
temporal info, and location for full indexability — only the fact text
is replaced with the original chunk content (one memory per chunk).

Useful for RAG-style indexing and benchmarks where original text
must be preserved in memory.

- Add "verbatim" to RETAIN_EXTRACTION_MODES in config.py
- Add VERBATIM_FACT_EXTRACTION_PROMPT with instructions to preserve text
- Add _collapse_to_verbatim() post-processing to enforce 1 fact/chunk
- Expose in bank config UI dropdown with updated description
- Update configuration.md docs with verbatim mode description
- Add unit test for _collapse_to_verbatim and integration test via LLM
- Fix pre-existing main.py CLI override missing new reranker fields
- Fix pre-existing cross_encoder.py ty type error via setattr

* refactor(retain): verbatim mode skips 'what' field entirely

Instead of asking the LLM to echo the chunk text back into 'what' and
then discarding it, verbatim mode now uses a dedicated schema
(VerbatimExtractedFact) that omits the 'what' field altogether.
The LLM only returns metadata (entities, temporal info, location, who),
saving output tokens and avoiding any risk of paraphrasing before the
backfill.

- Add VerbatimExtractedFact / VerbatimFactExtractionResponse models
- Verbatim mode skips causal-relations section (nothing to relate causally)
- _extract_facts_from_chunk: allow missing 'what' in verbatim mode,
  set combined_text="" (backfilled by _collapse_to_verbatim)
- Update verbatim prompt to say DO NOT include 'what'

* feat(retain): add index_only extraction mode

Zero-LLM retain mode: chunks are stored as-is with no LLM call, no
entity extraction, and no temporal indexing. Embeddings still run for
semantic search. User-provided entities via RetainContent.entities
are the sole source of entity data.

Early return placed before the batch-API check so no LLM queue or
concurrency locks are acquired.

- Add "index_only" to RETAIN_EXTRACTION_MODES
- Add _extract_facts_index_only() with pure Python chunking path
- Add to UI dropdown and update description
- Update configuration.md with index_only docs and table entry
- Add unit test asserting zero token usage and exact text preservation

* feat(retain): add named retain strategies

Allows mixing extraction modes in a single bank via named strategies.
Each strategy is a set of hierarchical config overrides (extraction_mode,
chunk_size, entity_labels, entities_allow_free_form, etc.) applied on
top of the resolved bank config at retain time.

- retain_strategies: dict of strategy_name → config overrides (bank config)
- retain_default_strategy: default strategy when none specified (bank config)
- strategy field on /retain request: per-call override
- apply_strategy() in config_resolver applies overrides via dataclasses.replace()
- strategy propagates through retain_batch_async → _retain_batch_async_internal
  and through the async worker task payload
- Any hierarchical field is overridable per strategy, including entity_labels
  and entities_allow_free_form
- Docs updated with strategy configuration example and RRF fairness note
- Unit test for apply_strategy covering overrides, unknown strategy, and
  non-hierarchical field filtering

* feat(retain): add per-item strategy and strategy tests

- Add `strategy` field to `MemoryItem` so individual items in a retain
  request can override the request-level strategy
- Add `strategy` field to `FileRetainMetadata` for per-file strategy
  override in file retain requests
- Group memory items by effective strategy in `api_retain`; each group
  is processed as a separate batch, results are aggregated
- Thread strategy through `submit_async_file_retain` →
  `_handle_file_convert_retain` → retain task payload
- Add `operation_ids` to `RetainResponse` for async requests with
  mixed per-item strategies
- Add `test_strategy_overrides_extraction_mode_for_index_only`: unit
  test verifying a named strategy with index_only bypasses the LLM
- Add `test_retain_request_per_item_strategy_field`: unit test for
  per-item strategy grouping logic

* feat(ui): add retain strategies and default strategy to bank config UI

- Add StrategiesEditor component: per-strategy cards with name input and
  JSON overrides textarea; supports add/remove; validates JSON inline
- Add Default Strategy text input (retain_default_strategy)
- Update RetainEdits type and retainSlice() to include both new fields
- Regenerate OpenAPI spec (retain_strategies, retain_default_strategy,
  per-item strategy on MemoryItem/FileRetainMetadata, operation_ids on
  RetainResponse)

* refactor(ui): move retain strategies into its own dedicated config section

* feat(ui): improve retain strategies UX and add strategy to document dialog

- Strategy form now includes entity section (free form toggle + entity labels editor)
- Default strategy selector moved outside tab panel, above strategy chips
- Strategy tabs redesigned with underline indicator style for clarity
- Remove strategy confirms with AlertDialog
- Fix tab re-render bug when typing strategy name (skipSyncRef)
- Add strategy field to Add New Document dialog (text + per-file for uploads)
- File upload collapsible uses same Document/Tags/Source tabbed layout
- API: validate empty strategy names in config_resolver
- api.ts: add strategy field to retain and uploadFiles types

* fix: forward strategy through HTTP layer and SDK; add integration test

- route.ts: extract and forward `strategy` from request body to retainBatch
- TypeScript SDK: accept and forward `strategy` in retainBatch options and per-item
- config_resolver.py: validate empty strategy name keys on update
- bank-config-view.tsx: merge entity fields into RetainStrategyForm, redesign strategy tabs with underline style, add confirmation dialog for removal, fix tab-reset-on-typing with skipSyncRef, move default strategy selector outside panel
- bank-selector.tsx: add strategy field to Add Document dialog (per-file in tabbed collapsible)
- test_retain.py: add end-to-end integration test verifying named strategy application (index_only = 0 LLM tokens)

* fix: regenerate TypeScript client with strategy field in RetainRequest/MemoryItem

- Regenerate OpenAPI spec to include strategy field in RetainRequest and MemoryItem
- Regenerate TypeScript client from updated spec
- Add strategy to MemoryItemInput interface
- Remove (item as any) cast now that strategy is properly typed

* rename: index_only extraction mode → chunks

* remove top-level strategy from RetainRequest; strategy is per-item only

* fix(clients): update Go and Python generated clients with strategy/operation_ids fields

* fix(ci): update hierarchical field count, add strategy to Rust MemoryItem initializers

* fix(go-client): minimal targeted YAML updates for strategy/operation_ids fields
This commit is contained in:
Nicolò Boschi 2026-03-17 18:08:25 +01:00 committed by GitHub
parent ef90842f87
commit e4f8a157c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1503 additions and 276 deletions

View file

@ -425,6 +425,11 @@ class MemoryItem(BaseModel):
"A list of tag lists runs one pass per inner list, giving full control over which combinations to use."
),
)
strategy: str | None = Field(
default=None,
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'.",
)
@field_validator("timestamp", mode="before")
@classmethod
@ -491,6 +496,11 @@ class FileRetainMetadata(BaseModel):
description="Parser or ordered fallback chain for this file (overrides request-level parser). "
"E.g. 'iris' or ['iris', 'markitdown'].",
)
strategy: str | None = Field(
default=None,
description="Named retain strategy for this file. Overrides the bank's default strategy. "
"Strategies are defined in the bank config under 'retain_strategies'.",
)
class FileRetainRequest(BaseModel):
@ -544,7 +554,11 @@ class RetainResponse(BaseModel):
)
operation_id: str | None = Field(
default=None,
description="Operation ID for tracking async operations. Use GET /v1/default/banks/{bank_id}/operations to list operations. Only present when async=true.",
description="Operation ID for tracking async operations. Use GET /v1/default/banks/{bank_id}/operations to list operations. Only present when async=true. When items use different per-item strategies, use operation_ids instead.",
)
operation_ids: list[str] | None = Field(
default=None,
description="Operation IDs when items were submitted as multiple strategy groups (async=true with mixed per-item strategies). operation_id is set to the first entry for backward compatibility.",
)
usage: TokenUsage | None = Field(
default=None,
@ -4441,10 +4455,13 @@ def _register_routes(app: FastAPI):
metrics = get_metrics_collector()
try:
# Prepare contents for processing
contents = []
# Group items by strategy
strategy_groups: dict[str | None, list[dict]] = {}
for item in request.items:
content_dict = {"content": item.content}
effective = item.strategy
if effective not in strategy_groups:
strategy_groups[effective] = []
content_dict: dict = {"content": item.content}
if item.timestamp == "unset":
content_dict["event_date"] = None
elif item.timestamp:
@ -4461,20 +4478,30 @@ def _register_routes(app: FastAPI):
content_dict["tags"] = item.tags
if item.observation_scopes is not None:
content_dict["observation_scopes"] = item.observation_scopes
contents.append(content_dict)
strategy_groups[effective].append(content_dict)
if request.async_:
# Async processing: queue task and return immediately
result = await app.state.memory.submit_async_retain(
bank_id, contents, document_tags=request.document_tags, request_context=request_context
)
# Async processing: one submit per strategy group
all_operation_ids = []
total_items_count = 0
for group_strategy, contents in strategy_groups.items():
result = await app.state.memory.submit_async_retain(
bank_id,
contents,
document_tags=request.document_tags,
strategy=group_strategy,
request_context=request_context,
)
all_operation_ids.append(result["operation_id"])
total_items_count += result["items_count"]
return RetainResponse.model_validate(
{
"success": True,
"bank_id": bank_id,
"items_count": result["items_count"],
"items_count": total_items_count,
"async": True,
"operation_id": result["operation_id"],
"operation_id": all_operation_ids[0] if all_operation_ids else None,
"operation_ids": all_operation_ids if len(all_operation_ids) > 1 else None,
}
)
else:
@ -4493,24 +4520,41 @@ def _register_routes(app: FastAPI):
),
)
# Synchronous processing: wait for completion (record metrics)
# Synchronous processing: one batch per strategy group, aggregate results
total_items_count = 0
total_usage = TokenUsage(input_tokens=0, output_tokens=0, total_tokens=0)
with metrics.record_operation("retain", bank_id=bank_id, source="api"):
result, usage = await app.state.memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
document_tags=request.document_tags,
request_context=request_context,
return_usage=True,
outbox_callback=app.state.memory._build_retain_outbox_callback(
for group_strategy, contents in strategy_groups.items():
result, usage = await app.state.memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
operation_id=None,
schema=_current_schema.get(),
),
)
document_tags=request.document_tags,
strategy=group_strategy,
request_context=request_context,
return_usage=True,
outbox_callback=app.state.memory._build_retain_outbox_callback(
bank_id=bank_id,
contents=contents,
operation_id=None,
schema=_current_schema.get(),
),
)
total_items_count += len(contents)
if usage:
total_usage = TokenUsage(
input_tokens=total_usage.input_tokens + usage.input_tokens,
output_tokens=total_usage.output_tokens + usage.output_tokens,
total_tokens=total_usage.total_tokens + usage.total_tokens,
)
return RetainResponse.model_validate(
{"success": True, "bank_id": bank_id, "items_count": len(contents), "async": False, "usage": usage}
{
"success": True,
"bank_id": bank_id,
"items_count": total_items_count,
"async": False,
"usage": total_usage,
}
)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
@ -4673,6 +4717,7 @@ def _register_routes(app: FastAPI):
"tags": file_meta.tags or [],
"timestamp": file_meta.timestamp,
"parser": parser_chain,
"strategy": file_meta.strategy,
}
file_items.append(item)

View file

@ -267,6 +267,7 @@ ENV_RETAIN_EXTRACT_CAUSAL_LINKS = "HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS"
ENV_RETAIN_EXTRACTION_MODE = "HINDSIGHT_API_RETAIN_EXTRACTION_MODE"
ENV_RETAIN_MISSION = "HINDSIGHT_API_RETAIN_MISSION"
ENV_RETAIN_CUSTOM_INSTRUCTIONS = "HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS"
ENV_RETAIN_DEFAULT_STRATEGY = "HINDSIGHT_API_RETAIN_DEFAULT_STRATEGY"
ENV_RETAIN_BATCH_TOKENS = "HINDSIGHT_API_RETAIN_BATCH_TOKENS"
ENV_RETAIN_ENTITY_LOOKUP = "HINDSIGHT_API_RETAIN_ENTITY_LOOKUP"
ENV_RETAIN_BATCH_ENABLED = "HINDSIGHT_API_RETAIN_BATCH_ENABLED"
@ -443,9 +444,11 @@ DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 64000 # Max tokens for fact extraction L
DEFAULT_RETAIN_CHUNK_SIZE = 3000 # Max chars per chunk for fact extraction
DEFAULT_RETAIN_EXTRACT_CAUSAL_LINKS = True # Extract causal links between facts
DEFAULT_RETAIN_EXTRACTION_MODE = "concise" # Extraction mode: "concise", "verbose", or "custom"
RETAIN_EXTRACTION_MODES = ("concise", "verbose", "custom") # Allowed extraction modes
RETAIN_EXTRACTION_MODES = ("concise", "verbose", "custom", "verbatim", "chunks") # Allowed extraction modes
DEFAULT_RETAIN_MISSION = None # Declarative spec of what to retain (injected into any extraction mode)
DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS = None # Custom extraction guidelines (only used when mode="custom")
DEFAULT_RETAIN_DEFAULT_STRATEGY = None # Default strategy name (None = no strategy override)
DEFAULT_RETAIN_STRATEGIES: dict | None = None # Named retain strategies (dict of name → config overrides)
DEFAULT_RETAIN_BATCH_TOKENS = 10_000 # ~40KB of text # Max chars per sub-batch for async retain auto-splitting
DEFAULT_RETAIN_ENTITY_LOOKUP = "trigram" # "full" or "trigram"
DEFAULT_RETAIN_BATCH_ENABLED = False # Use LLM Batch API for fact extraction (only when async=True)
@ -719,6 +722,8 @@ class HindsightConfig:
retain_extraction_mode: str
retain_mission: str | None
retain_custom_instructions: str | None
retain_default_strategy: str | None
retain_strategies: dict | None
retain_batch_tokens: int
retain_batch_enabled: bool
retain_batch_poll_interval_seconds: int
@ -849,6 +854,8 @@ class HindsightConfig:
"retain_extraction_mode",
"retain_mission",
"retain_custom_instructions",
"retain_default_strategy",
"retain_strategies",
# Entity labels (controlled vocabulary for entity classification)
"entity_labels",
"entities_allow_free_form",
@ -1101,9 +1108,7 @@ class HindsightConfig:
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE, str(DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE)
).lower()
in ("true", "1"),
reranker_local_fp16=os.getenv(
ENV_RERANKER_LOCAL_FP16, str(DEFAULT_RERANKER_LOCAL_FP16)
).lower()
reranker_local_fp16=os.getenv(ENV_RERANKER_LOCAL_FP16, str(DEFAULT_RERANKER_LOCAL_FP16)).lower()
in ("true", "1"),
reranker_local_bucket_batching=os.getenv(
ENV_RERANKER_LOCAL_BUCKET_BATCHING, str(DEFAULT_RERANKER_LOCAL_BUCKET_BATCHING)
@ -1177,6 +1182,8 @@ class HindsightConfig:
),
retain_mission=os.getenv(ENV_RETAIN_MISSION) or DEFAULT_RETAIN_MISSION,
retain_custom_instructions=os.getenv(ENV_RETAIN_CUSTOM_INSTRUCTIONS) or DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS,
retain_default_strategy=os.getenv(ENV_RETAIN_DEFAULT_STRATEGY) or DEFAULT_RETAIN_DEFAULT_STRATEGY,
retain_strategies=DEFAULT_RETAIN_STRATEGIES,
retain_batch_tokens=int(os.getenv(ENV_RETAIN_BATCH_TOKENS, str(DEFAULT_RETAIN_BATCH_TOKENS))),
retain_entity_lookup=os.getenv(ENV_RETAIN_ENTITY_LOOKUP, DEFAULT_RETAIN_ENTITY_LOOKUP),
retain_batch_enabled=os.getenv(ENV_RETAIN_BATCH_ENABLED, str(DEFAULT_RETAIN_BATCH_ENABLED)).lower()

View file

@ -10,7 +10,7 @@ multiple API servers.
import json
import logging
from dataclasses import asdict
from dataclasses import asdict, replace
from typing import Any
import asyncpg
@ -239,6 +239,14 @@ class ConfigResolver:
logger.warning(f"Failed to check permissions for bank {bank_id}: {e}")
# Continue without permission check (fail open for backward compatibility)
# Validate retain_strategies: reject empty string keys
if "retain_strategies" in normalized_updates and normalized_updates["retain_strategies"]:
empty_keys = [k for k in normalized_updates["retain_strategies"] if not str(k).strip()]
if empty_keys:
raise ValueError(
"Strategy names must not be empty strings. Remove entries with empty names before saving."
)
# Merge with existing config (JSONB || operator)
async with self.pool.acquire() as conn:
await conn.execute(
@ -273,3 +281,35 @@ class ConfigResolver:
)
logger.info(f"Reset bank config for {bank_id} to defaults")
def apply_strategy(config: HindsightConfig, strategy_name: str) -> HindsightConfig:
"""
Apply a named retain strategy's overrides on top of a resolved config.
A strategy is a named set of hierarchical field overrides stored in
config.retain_strategies. Any field in _HIERARCHICAL_FIELDS can be
overridden, including retain_extraction_mode, retain_chunk_size,
entity_labels, entities_allow_free_form, etc.
Unknown strategy names log a warning and return config unchanged.
Unknown or non-hierarchical fields in the strategy are silently ignored.
"""
strategies = config.retain_strategies or {}
if strategy_name not in strategies:
logger.warning(f"Unknown retain strategy '{strategy_name}', using resolved config as-is")
return config
overrides = strategies[strategy_name]
if not isinstance(overrides, dict):
logger.warning(f"Retain strategy '{strategy_name}' is not a dict, skipping")
return config
configurable = HindsightConfig.get_configurable_fields()
filtered = {k: v for k, v in overrides.items() if k in configurable}
if not filtered:
return config
logger.debug(f"Applying retain strategy '{strategy_name}': {list(filtered.keys())}")
return replace(config, **filtered)

View file

@ -561,6 +561,7 @@ class MemoryEngine(MemoryEngineInterface):
contents = task_dict.get("contents", [])
document_tags = task_dict.get("document_tags")
operation_id = task_dict.get("operation_id") # For batch API crash recovery
strategy = task_dict.get("strategy")
logger.info(
f"[BATCH_RETAIN_TASK] Starting background batch retain for bank_id={bank_id}, {len(contents)} items, operation_id={operation_id}"
@ -584,6 +585,7 @@ class MemoryEngine(MemoryEngineInterface):
document_tags=document_tags,
request_context=context,
operation_id=operation_id,
strategy=strategy,
outbox_callback=self._build_retain_outbox_callback(
bank_id=bank_id,
contents=contents,
@ -712,6 +714,8 @@ class MemoryEngine(MemoryEngineInterface):
retain_task_payload: dict[str, Any] = {"contents": retain_contents}
if document_tags:
retain_task_payload["document_tags"] = document_tags
if task_dict.get("strategy"):
retain_task_payload["strategy"] = task_dict["strategy"]
# Pass tenant/api_key context through to retain task
if task_dict.get("_tenant_id"):
@ -1953,6 +1957,7 @@ class MemoryEngine(MemoryEngineInterface):
return_usage: bool = False,
operation_id: str | None = None,
outbox_callback: "Callable[[asyncpg.Connection], Awaitable[None]] | None" = None,
strategy: str | None = None,
):
"""
Store multiple content items as memory units in ONE batch operation.
@ -2110,6 +2115,7 @@ class MemoryEngine(MemoryEngineInterface):
confidence_score=confidence_score,
document_tags=document_tags,
operation_id=operation_id,
strategy=strategy,
# Outbox callback runs inside the last sub-batch's transaction so the
# webhook delivery row is committed atomically with the final retain data.
outbox_callback=outbox_callback if i == len(sub_batches) else None,
@ -2134,6 +2140,7 @@ class MemoryEngine(MemoryEngineInterface):
confidence_score=confidence_score,
document_tags=document_tags,
operation_id=operation_id,
strategy=strategy,
outbox_callback=outbox_callback,
)
@ -2186,6 +2193,7 @@ class MemoryEngine(MemoryEngineInterface):
document_tags: list[str] | None = None,
operation_id: str | None = None,
outbox_callback: "Callable[[asyncpg.Connection], Awaitable[None]] | None" = None,
strategy: str | None = None,
) -> tuple[list[list[str]], "TokenUsage"]:
"""
Internal method for batch processing without chunking logic.
@ -2218,6 +2226,13 @@ class MemoryEngine(MemoryEngineInterface):
# Resolve bank-specific config for this operation
resolved_config = await self._config_resolver.resolve_full_config(bank_id, request_context)
# Apply strategy overrides: explicit strategy > bank default strategy
from hindsight_api.config_resolver import apply_strategy
effective_strategy = strategy or resolved_config.retain_default_strategy
if effective_strategy:
resolved_config = apply_strategy(resolved_config, effective_strategy)
# Create parent span for retain operation
with create_operation_span("retain", bank_id):
return await orchestrator.retain_batch(
@ -7377,6 +7392,7 @@ class MemoryEngine(MemoryEngineInterface):
*,
request_context: "RequestContext",
document_tags: list[str] | None = None,
strategy: str | None = None,
) -> dict[str, Any]:
"""Submit a batch retain operation to run asynchronously.
@ -7485,6 +7501,8 @@ class MemoryEngine(MemoryEngineInterface):
task_payload: dict[str, Any] = {"contents": sub_batch}
if document_tags:
task_payload["document_tags"] = document_tags
if strategy:
task_payload["strategy"] = strategy
# Pass tenant_id and api_key_id through task payload
if request_context.tenant_id:
task_payload["_tenant_id"] = request_context.tenant_id
@ -7599,6 +7617,8 @@ class MemoryEngine(MemoryEngineInterface):
"document_tags": document_tags or [],
"timestamp": item.get("timestamp"),
}
if item.get("strategy"):
task_payload["strategy"] = item["strategy"]
# Pass tenant_id and api_key_id through task payload
if request_context.tenant_id:

View file

@ -332,6 +332,43 @@ class FactExtractionResponseNoCausal(BaseModel):
facts: list[ExtractedFactNoCausal] = Field(description="List of extracted factual statements")
class VerbatimExtractedFact(BaseModel):
"""
Schema for verbatim extraction mode.
Omits 'what' entirely the original chunk text is used as fact_text in code.
The LLM only extracts metadata: entities, temporal info, location, people.
"""
model_config = ConfigDict(
json_schema_mode="validation",
json_schema_extra={"required": ["when", "where", "who", "fact_type"]},
)
when: str = Field(description="When it happened. 'N/A' if unknown.")
where: str = Field(description="Location if relevant. 'N/A' if none.")
who: str = Field(description="People involved with relationships. 'N/A' if general.")
fact_kind: str = Field(default="conversation", description="'event' or 'conversation'")
occurred_start: str | None = Field(default=None, description="ISO timestamp for events")
occurred_end: str | None = Field(default=None, description="ISO timestamp for event end")
fact_type: Literal["world", "assistant"] = Field(description="'world' or 'assistant'")
entities: list[Entity] | None = Field(default=None, description="People, places, concepts")
@field_validator("entities", mode="before")
@classmethod
def ensure_entities_list(cls, v):
if v is None:
return []
return v
class VerbatimFactExtractionResponse(BaseModel):
"""Response for verbatim extraction mode (one entry per chunk, no fact text)."""
facts: list[VerbatimExtractedFact] = Field(description="List of metadata entries (one per chunk)")
def chunk_text(text: str, max_chars: int) -> list[str]:
"""
Split text into chunks, preserving conversation structure when possible.
@ -552,6 +589,27 @@ CUSTOM_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
examples="", # No examples for custom mode
)
# Verbatim mode: preserve the original text exactly, but still extract metadata
_VERBATIM_GUIDELINES = """══════════════════════════════════════════════════════════════════════════
VERBATIM MODE Extract metadata only
The original text will be stored as-is in code. Your ONLY job is to extract metadata.
RULES:
- Produce EXACTLY ONE entry per input chunk.
- DO NOT include a "what" field it is not part of the output schema.
- Extract all entities (people, places, organizations, objects, concepts).
- Extract temporal information (occurred_start, occurred_end, fact_kind, when).
- Extract location (where) and people (who).
- fact_type: use "world" unless the content is clearly an interaction with the assistant."""
VERBATIM_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
retain_mission_section="{retain_mission_section}",
extraction_guidelines=_VERBATIM_GUIDELINES,
examples="",
)
# Verbose extraction prompt - detailed, comprehensive facts (legacy mode)
VERBOSE_FACT_EXTRACTION_PROMPT = """Extract facts from text into structured format with FIVE required dimensions - BE EXTREMELY DETAILED.
@ -770,6 +828,10 @@ def _build_extraction_prompt_and_schema(config) -> tuple[str, type]:
)
elif extraction_mode == "verbose":
prompt = VERBOSE_FACT_EXTRACTION_PROMPT
elif extraction_mode == "verbatim":
prompt = VERBATIM_FACT_EXTRACTION_PROMPT.format(
retain_mission_section=retain_mission_section,
)
else:
base_prompt = CONCISE_FACT_EXTRACTION_PROMPT
prompt = base_prompt.format(
@ -777,7 +839,11 @@ def _build_extraction_prompt_and_schema(config) -> tuple[str, type]:
)
# Add causal relationships section if enabled
if extract_causal_links:
# Verbatim mode never uses causal relations (no fact text to relate causally)
if extraction_mode == "verbatim":
base_fact_class = VerbatimExtractedFact
base_response_class = VerbatimFactExtractionResponse
elif extract_causal_links:
prompt = prompt + CAUSAL_RELATIONSHIPS_SECTION
base_fact_class = ExtractedFactVerbose if extraction_mode == "verbose" else ExtractedFact
base_response_class = FactExtractionResponseVerbose if extraction_mode == "verbose" else FactExtractionResponse
@ -1012,8 +1078,10 @@ async def _extract_facts_from_chunk(
if not what:
what = get_value("factual_core")
if not what:
logger.warning(f"Skipping fact {i}: missing 'what' field")
continue
# In verbatim mode, 'what' is intentionally absent — text is backfilled from chunk
if extraction_mode != "verbatim":
logger.warning(f"Skipping fact {i}: missing 'what' field")
continue
# Critical field: fact_type
# LLM uses "assistant" but we convert to "experience" for storage
@ -1046,19 +1114,23 @@ async def _extract_facts_from_chunk(
fact_kind = "conversation"
# Build combined fact text from the 4 dimensions: what | when | who | why
# In verbatim mode, leave combined_text empty — _collapse_to_verbatim backfills it
fact_data = {}
combined_parts = [what]
if extraction_mode == "verbatim":
combined_text = ""
else:
combined_parts = [what]
if when:
combined_parts.append(f"When: {when}")
if when:
combined_parts.append(f"When: {when}")
if who:
combined_parts.append(f"Involving: {who}")
if who:
combined_parts.append(f"Involving: {who}")
if why:
combined_parts.append(why)
if why:
combined_parts.append(why)
combined_text = " | ".join(combined_parts)
combined_text = " | ".join(combined_parts)
# Add temporal fields
# For events: occurred_start/occurred_end (when the event happened)
@ -1889,6 +1961,52 @@ async def extract_facts_from_contents_batch_api(
return extracted_facts, chunks_metadata, total_usage
def _extract_facts_chunks(
contents: list[RetainContent],
config,
) -> tuple[list[ExtractedFactType], list[ChunkMetadata], TokenUsage]:
"""
chunks mode: no LLM call, no entity extraction.
Each chunk becomes one memory unit with the raw text as fact_text.
User-provided entities from RetainContent.entities are picked up downstream
by entity_processing.py they are the sole source of entity data in this mode.
"""
extracted_facts: list[ExtractedFactType] = []
chunks_metadata: list[ChunkMetadata] = []
global_chunk_idx = 0
for content_index, content in enumerate(contents):
chunks = chunk_text(content.content, config.retain_chunk_size)
for chunk in chunks:
chunks_metadata.append(
ChunkMetadata(
chunk_text=chunk,
fact_count=1,
content_index=content_index,
chunk_index=global_chunk_idx,
)
)
extracted_facts.append(
ExtractedFactType(
fact_text=chunk,
fact_type="world",
entities=[],
content_index=content_index,
chunk_index=global_chunk_idx,
context=content.context,
mentioned_at=content.event_date,
metadata=content.metadata,
tags=content.tags,
observation_scopes=content.observation_scopes,
)
)
global_chunk_idx += 1
_add_temporal_offsets(extracted_facts, contents)
return extracted_facts, chunks_metadata, TokenUsage()
async def extract_facts_from_contents(
contents: list[RetainContent],
llm_config,
@ -1924,6 +2042,11 @@ async def extract_facts_from_contents(
if not contents:
return [], [], TokenUsage()
# chunks mode: skip LLM entirely, store each chunk as-is
# Must come before the batch-API check so no LLM queue/locks are acquired
if config.retain_extraction_mode == "chunks":
return _extract_facts_chunks(contents, config)
# Route to batch API if enabled
if config.retain_batch_enabled:
return await extract_facts_from_contents_batch_api(
@ -2013,15 +2136,46 @@ async def extract_facts_from_contents(
global_fact_idx += 1
fact_idx_in_content += 1
# Step 4: Add time offsets to preserve ordering within each content
# Step 4: For verbatim mode, collapse to one fact per chunk with original text
if config.retain_extraction_mode == "verbatim":
extracted_facts = _collapse_to_verbatim(extracted_facts, chunks_metadata)
# Step 5: Add time offsets to preserve ordering within each content
_add_temporal_offsets(extracted_facts, contents)
# Step 5: Auto-tag facts from label groups with tag=True
# Step 6: Auto-tag facts from label groups with tag=True
_inject_label_tags(extracted_facts, config)
return extracted_facts, chunks_metadata, total_usage
def _collapse_to_verbatim(facts: list[ExtractedFactType], chunks: list[ChunkMetadata]) -> list[ExtractedFactType]:
"""
For verbatim mode: ensure one fact per chunk with the original chunk text preserved.
The LLM prompt asks for exactly one fact per chunk, but if it returns more,
this collapses them: keeps the first fact as representative, overrides its
fact_text with the raw chunk text, and merges entities from any extra facts.
"""
chunk_text_map = {c.chunk_index: c.chunk_text for c in chunks}
seen: dict[int, ExtractedFactType] = {}
result: list[ExtractedFactType] = []
for fact in facts:
if fact.chunk_index not in seen:
fact.fact_text = chunk_text_map.get(fact.chunk_index, fact.fact_text)
seen[fact.chunk_index] = fact
result.append(fact)
else:
# Merge entities from extra facts into the representative
representative = seen[fact.chunk_index]
for entity in fact.entities:
if entity not in representative.entities:
representative.entities.append(entity)
return result
def _parse_datetime(date_str: str):
"""Parse ISO datetime string."""
from dateutil import parser as date_parser

View file

@ -257,6 +257,8 @@ def main():
retain_extraction_mode=config.retain_extraction_mode,
retain_mission=config.retain_mission,
retain_custom_instructions=config.retain_custom_instructions,
retain_default_strategy=config.retain_default_strategy,
retain_strategies=config.retain_strategies,
retain_batch_tokens=config.retain_batch_tokens,
retain_entity_lookup=config.retain_entity_lookup,
retain_batch_enabled=config.retain_batch_enabled,

View file

@ -89,7 +89,7 @@ async def test_hierarchical_fields_categorization():
assert "entity_labels" in configurable
# Verify count is correct
assert len(configurable) == 17
assert len(configurable) == 19
# Verify credential fields (NEVER exposed)
assert "llm_api_key" in credentials

View file

@ -1,11 +1,13 @@
"""
Test retain function and chunk storage.
"""
import pytest
import logging
from datetime import datetime, timezone, timedelta
from hindsight_api.engine.memory_engine import Budget
from datetime import datetime, timedelta, timezone
import pytest
from hindsight_api import RequestContext
from hindsight_api.engine.memory_engine import Budget
logger = logging.getLogger(__name__)
@ -60,7 +62,7 @@ async def test_retain_with_chunks(memory, request_context):
request_context=request_context,
)
print(f"\n=== Recall Results (with chunks) ===")
print("\n=== Recall Results (with chunks) ===")
print(f"Found {len(result.results)} results")
assert len(result.results) > 0, "Should find facts about Alice"
@ -149,7 +151,7 @@ async def test_chunks_and_entities_follow_fact_order(memory, request_context):
request_context=request_context,
)
print(f"\n=== Recall Results ===")
print("\n=== Recall Results ===")
print(f"Found {len(result.results)} facts")
# Extract the order of entities mentioned in facts
@ -421,7 +423,7 @@ async def test_mentioned_at_vs_occurred(memory, request_context):
# Verify it's the historical date, not today
assert mentioned_dt.year == 2020, f"mentioned_at should be 2020, got {mentioned_dt.year}"
print(f"✓ Test passed: Historical conversation correctly ingested with event_date=2020")
print("✓ Test passed: Historical conversation correctly ingested with event_date=2020")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@ -489,15 +491,15 @@ async def test_occurred_dates_not_defaulted(memory, request_context):
# If occurred_start is set, it means the LLM extracted it
# In this case, log it but don't fail (LLM behavior can vary)
print(f"⚠ LLM extracted occurred_start: {fact.occurred_start}")
print(f" This test expects None for present-tense observations")
print(" This test expects None for present-tense observations")
else:
print(f"✓ occurred_start is correctly None (not defaulted to mentioned_at)")
print("✓ occurred_start is correctly None (not defaulted to mentioned_at)")
if fact.occurred_end is not None:
print(f"⚠ LLM extracted occurred_end: {fact.occurred_end}")
print(f" This test expects None for present-tense observations")
print(" This test expects None for present-tense observations")
else:
print(f"✓ occurred_end is correctly None (not defaulted to mentioned_at)")
print("✓ occurred_end is correctly None (not defaulted to mentioned_at)")
# At least verify they're not equal to mentioned_at if they are set
if fact.occurred_start is not None:
@ -513,7 +515,7 @@ async def test_occurred_dates_not_defaulted(memory, request_context):
f"occurred_start={occurred_start_dt}, mentioned_at={mentioned_dt}"
)
print(f"✓ Test passed: occurred dates are not incorrectly defaulted to mentioned_at")
print("✓ Test passed: occurred dates are not incorrectly defaulted to mentioned_at")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@ -585,7 +587,7 @@ async def test_mentioned_at_from_context_string(memory, request_context):
else:
print(f"⚠ LLM did not extract date from context, fell back to now(): {mentioned_dt}")
print(f"✓ mentioned_at is always set (never None)")
print("✓ mentioned_at is always set (never None)")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@ -851,8 +853,8 @@ async def test_metadata_storage_and_retrieval(memory, request_context):
assert len(result.results) > 0, "Should recall stored facts"
print(f"✓ Successfully stored and retrieved facts")
print(f" (Note: Metadata support depends on API implementation)")
print("✓ Successfully stored and retrieved facts")
print(" (Note: Metadata support depends on API implementation)")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@ -954,7 +956,7 @@ async def test_mixed_content_batch(memory, request_context):
short_units = len(unit_ids[0])
long_units = len(unit_ids[1])
print(f"✓ Mixed batch processed successfully")
print("✓ Mixed batch processed successfully")
print(f" Short content: {short_units} units")
print(f" Long content: {long_units} units")
@ -1356,7 +1358,7 @@ async def test_chunks_truncation_behavior(memory, request_context):
if truncated_chunks:
print(f" {len(truncated_chunks)} chunks were truncated due to token limit")
else:
print(f" No chunks were truncated (content within limit)")
print(" No chunks were truncated (content within limit)")
else:
print("✓ No chunks returned (may be under token limit)")
@ -2210,9 +2212,10 @@ async def test_custom_extraction_mode():
custom guidelines while keeping structural parts intact.
"""
import os
from hindsight_api import LLMConfig
from hindsight_api.config import _get_raw_config, clear_config_cache
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
from hindsight_api.config import clear_config_cache, _get_raw_config
# Save original env vars
original_mode = os.getenv("HINDSIGHT_API_RETAIN_EXTRACTION_MODE")
@ -2284,7 +2287,7 @@ If the text contains both Italian and English content, extract ONLY the Italian
if found_english_only:
logger.warning(f"⚠ Found English-only keywords in facts: {found_english_only}")
logger.warning(f" Facts: {all_facts_text}")
logger.warning(f" This may indicate the LLM is not strictly following language-specific custom guidelines")
logger.warning(" This may indicate the LLM is not strictly following language-specific custom guidelines")
# Log but don't fail - LLM behavior can vary
else:
logger.info("✓ Successfully extracted only Italian facts, ignored English facts")
@ -2315,6 +2318,213 @@ If the text contains both Italian and English content, extract ONLY the Italian
clear_config_cache()
def test_apply_strategy():
"""
Unit test for apply_strategy:
- Known strategy applies overrides on top of resolved config
- Unknown strategy returns config unchanged with a warning
- Non-hierarchical fields in a strategy are silently ignored
- entity_labels and entities_allow_free_form are overridable
"""
from hindsight_api.config import _get_raw_config, clear_config_cache
from hindsight_api.config_resolver import apply_strategy
clear_config_cache()
base_config = _get_raw_config()
strategies = {
"documents": {
"retain_extraction_mode": "chunks",
"retain_chunk_size": 800,
"entities_allow_free_form": False,
},
"bad_field": {
"database_url": "should-be-ignored", # static field, not hierarchical
"retain_extraction_mode": "verbose",
},
}
config_with_strategies = base_config.__class__(
**{**base_config.__dict__, "retain_strategies": strategies}
)
# Known strategy: overrides applied
result = apply_strategy(config_with_strategies, "documents")
assert result.retain_extraction_mode == "chunks"
assert result.retain_chunk_size == 800
assert result.entities_allow_free_form is False
# Non-hierarchical field silently ignored, hierarchical one applied
result2 = apply_strategy(config_with_strategies, "bad_field")
assert result2.retain_extraction_mode == "verbose"
assert result2.database_url == base_config.database_url # unchanged
# Unknown strategy: config returned unchanged
result3 = apply_strategy(config_with_strategies, "nonexistent")
assert result3.retain_extraction_mode == base_config.retain_extraction_mode
def test_collapse_to_verbatim_single_fact_per_chunk():
"""
Unit test for _collapse_to_verbatim:
- One fact per chunk text overridden with original chunk text
- Two facts from same chunk collapsed to one, entities merged
"""
from hindsight_api.engine.retain.fact_extraction import _collapse_to_verbatim
from hindsight_api.engine.retain.types import ChunkMetadata, ExtractedFact
chunks = [
ChunkMetadata(chunk_text="Alice went to Paris.", fact_count=1, content_index=0, chunk_index=0),
ChunkMetadata(chunk_text="Bob fixed the bug yesterday.", fact_count=2, content_index=0, chunk_index=1),
]
facts = [
ExtractedFact(fact_text="LLM paraphrase of Alice in Paris", fact_type="world", entities=["Alice", "Paris"], chunk_index=0, content_index=0),
ExtractedFact(fact_text="LLM first fact about Bob", fact_type="world", entities=["Bob"], chunk_index=1, content_index=0),
ExtractedFact(fact_text="LLM second fact about bug", fact_type="world", entities=["bug"], chunk_index=1, content_index=0),
]
result = _collapse_to_verbatim(facts, chunks)
assert len(result) == 2, "Should produce exactly one fact per chunk"
# Chunk 0: text overridden with original chunk text
assert result[0].fact_text == "Alice went to Paris.", "Text must be the raw chunk text"
assert result[0].entities == ["Alice", "Paris"]
# Chunk 1: collapsed to one fact, entities merged from both LLM facts
assert result[1].fact_text == "Bob fixed the bug yesterday.", "Text must be the raw chunk text"
assert "Bob" in result[1].entities
assert "bug" in result[1].entities
def test_chunks_extraction_mode():
"""
Unit test for chunks mode: no LLM, chunks stored as-is, zero token usage.
"""
import asyncio
import os
from hindsight_api.config import _get_raw_config, clear_config_cache
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_contents
from hindsight_api.engine.retain.types import RetainContent
original_mode = os.getenv("HINDSIGHT_API_RETAIN_EXTRACTION_MODE")
try:
os.environ["HINDSIGHT_API_RETAIN_EXTRACTION_MODE"] = "chunks"
clear_config_cache()
contents = [
RetainContent(
content="Alice joined the infrastructure team on March 5, 2024.",
event_date=datetime(2024, 3, 10, tzinfo=timezone.utc),
entities=[{"text": "Alice"}, {"text": "infrastructure team"}],
),
RetainContent(content="Bob fixed the critical bug in the payment service."),
]
facts, chunks, usage = asyncio.get_event_loop().run_until_complete(
extract_facts_from_contents(
contents=contents,
llm_config=None, # Must not be called
agent_name="TestAgent",
config=_get_raw_config(),
)
)
# One fact per chunk (both contents fit in one chunk each)
assert len(facts) == len(chunks) == 2
# Text preserved exactly
assert facts[0].fact_text == contents[0].content
assert facts[1].fact_text == contents[1].content
# No LLM-extracted entities (user-provided entities handled downstream)
assert facts[0].entities == []
assert facts[1].entities == []
# Zero token usage
assert usage.total_tokens == 0
logger.info("✓ chunks mode: no LLM call, chunks stored as-is, zero token usage")
finally:
if original_mode is not None:
os.environ["HINDSIGHT_API_RETAIN_EXTRACTION_MODE"] = original_mode
else:
os.environ.pop("HINDSIGHT_API_RETAIN_EXTRACTION_MODE", None)
clear_config_cache()
@pytest.mark.asyncio
async def test_verbatim_extraction_mode():
"""
Integration test for verbatim extraction mode.
Verifies that:
1. Each chunk produces exactly one fact
2. The fact text is the original chunk text, not a paraphrase
3. Entities are still extracted by the LLM
4. Temporal info (occurred_start) is still extracted
"""
import os
from hindsight_api import LLMConfig
from hindsight_api.config import _get_raw_config, clear_config_cache
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_contents
from hindsight_api.engine.retain.types import RetainContent
original_mode = os.getenv("HINDSIGHT_API_RETAIN_EXTRACTION_MODE")
try:
os.environ["HINDSIGHT_API_RETAIN_EXTRACTION_MODE"] = "verbatim"
clear_config_cache()
text = (
"Alice joined the infrastructure team on March 5, 2024. "
"She holds a CKA certification and has 5 years of Kubernetes experience."
)
llm_config = LLMConfig.for_memory()
contents = [RetainContent(content=text, event_date=datetime(2024, 3, 10, tzinfo=timezone.utc), context="onboarding notes")]
facts, chunks, _ = await extract_facts_from_contents(
contents=contents,
llm_config=llm_config,
agent_name="TestAgent",
config=_get_raw_config(),
)
logger.info(f"Verbatim mode extracted {len(facts)} facts from {len(chunks)} chunks")
for i, f in enumerate(facts):
logger.info(f" fact[{i}]: {f.fact_text!r} entities={f.entities}")
# One fact per chunk
assert len(facts) == len(chunks), "Verbatim mode must produce exactly one fact per chunk"
# Text must match the original chunk exactly
for fact, chunk in zip(facts, chunks):
assert fact.fact_text == chunk.chunk_text, (
f"fact_text must equal original chunk text.\n"
f" expected: {chunk.chunk_text!r}\n"
f" got: {fact.fact_text!r}"
)
# Entities should still be extracted
all_entities = [e for f in facts for e in f.entities]
assert any("alice" in e.lower() for e in all_entities), (
f"Expected entity 'Alice' to be extracted. Entities: {all_entities}"
)
logger.info("✓ Verbatim mode preserves chunk text and still extracts entities")
finally:
if original_mode is not None:
os.environ["HINDSIGHT_API_RETAIN_EXTRACTION_MODE"] = original_mode
else:
os.environ.pop("HINDSIGHT_API_RETAIN_EXTRACTION_MODE", None)
clear_config_cache()
@pytest.mark.asyncio
async def test_retain_batch_with_per_item_tags_on_document(memory, request_context):
"""
@ -2349,7 +2559,7 @@ async def test_retain_batch_with_per_item_tags_on_document(memory, request_conte
)
assert len(result) > 0, "Should have retained content"
print(f"\n=== Retained content with tags ===")
print("\n=== Retained content with tags ===")
# Retrieve the document
doc = await memory.get_document(
@ -2380,6 +2590,7 @@ async def test_retain_batch_with_per_item_tags_on_document(memory, request_conte
def test_retain_mission_injected_into_prompt():
"""Test that retain_mission is injected as a FOCUS section into any extraction mode."""
from unittest.mock import MagicMock
from hindsight_api.engine.retain.fact_extraction import _build_extraction_prompt_and_schema
spec = "Focus on technical decisions and architecture choices only."
@ -2405,6 +2616,7 @@ def test_retain_mission_injected_into_prompt():
def test_retain_mission_absent_when_not_set():
"""Test that no FOCUS section appears when retain_mission is not set."""
from unittest.mock import MagicMock
from hindsight_api.engine.retain.fact_extraction import _build_extraction_prompt_and_schema
config = MagicMock()
@ -2421,6 +2633,7 @@ def test_retain_mission_absent_when_not_set():
def test_retain_mission_config_loaded_from_env():
"""Test that retain_mission is loaded from env and is a configurable field."""
import os
from hindsight_api.config import HindsightConfig, _get_raw_config, clear_config_cache
original = os.getenv("HINDSIGHT_API_RETAIN_MISSION")
@ -2436,3 +2649,141 @@ def test_retain_mission_config_loaded_from_env():
else:
os.environ["HINDSIGHT_API_RETAIN_MISSION"] = original
clear_config_cache()
def test_strategy_overrides_extraction_mode_for_chunks():
"""
Unit test: a named strategy with retain_extraction_mode=chunks causes
extract_facts_from_contents to skip the LLM and return verbatim chunks.
"""
import asyncio
from hindsight_api.config import _get_raw_config, clear_config_cache
from hindsight_api.config_resolver import apply_strategy
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_contents
from hindsight_api.engine.retain.types import RetainContent
clear_config_cache()
base_config = _get_raw_config()
# Build a config that has a strategy overriding to chunks
strategies = {"fast": {"retain_extraction_mode": "chunks"}}
config_with_strategies = base_config.__class__(
**{**base_config.__dict__, "retain_strategies": strategies}
)
strategy_config = apply_strategy(config_with_strategies, "fast")
assert strategy_config.retain_extraction_mode == "chunks"
contents = [
RetainContent(content="Alice deployed the new API on Monday."),
RetainContent(content="Bob reviewed the pull request."),
]
facts, chunks, usage = asyncio.get_event_loop().run_until_complete(
extract_facts_from_contents(
contents=contents,
llm_config=None, # chunks must not call the LLM
agent_name="TestAgent",
config=strategy_config,
)
)
assert len(facts) == 2
assert facts[0].fact_text == contents[0].content
assert facts[1].fact_text == contents[1].content
assert usage.total_tokens == 0
logger.info("✓ strategy with chunks mode: no LLM, verbatim chunks, zero tokens")
def test_retain_request_per_item_strategy_field():
"""
Unit test: MemoryItem accepts a strategy field; items with different strategies
are grouped correctly by per-item strategy.
"""
from hindsight_api.api.http import RetainRequest
request = RetainRequest.model_validate(
{
"items": [
{"content": "Alice joined.", "strategy": "fast"},
{"content": "Bob left.", "strategy": "detailed"},
{"content": "Carol arrived."}, # no strategy — falls back to bank default
],
}
)
assert request.items[0].strategy == "fast"
assert request.items[1].strategy == "detailed"
assert request.items[2].strategy is None
# Simulate grouping logic from api_retain handler
strategy_groups: dict = {}
for item in request.items:
strategy_groups.setdefault(item.strategy, []).append(item.content)
assert set(strategy_groups.keys()) == {"fast", "detailed", None}
assert strategy_groups["fast"] == ["Alice joined."]
assert strategy_groups["detailed"] == ["Bob left."]
assert strategy_groups[None] == ["Carol arrived."]
logger.info("✓ per-item strategy grouping works correctly")
@pytest.mark.asyncio
async def test_named_strategy_applied_end_to_end(memory, request_context):
"""
Integration test: a named strategy stored in bank config is actually applied
during retain_batch_async.
Regression test for the bug where strategy was passed through the HTTP layer
but the extraction mode override was silently ignored, always using the bank
default (e.g. 'concise') instead of the strategy's override (e.g. 'chunks').
"""
from hindsight_api.config_resolver import ConfigResolver
bank_id = f"test_strategy_e2e_{datetime.now(timezone.utc).timestamp()}"
try:
# Seed the bank so the row exists before we write config to it
# (update_bank_config is a plain UPDATE — it silently no-ops on missing rows)
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": "seed"}],
request_context=request_context,
)
# Now configure the bank with a named strategy that overrides to chunks
await memory._config_resolver.update_bank_config(
bank_id,
{
"retain_extraction_mode": "concise", # bank default
"retain_strategies": {
"chunks": {"retain_extraction_mode": "chunks"},
},
},
request_context,
)
contents = [{"content": "Alice deployed the new API on Monday."}]
# Retain using the named strategy
unit_ids_by_content, usage = await memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
strategy="chunks",
request_context=request_context,
return_usage=True,
)
# chunks produces exactly one fact per chunk (verbatim) and calls no LLM
assert usage.total_tokens == 0, f"chunks should use zero LLM tokens, got {usage.total_tokens}"
assert len(unit_ids_by_content) == 1
assert len(unit_ids_by_content[0]) == 1, "chunks should produce exactly one fact per content item"
# Verify the stored fact is the verbatim content
facts = await memory.recall_async(bank_id, "Alice", request_context=request_context)
assert any("Alice" in f.text for f in facts.results), "Verbatim content should be retrievable"
logger.info("✓ named strategy 'chunks' with chunks applied end-to-end: no LLM, verbatim storage")
finally:
await memory.delete_bank(bank_id, request_context=request_context)

View file

@ -390,6 +390,7 @@ pub fn retain(
entities: None,
tags: None,
observation_scopes: None,
strategy: None,
};
let request = RetainRequest {

View file

@ -4014,6 +4014,9 @@ components:
type: array
observation_scopes:
$ref: '#/components/schemas/ObservationScopes'
strategy:
nullable: true
type: string
required:
- content
title: MemoryItem
@ -4795,6 +4798,11 @@ components:
operation_id:
nullable: true
type: string
operation_ids:
items:
type: string
nullable: true
type: array
usage:
$ref: '#/components/schemas/TokenUsage'
required:

View file

@ -29,6 +29,7 @@ type MemoryItem struct {
Entities []EntityInput `json:"entities,omitempty"`
Tags []string `json:"tags,omitempty"`
ObservationScopes NullableObservationScopes `json:"observation_scopes,omitempty"`
Strategy NullableString `json:"strategy,omitempty"`
}
type _MemoryItem MemoryItem
@ -342,6 +343,48 @@ func (o *MemoryItem) UnsetObservationScopes() {
o.ObservationScopes.Unset()
}
// GetStrategy returns the Strategy field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *MemoryItem) GetStrategy() string {
if o == nil || IsNil(o.Strategy.Get()) {
var ret string
return ret
}
return *o.Strategy.Get()
}
// GetStrategyOk returns a tuple with the Strategy 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) GetStrategyOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.Strategy.Get(), o.Strategy.IsSet()
}
// HasStrategy returns a boolean if a field has been set.
func (o *MemoryItem) HasStrategy() bool {
if o != nil && o.Strategy.IsSet() {
return true
}
return false
}
// SetStrategy gets a reference to the given NullableString and assigns it to the Strategy field.
func (o *MemoryItem) SetStrategy(v string) {
o.Strategy.Set(&v)
}
// SetStrategyNil sets the value for Strategy to be an explicit nil
func (o *MemoryItem) SetStrategyNil() {
o.Strategy.Set(nil)
}
// UnsetStrategy ensures that no value is present for Strategy, not even an explicit nil
func (o *MemoryItem) UnsetStrategy() {
o.Strategy.Unset()
}
func (o MemoryItem) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
@ -374,6 +417,9 @@ func (o MemoryItem) ToMap() (map[string]interface{}, error) {
if o.ObservationScopes.IsSet() {
toSerialize["observation_scopes"] = o.ObservationScopes.Get()
}
if o.Strategy.IsSet() {
toSerialize["strategy"] = o.Strategy.Get()
}
return toSerialize, nil
}

View file

@ -27,6 +27,7 @@ type RetainResponse struct {
// Whether the operation was processed asynchronously
Async bool `json:"async"`
OperationId NullableString `json:"operation_id,omitempty"`
OperationIds []string `json:"operation_ids,omitempty"`
Usage NullableTokenUsage `json:"usage,omitempty"`
}
@ -191,6 +192,39 @@ func (o *RetainResponse) UnsetOperationId() {
o.OperationId.Unset()
}
// GetOperationIds returns the OperationIds field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *RetainResponse) GetOperationIds() []string {
if o == nil {
var ret []string
return ret
}
return o.OperationIds
}
// GetOperationIdsOk returns a tuple with the OperationIds 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 *RetainResponse) GetOperationIdsOk() ([]string, bool) {
if o == nil || IsNil(o.OperationIds) {
return nil, false
}
return o.OperationIds, true
}
// HasOperationIds returns a boolean if a field has been set.
func (o *RetainResponse) HasOperationIds() bool {
if o != nil && !IsNil(o.OperationIds) {
return true
}
return false
}
// SetOperationIds gets a reference to the given []string and assigns it to the OperationIds field.
func (o *RetainResponse) SetOperationIds(v []string) {
o.OperationIds = v
}
// GetUsage returns the Usage field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *RetainResponse) GetUsage() TokenUsage {
if o == nil || IsNil(o.Usage.Get()) {
@ -250,6 +284,9 @@ func (o RetainResponse) ToMap() (map[string]interface{}, error) {
if o.OperationId.IsSet() {
toSerialize["operation_id"] = o.OperationId.Get()
}
if o.OperationIds != nil {
toSerialize["operation_ids"] = o.OperationIds
}
if o.Usage.IsSet() {
toSerialize["usage"] = o.Usage.Get()
}

View file

@ -37,7 +37,8 @@ class MemoryItem(BaseModel):
entities: Optional[List[EntityInput]] = None
tags: Optional[List[StrictStr]] = None
observation_scopes: Optional[ObservationScopes] = None
__properties: ClassVar[List[str]] = ["content", "timestamp", "context", "metadata", "document_id", "entities", "tags", "observation_scopes"]
strategy: Optional[StrictStr] = None
__properties: ClassVar[List[str]] = ["content", "timestamp", "context", "metadata", "document_id", "entities", "tags", "observation_scopes", "strategy"]
model_config = ConfigDict(
populate_by_name=True,
@ -126,6 +127,11 @@ class MemoryItem(BaseModel):
if self.observation_scopes is None and "observation_scopes" in self.model_fields_set:
_dict['observation_scopes'] = None
# set to None if strategy (nullable) is None
# and model_fields_set contains the field
if self.strategy is None and "strategy" in self.model_fields_set:
_dict['strategy'] = None
return _dict
@classmethod
@ -145,7 +151,8 @@ class MemoryItem(BaseModel):
"document_id": obj.get("document_id"),
"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
"observation_scopes": ObservationScopes.from_dict(obj["observation_scopes"]) if obj.get("observation_scopes") is not None else None,
"strategy": obj.get("strategy")
})
return _obj

View file

@ -32,8 +32,9 @@ class RetainResponse(BaseModel):
items_count: StrictInt
var_async: StrictBool = Field(description="Whether the operation was processed asynchronously", alias="async")
operation_id: Optional[StrictStr] = None
operation_ids: Optional[List[StrictStr]] = None
usage: Optional[TokenUsage] = None
__properties: ClassVar[List[str]] = ["success", "bank_id", "items_count", "async", "operation_id", "usage"]
__properties: ClassVar[List[str]] = ["success", "bank_id", "items_count", "async", "operation_id", "operation_ids", "usage"]
model_config = ConfigDict(
populate_by_name=True,
@ -82,6 +83,11 @@ class RetainResponse(BaseModel):
if self.operation_id is None and "operation_id" in self.model_fields_set:
_dict['operation_id'] = None
# set to None if operation_ids (nullable) is None
# and model_fields_set contains the field
if self.operation_ids is None and "operation_ids" in self.model_fields_set:
_dict['operation_ids'] = None
# set to None if usage (nullable) is None
# and model_fields_set contains the field
if self.usage is None and "usage" in self.model_fields_set:
@ -104,6 +110,7 @@ class RetainResponse(BaseModel):
"items_count": obj.get("items_count"),
"async": obj.get("async"),
"operation_id": obj.get("operation_id"),
"operation_ids": obj.get("operation_ids"),
"usage": TokenUsage.from_dict(obj["usage"]) if obj.get("usage") is not None else None
})
return _obj

View file

@ -72,6 +72,7 @@ mod tests {
entities: None,
tags: None,
observation_scopes: None,
strategy: None,
},
types::MemoryItem {
content: "Bob works with Alice on the search team".to_string(),
@ -82,6 +83,7 @@ mod tests {
entities: None,
tags: None,
observation_scopes: None,
strategy: None,
},
],
document_tags: None,

View file

@ -1244,6 +1244,12 @@ export type MemoryItem = {
| "all_combinations"
| Array<Array<string>>
| null;
/**
* Strategy
*
* 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;
};
/**
@ -1958,9 +1964,15 @@ export type RetainResponse = {
/**
* Operation Id
*
* Operation ID for tracking async operations. Use GET /v1/default/banks/{bank_id}/operations to list operations. Only present when async=true.
* Operation ID for tracking async operations. Use GET /v1/default/banks/{bank_id}/operations to list operations. Only present when async=true. When items use different per-item strategies, use operation_ids instead.
*/
operation_id?: string | null;
/**
* Operation Ids
*
* Operation IDs when items were submitted as multiple strategy groups (async=true with mixed per-item strategies). operation_id is set to the first entry for backward compatibility.
*/
operation_ids?: Array<string> | null;
/**
* Token usage metrics for LLM calls during fact extraction (only present for synchronous operations)
*/

View file

@ -82,6 +82,7 @@ export interface MemoryItemInput {
entities?: EntityInput[];
tags?: string[];
observation_scopes?: "per_tag" | "combined" | "all_combinations" | string[][];
strategy?: string;
}
export class HindsightClient {
@ -190,6 +191,7 @@ export class HindsightClient {
entities: item.entities,
tags: item.tags,
observation_scopes: item.observation_scopes,
strategy: item.strategy,
timestamp:
item.timestamp instanceof Date
? item.timestamp.toISOString()

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useMemo, type ReactNode } from "react";
import { useState, useEffect, useRef, useMemo, type ReactNode } from "react";
import { useBank } from "@/lib/bank-context";
import { useFeatures } from "@/lib/features-context";
import { client } from "@/lib/api";
@ -15,6 +15,16 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
@ -35,6 +45,13 @@ type RetainEdits = {
retain_extraction_mode: string | null;
retain_mission: string | null;
retain_custom_instructions: string | null;
entities_allow_free_form: boolean | null;
entity_labels: LabelGroup[] | null;
};
type StrategiesEdits = {
retain_default_strategy: string | null;
retain_strategies: Record<string, Record<string, any>> | null;
};
type ObservationsEdits = {
@ -55,11 +72,6 @@ type LabelGroup = {
values: LabelValue[];
};
type EntityLabelsEdits = {
entity_labels: LabelGroup[] | null;
entities_allow_free_form: boolean;
};
type MCPEdits = {
mcp_enabled_tools: string[] | null;
};
@ -134,12 +146,28 @@ const ALL_TOOLS: string[] = MCP_TOOL_GROUPS.flatMap((g) => g.tools);
// ─── Slice helpers ────────────────────────────────────────────────────────────
function parseEntityLabels(raw: unknown): LabelGroup[] | null {
if (Array.isArray(raw)) return raw as LabelGroup[];
if (raw && typeof raw === "object" && Array.isArray((raw as any).attributes))
return (raw as any).attributes as LabelGroup[];
return null;
}
function retainSlice(config: Record<string, any>): RetainEdits {
return {
retain_chunk_size: config.retain_chunk_size ?? null,
retain_extraction_mode: config.retain_extraction_mode ?? null,
retain_mission: config.retain_mission ?? null,
retain_custom_instructions: config.retain_custom_instructions ?? null,
entities_allow_free_form: config.entities_allow_free_form ?? null,
entity_labels: parseEntityLabels(config.entity_labels),
};
}
function strategiesSlice(config: Record<string, any>): StrategiesEdits {
return {
retain_default_strategy: config.retain_default_strategy ?? null,
retain_strategies: config.retain_strategies ?? null,
};
}
@ -154,20 +182,6 @@ function observationsSlice(config: Record<string, any>): ObservationsEdits {
};
}
function entityLabelsSlice(config: Record<string, any>): EntityLabelsEdits {
const raw = config.entity_labels;
let attrs: LabelGroup[] | null = null;
if (Array.isArray(raw)) {
attrs = raw as LabelGroup[];
} else if (raw && typeof raw === "object" && Array.isArray(raw.attributes)) {
attrs = raw.attributes as LabelGroup[];
}
return {
entity_labels: attrs,
entities_allow_free_form: config.entities_allow_free_form ?? true,
};
}
function mcpSlice(config: Record<string, any>): MCPEdits {
return {
mcp_enabled_tools: config.mcp_enabled_tools ?? null,
@ -201,12 +215,10 @@ export function BankConfigView() {
// Per-section local edits
const [retainEdits, setRetainEdits] = useState<RetainEdits>(retainSlice({}));
const [strategiesEdits, setStrategiesEdits] = useState<StrategiesEdits>(strategiesSlice({}));
const [observationsEdits, setObservationsEdits] = useState<ObservationsEdits>(
observationsSlice({})
);
const [entityLabelsEdits, setEntityLabelsEdits] = useState<EntityLabelsEdits>(
entityLabelsSlice({})
);
const [reflectEdits, setReflectEdits] = useState<ProfileData>(DEFAULT_PROFILE);
const [mcpEdits, setMcpEdits] = useState<MCPEdits>(mcpSlice({}));
const [geminiEdits, setGeminiEdits] = useState<GeminiEdits>(geminiSlice({}));
@ -214,32 +226,26 @@ export function BankConfigView() {
// Per-section saving/error state
const [retainSaving, setRetainSaving] = useState(false);
const [observationsSaving, setObservationsSaving] = useState(false);
const [entityLabelsSaving, setEntityLabelsSaving] = useState(false);
const [reflectSaving, setReflectSaving] = useState(false);
const [mcpSaving, setMcpSaving] = useState(false);
const [geminiSaving, setGeminiSaving] = useState(false);
const [retainError, setRetainError] = useState<string | null>(null);
const [observationsError, setObservationsError] = useState<string | null>(null);
const [entityLabelsError, setEntityLabelsError] = useState<string | null>(null);
const [reflectError, setReflectError] = useState<string | null>(null);
const [mcpError, setMcpError] = useState<string | null>(null);
const [geminiError, setGeminiError] = useState<string | null>(null);
// Reset dialog
// Dirty tracking
const retainDirty = useMemo(
() => JSON.stringify(retainEdits) !== JSON.stringify(retainSlice(baseConfig)),
[retainEdits, baseConfig]
() =>
JSON.stringify(retainEdits) !== JSON.stringify(retainSlice(baseConfig)) ||
JSON.stringify(strategiesEdits) !== JSON.stringify(strategiesSlice(baseConfig)),
[retainEdits, strategiesEdits, baseConfig]
);
const observationsDirty = useMemo(
() => JSON.stringify(observationsEdits) !== JSON.stringify(observationsSlice(baseConfig)),
[observationsEdits, baseConfig]
);
const entityLabelsDirty = useMemo(
() => JSON.stringify(entityLabelsEdits) !== JSON.stringify(entityLabelsSlice(baseConfig)),
[entityLabelsEdits, baseConfig]
);
const reflectDirty = useMemo(
() => JSON.stringify(reflectEdits) !== JSON.stringify(baseProfile),
[reflectEdits, baseProfile]
@ -277,8 +283,8 @@ export function BankConfigView() {
setBaseConfig(cfg);
setBaseProfile(prof);
setRetainEdits(retainSlice(cfg));
setStrategiesEdits(strategiesSlice(cfg));
setObservationsEdits(observationsSlice(cfg));
setEntityLabelsEdits(entityLabelsSlice(cfg));
setReflectEdits(prof);
setMcpEdits(mcpSlice(cfg));
setGeminiEdits(geminiSlice(cfg));
@ -294,8 +300,9 @@ export function BankConfigView() {
setRetainSaving(true);
setRetainError(null);
try {
await client.updateBankConfig(bankId, retainEdits);
setBaseConfig((prev) => ({ ...prev, ...retainEdits }));
const payload = { ...retainEdits, ...strategiesEdits };
await client.updateBankConfig(bankId, payload);
setBaseConfig((prev) => ({ ...prev, ...payload }));
} catch (err: any) {
setRetainError(err.message || "Failed to save retain settings");
} finally {
@ -317,24 +324,6 @@ export function BankConfigView() {
}
};
const saveEntityLabels = async () => {
if (!bankId) return;
setEntityLabelsSaving(true);
setEntityLabelsError(null);
try {
const payload = {
entity_labels: entityLabelsEdits.entity_labels,
entities_allow_free_form: entityLabelsEdits.entities_allow_free_form,
};
await client.updateBankConfig(bankId, payload);
setBaseConfig((prev) => ({ ...prev, ...payload }));
} catch (err: any) {
setEntityLabelsError(err.message || "Failed to save entity labels settings");
} finally {
setEntityLabelsSaving(false);
}
};
const saveReflect = async () => {
if (!bankId) return;
setReflectSaving(true);
@ -416,111 +405,49 @@ export function BankConfigView() {
return (
<>
<div className="space-y-8">
{/* Retain Section */}
{/* Retain + Strategies Section */}
<ConfigSection
title="Retain"
description="Control what gets extracted and stored from content"
description="Default extraction settings and named strategies. Pass a strategy name on retain requests to override defaults per-item."
error={retainError}
dirty={retainDirty}
saving={retainSaving}
onSave={saveRetain}
>
<FieldRow
label="Chunk Size"
description="Size of text chunks for processing (characters)"
>
<Input
type="number"
min={500}
max={8000}
value={retainEdits.retain_chunk_size ?? ""}
onChange={(e) =>
setRetainEdits((prev) => ({
...prev,
retain_chunk_size: e.target.value ? parseFloat(e.target.value) : null,
}))
}
/>
</FieldRow>
<TextareaRow
label="Mission"
description="What this bank should pay attention to during extraction. Steers the LLM without replacing the extraction rules — works alongside any extraction mode."
value={retainEdits.retain_mission ?? ""}
onChange={(v) => setRetainEdits((prev) => ({ ...prev, retain_mission: v || null }))}
placeholder="e.g. Always include technical decisions, API design choices, and architectural trade-offs. Ignore meeting logistics, greetings, and social exchanges."
rows={3}
/>
<FieldRow
label="Extraction Mode"
description="How aggressively to extract facts: concise (default, selective), verbose (capture everything), custom (write your own extraction rules)"
label="Default strategy"
description="Applied automatically when no strategy is specified on a request."
>
<Select
value={retainEdits.retain_extraction_mode ?? ""}
onValueChange={(val) =>
setRetainEdits((prev) => ({ ...prev, retain_extraction_mode: val }))
value={strategiesEdits.retain_default_strategy ?? "__none__"}
onValueChange={(v) =>
setStrategiesEdits((prev) => ({
...prev,
retain_default_strategy: v === "__none__" ? null : v,
}))
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{["concise", "verbose", "custom"].map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
<SelectItem value="__none__">
<span className="text-muted-foreground italic">Default</span>
</SelectItem>
{Object.keys(strategiesEdits.retain_strategies ?? {}).map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</FieldRow>
{retainEdits.retain_extraction_mode === "custom" && (
<TextareaRow
label="Custom Extraction Prompt"
description="Replaces the built-in extraction rules entirely. Only active when Extraction Mode is set to custom."
value={retainEdits.retain_custom_instructions ?? ""}
onChange={(v) =>
setRetainEdits((prev) => ({ ...prev, retain_custom_instructions: v || null }))
}
rows={5}
/>
)}
</ConfigSection>
{/* Entity Labels Section */}
<ConfigSection
title="Entities"
description="Control entity extraction and define a controlled vocabulary of key:value classification labels (e.g. pedagogy:scaffolding, interest:active)"
error={entityLabelsError}
dirty={entityLabelsDirty}
saving={entityLabelsSaving}
onSave={saveEntityLabels}
>
<FieldRow
label="Free Form Entities"
description="Extract regular named entities (people, places, concepts) alongside entity labels. Disable to restrict extraction to entity labels only."
>
<div className="flex justify-end items-center gap-2">
<Label
htmlFor="entities-allow-free-form"
className="text-sm text-muted-foreground cursor-pointer select-none"
>
{entityLabelsEdits.entities_allow_free_form ? "Enabled" : "Disabled"}
</Label>
<Switch
id="entities-allow-free-form"
checked={entityLabelsEdits.entities_allow_free_form}
onCheckedChange={(v) =>
setEntityLabelsEdits((prev) => ({ ...prev, entities_allow_free_form: v }))
}
/>
</div>
</FieldRow>
<EntityLabelsEditor
value={entityLabelsEdits.entity_labels ?? []}
onChange={(attrs) =>
setEntityLabelsEdits((prev) => ({
...prev,
entity_labels: attrs.length > 0 ? attrs : null,
}))
<RetainStrategiesPanel
defaultValues={retainEdits}
onDefaultChange={(patch) => setRetainEdits((prev) => ({ ...prev, ...patch }))}
strategies={strategiesEdits.retain_strategies}
onStrategiesChange={(v) =>
setStrategiesEdits((prev) => ({ ...prev, retain_strategies: v }))
}
/>
</ConfigSection>
@ -755,6 +682,340 @@ export function BankConfigView() {
);
}
// ─── Retain strategies panel ──────────────────────────────────────────────────
type RetainFormValues = {
retain_extraction_mode: string | null;
retain_chunk_size: number | null;
retain_mission: string | null;
retain_custom_instructions: string | null;
entities_allow_free_form: boolean | null;
entity_labels: LabelGroup[] | null;
};
const EXTRACTION_MODES = ["concise", "verbose", "verbatim", "chunks", "custom"];
const INHERIT_SENTINEL = "__inherit__";
function RetainStrategyForm({
values,
onChange,
isOverride = false,
}: {
values: RetainFormValues;
onChange: (patch: Partial<RetainFormValues>) => void;
isOverride?: boolean;
}) {
const modeValue = values.retain_extraction_mode ?? (isOverride ? INHERIT_SENTINEL : "");
const showCustomField = values.retain_extraction_mode === "custom";
return (
<div className="divide-y divide-border/40">
<FieldRow
label="Extraction Mode"
description="How aggressively to extract facts. concise = selective, verbose = capture everything, verbatim = store chunks as-is (still extract entities/time), chunks = no LLM, custom = write your own rules."
>
<Select
value={modeValue}
onValueChange={(val) =>
onChange({ retain_extraction_mode: val === INHERIT_SENTINEL ? null : val || null })
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={isOverride ? "Inherited from default" : undefined} />
</SelectTrigger>
<SelectContent>
{isOverride && (
<SelectItem value={INHERIT_SENTINEL}>
<span className="text-muted-foreground italic">inherited</span>
</SelectItem>
)}
{EXTRACTION_MODES.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
</FieldRow>
<FieldRow label="Chunk Size" description="Size of text chunks for processing (characters)">
<Input
type="number"
min={500}
max={8000}
value={values.retain_chunk_size ?? ""}
onChange={(e) =>
onChange({ retain_chunk_size: e.target.value ? parseFloat(e.target.value) : null })
}
placeholder={isOverride ? "Inherited from default" : undefined}
/>
</FieldRow>
<TextareaRow
label="Mission"
description="What this bank should pay attention to during extraction. Steers the LLM without replacing the extraction rules."
value={values.retain_mission ?? ""}
onChange={(v) => onChange({ retain_mission: v || null })}
placeholder={
isOverride
? "Inherited from default"
: "e.g. Always include technical decisions, API design choices, and architectural trade-offs."
}
rows={3}
/>
{showCustomField && (
<TextareaRow
label="Custom Extraction Prompt"
description="Replaces the built-in extraction rules entirely. Only active when Extraction Mode is set to custom."
value={values.retain_custom_instructions ?? ""}
onChange={(v) => onChange({ retain_custom_instructions: v || null })}
rows={5}
/>
)}
<FieldRow
label="Free Form Entities"
description="Extract regular named entities (people, places, concepts) alongside entity labels. Disable to restrict extraction to entity labels only."
>
<div className="flex justify-end items-center gap-2">
<Label className="text-sm text-muted-foreground cursor-pointer select-none">
{(values.entities_allow_free_form ?? true) ? "Enabled" : "Disabled"}
</Label>
<Switch
checked={values.entities_allow_free_form ?? true}
onCheckedChange={(v) => onChange({ entities_allow_free_form: v })}
/>
</div>
</FieldRow>
<EntityLabelsEditor
value={values.entity_labels ?? []}
onChange={(attrs) => onChange({ entity_labels: attrs.length > 0 ? attrs : null })}
/>
</div>
);
}
type LocalStrategy = { id: number; name: string; values: RetainFormValues };
function fromStrategiesDict(dict: Record<string, Record<string, any>> | null): LocalStrategy[] {
if (!dict) return [];
return Object.entries(dict).map(([name, overrides], i) => ({
id: i,
name,
values: {
retain_extraction_mode: overrides.retain_extraction_mode ?? null,
retain_chunk_size: overrides.retain_chunk_size ?? null,
retain_mission: overrides.retain_mission ?? null,
retain_custom_instructions: overrides.retain_custom_instructions ?? null,
entities_allow_free_form: overrides.entities_allow_free_form ?? null,
entity_labels: parseEntityLabels(overrides.entity_labels),
},
}));
}
function toStrategiesDict(local: LocalStrategy[]): Record<string, Record<string, any>> | null {
const dict: Record<string, Record<string, any>> = {};
for (const s of local) {
if (!s.name.trim()) continue;
const overrides: Record<string, any> = {};
if (s.values.retain_extraction_mode !== null)
overrides.retain_extraction_mode = s.values.retain_extraction_mode;
if (s.values.retain_chunk_size !== null)
overrides.retain_chunk_size = s.values.retain_chunk_size;
if (s.values.retain_mission) overrides.retain_mission = s.values.retain_mission;
if (s.values.retain_custom_instructions)
overrides.retain_custom_instructions = s.values.retain_custom_instructions;
if (s.values.entities_allow_free_form !== null)
overrides.entities_allow_free_form = s.values.entities_allow_free_form;
if (s.values.entity_labels !== null) overrides.entity_labels = s.values.entity_labels;
dict[s.name.trim()] = overrides;
}
return Object.keys(dict).length > 0 ? dict : null;
}
function RetainStrategiesPanel({
defaultValues,
onDefaultChange,
strategies,
onStrategiesChange,
}: {
defaultValues: RetainFormValues;
onDefaultChange: (patch: Partial<RetainFormValues>) => void;
strategies: Record<string, Record<string, any>> | null;
onStrategiesChange: (v: Record<string, Record<string, any>> | null) => void;
}) {
const [local, setLocal] = useState<LocalStrategy[]>(() => fromStrategiesDict(strategies));
const [selectedTab, setSelectedTab] = useState<number | "default">("default");
const [pendingDelete, setPendingDelete] = useState<LocalStrategy | null>(null);
const skipSyncRef = useRef(false);
const strategiesKey = JSON.stringify(strategies);
useEffect(() => {
if (skipSyncRef.current) {
skipSyncRef.current = false;
return;
}
setLocal(fromStrategiesDict(strategies));
}, [strategiesKey]);
const updateLocal = (next: LocalStrategy[]) => {
skipSyncRef.current = true;
setLocal(next);
onStrategiesChange(toStrategiesDict(next));
};
const addStrategy = () => {
const id = Date.now();
const next = [
...local,
{
id,
name: "",
values: {
retain_extraction_mode: null,
retain_chunk_size: null,
retain_mission: null,
retain_custom_instructions: null,
entities_allow_free_form: null,
entity_labels: null,
},
},
];
updateLocal(next);
setSelectedTab(id);
};
const removeStrategy = (id: number) => {
const next = local.filter((s) => s.id !== id);
updateLocal(next);
if (selectedTab === id) setSelectedTab("default");
};
const updateStrategy = (id: number, patch: Partial<LocalStrategy>) => {
updateLocal(local.map((s) => (s.id === id ? { ...s, ...patch } : s)));
};
const activeStrategy = selectedTab !== "default" ? local.find((s) => s.id === selectedTab) : null;
return (
<div>
{/* Tab bar */}
<div className="border-b border-border px-6 flex items-stretch gap-1 flex-wrap">
{/* Default tab */}
<button
type="button"
onClick={() => setSelectedTab("default")}
className={`relative py-3 px-4 text-sm font-semibold transition-colors border-b-2 -mb-px ${
selectedTab === "default"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
}`}
>
Default
</button>
{/* Named strategy tabs */}
{local.map((s) => (
<div
key={s.id}
className={`relative flex items-center gap-2 py-3 px-4 text-sm font-semibold transition-colors border-b-2 -mb-px cursor-pointer ${
selectedTab === s.id
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
}`}
onClick={() => setSelectedTab(s.id)}
>
<span className="font-mono">
{s.name || <span className="italic font-normal opacity-50">unnamed</span>}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setPendingDelete(s);
}}
className="opacity-40 hover:opacity-100 hover:text-destructive transition-opacity text-base leading-none"
>
×
</button>
</div>
))}
<button
type="button"
onClick={addStrategy}
className="py-3 px-3 text-sm text-muted-foreground hover:text-primary transition-colors flex items-center gap-1.5"
>
<Plus className="h-3.5 w-3.5" />
Add strategy
</button>
</div>
{/* Form */}
<div>
{selectedTab === "default" ? (
<RetainStrategyForm values={defaultValues} onChange={onDefaultChange} />
) : activeStrategy ? (
<div>
<div className="px-6 py-3 flex items-center gap-3 border-b border-border/40">
<label className="text-xs text-muted-foreground shrink-0">Name</label>
<div className="flex flex-col gap-1">
<Input
value={activeStrategy.name}
onChange={(e) => updateStrategy(activeStrategy.id, { name: e.target.value })}
placeholder="strategy name (e.g. fast)"
className={`h-7 text-xs font-mono max-w-[200px] ${!activeStrategy.name.trim() ? "border-destructive focus-visible:ring-destructive" : ""}`}
/>
{!activeStrategy.name.trim() && (
<p className="text-xs text-destructive">Name is required</p>
)}
</div>
</div>
<RetainStrategyForm
values={activeStrategy.values}
onChange={(patch) =>
updateStrategy(activeStrategy.id, {
values: { ...activeStrategy.values, ...patch },
})
}
isOverride
/>
</div>
) : null}
</div>
<AlertDialog
open={pendingDelete !== null}
onOpenChange={(open) => {
if (!open) setPendingDelete(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Delete strategy &ldquo;{pendingDelete?.name || "unnamed"}&rdquo;?
</AlertDialogTitle>
<AlertDialogDescription>
This will remove the strategy and all its overrides. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
if (pendingDelete) {
removeStrategy(pendingDelete.id);
setPendingDelete(null);
}
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
// ─── ToolSelector ─────────────────────────────────────────────────────────────
function ToolSelector({

View file

@ -80,8 +80,22 @@ function BankSelectorInner() {
"document"
);
const [docAsync, setDocAsync] = React.useState(false);
const [docStrategy, setDocStrategy] = React.useState("");
const [isCreatingDoc, setIsCreatingDoc] = React.useState(false);
// Available strategies for the current bank
const [bankStrategies, setBankStrategies] = React.useState<string[]>([]);
React.useEffect(() => {
if (!docDialogOpen || !currentBank) return;
client
.getBankConfig(currentBank)
.then((resp) => {
const strategies = resp.config?.retain_strategies;
setBankStrategies(strategies ? Object.keys(strategies) : []);
})
.catch(() => setBankStrategies([]));
}, [docDialogOpen, currentBank]);
// File upload state
const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]);
const [filesMetadata, setFilesMetadata] = React.useState<
@ -91,6 +105,8 @@ function BankSelectorInner() {
document_id: string;
tags: string;
metadata: string;
strategy: string;
advancedTab: "document" | "tags" | "source";
expanded: boolean;
}[]
>([]);
@ -195,6 +211,8 @@ function BankSelectorInner() {
document_id: "",
tags: "",
metadata: "",
strategy: "",
advancedTab: "document" as "document" | "tags" | "source",
expanded: false,
});
@ -214,7 +232,14 @@ function BankSelectorInner() {
const updateFileMeta = (
index: number,
field: "context" | "timestamp" | "document_id" | "tags" | "metadata",
field:
| "context"
| "timestamp"
| "document_id"
| "tags"
| "metadata"
| "strategy"
| "advancedTab",
value: string
) => {
setFilesMetadata((prev) => prev.map((m, i) => (i === index ? { ...m, [field]: value } : m)));
@ -246,6 +271,7 @@ function BankSelectorInner() {
.filter(Boolean),
}),
...(meta.metadata && { metadata: parseMetadata(meta.metadata) }),
...(meta.strategy && { strategy: meta.strategy }),
}));
await client.uploadFiles({
@ -293,6 +319,7 @@ function BankSelectorInner() {
observation_scopes?: "per_tag" | "combined" | "all_combinations" | string[][];
metadata?: Record<string, string>;
entities?: Array<{ text: string }>;
strategy?: string;
} = { content: docContent };
if (docContext) item.context = docContext;
if (docEventDate) item.timestamp = docEventDate + ":00";
@ -320,6 +347,7 @@ function BankSelectorInner() {
if (parsedMeta) item.metadata = parsedMeta;
const parsedEntities = parseEntities(docEntities);
if (parsedEntities) item.entities = parsedEntities;
if (docStrategy) item.strategy = docStrategy;
await client.retain({
bank_id: currentBank,
@ -340,6 +368,7 @@ function BankSelectorInner() {
setDocEntities("");
setDocAdvancedTab("document");
setDocAsync(false);
setDocStrategy("");
// Navigate to documents view to see the new document
router.push(`/banks/${currentBank}?view=documents`);
@ -647,74 +676,148 @@ function BankSelectorInner() {
{/* Per-file metadata form */}
{meta?.expanded && (
<div className="px-3 pb-3 space-y-2 border-t border-border/50">
<div className="mt-2">
<label className="font-bold block mb-1 text-sm text-foreground">
Context
</label>
<Input
value={meta.context}
onChange={(e) =>
updateFileMeta(index, "context", e.target.value)
}
placeholder="Optional context..."
className="h-8 text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="font-bold block mb-1 text-sm text-foreground">
Event Date
</label>
<Input
type="datetime-local"
value={meta.timestamp}
onChange={(e) =>
updateFileMeta(index, "timestamp", e.target.value)
}
className="h-8 text-sm text-foreground"
/>
<div className="border-t border-border/50">
<Tabs
value={meta.advancedTab}
onValueChange={(v) => updateFileMeta(index, "advancedTab", v)}
>
<TabsList className="w-full border-b border-border bg-transparent h-8 p-0 gap-0 justify-start rounded-none">
{(["document", "tags", "source"] as const).map((t) => (
<TabsTrigger
key={t}
value={t}
className="rounded-none h-full px-4 text-xs font-medium bg-transparent shadow-none text-muted-foreground hover:text-foreground data-[state=active]:text-foreground data-[state=active]:shadow-none data-[state=active]:bg-transparent data-[state=active]:border-b-2 data-[state=active]:border-primary -mb-px capitalize"
>
{t}
</TabsTrigger>
))}
</TabsList>
<div className="px-3 py-3 space-y-2">
<TabsContent value="document" className="mt-0 space-y-2">
<div className="grid grid-cols-2 gap-2">
<div>
<label className="font-bold block mb-1 text-sm text-foreground">
Event Date
</label>
<Input
type="datetime-local"
value={meta.timestamp}
onChange={(e) =>
updateFileMeta(index, "timestamp", e.target.value)
}
className="h-8 text-sm text-foreground"
/>
</div>
<div>
<label className="font-bold block mb-1 text-sm text-foreground">
Document ID
</label>
<Input
value={meta.document_id}
onChange={(e) =>
updateFileMeta(
index,
"document_id",
e.target.value
)
}
placeholder="Optional ID..."
className="h-8 text-sm"
/>
</div>
</div>
<div>
<label className="font-bold block mb-1 text-sm text-foreground">
Strategy
</label>
{bankStrategies.length > 0 ? (
<Select
value={meta.strategy || "__none__"}
onValueChange={(v) =>
updateFileMeta(
index,
"strategy",
v === "__none__" ? "" : v
)
}
>
<SelectTrigger className="w-full h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
<span className="text-muted-foreground italic">
Default
</span>
</SelectItem>
{bankStrategies.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={meta.strategy}
onChange={(e) =>
updateFileMeta(index, "strategy", e.target.value)
}
placeholder="Strategy name (optional)..."
className="h-8 text-sm"
/>
)}
</div>
</TabsContent>
<TabsContent value="tags" className="mt-0 space-y-2">
<div>
<label className="font-bold block mb-1 text-sm text-foreground">
Tags
</label>
<Input
value={meta.tags}
onChange={(e) =>
updateFileMeta(index, "tags", e.target.value)
}
placeholder="tag1, tag2..."
className="h-8 text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
Comma-separated used to filter memories during
recall/reflect
</p>
</div>
</TabsContent>
<TabsContent value="source" className="mt-0 space-y-2">
<div>
<label className="font-bold block mb-1 text-sm text-foreground">
Context
</label>
<Input
value={meta.context}
onChange={(e) =>
updateFileMeta(index, "context", e.target.value)
}
placeholder="Optional context..."
className="h-8 text-sm"
/>
</div>
<div>
<label className="font-bold block mb-1 text-sm text-foreground">
Metadata
</label>
<Textarea
value={meta.metadata}
onChange={(e) =>
updateFileMeta(index, "metadata", e.target.value)
}
placeholder={"source: slack\nchannel: engineering"}
className="min-h-[52px] resize-y font-mono text-sm"
/>
</div>
</TabsContent>
</div>
<div>
<label className="font-bold block mb-1 text-sm text-foreground">
Document ID
</label>
<Input
value={meta.document_id}
onChange={(e) =>
updateFileMeta(index, "document_id", e.target.value)
}
placeholder="Optional ID..."
className="h-8 text-sm"
/>
</div>
</div>
<div>
<label className="font-bold block mb-1 text-sm text-foreground">
Tags
</label>
<Input
value={meta.tags}
onChange={(e) =>
updateFileMeta(index, "tags", e.target.value)
}
placeholder="tag1, tag2..."
className="h-8 text-sm"
/>
</div>
<div>
<label className="font-bold block mb-1 text-sm text-foreground">
Metadata
</label>
<Textarea
value={meta.metadata}
onChange={(e) =>
updateFileMeta(index, "metadata", e.target.value)
}
placeholder={"source: slack\nchannel: engineering"}
className="min-h-[52px] resize-y font-mono text-sm"
/>
</div>
</Tabs>
</div>
)}
</div>
@ -798,6 +901,41 @@ function BankSelectorInner() {
/>
</div>
</div>
<div>
<label className="font-bold block mb-1 text-sm text-foreground">
Strategy
</label>
{bankStrategies.length > 0 ? (
<Select
value={docStrategy || "__none__"}
onValueChange={(v) => setDocStrategy(v === "__none__" ? "" : v)}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
<span className="text-muted-foreground italic">Default</span>
</SelectItem>
{bankStrategies.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
type="text"
value={docStrategy}
onChange={(e) => setDocStrategy(e.target.value)}
placeholder="Strategy name (optional)..."
/>
)}
<p className="text-xs text-muted-foreground mt-1">
Override the bank&apos;s default extraction strategy for this document.
</p>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="async-doc"

View file

@ -204,6 +204,7 @@ export class ControlPlaneClient {
entities?: Array<{ text: string; type?: string }>;
tags?: string[];
observation_scopes?: "per_tag" | "combined" | "all_combinations" | string[][];
strategy?: string;
}>;
document_id?: string;
async?: boolean;
@ -886,6 +887,7 @@ export class ControlPlaneClient {
metadata?: Record<string, any>;
tags?: string[];
timestamp?: string;
strategy?: string;
}>;
}) {
const formData = new FormData();

View file

@ -567,7 +567,7 @@ Controls the retain (memory ingestion) pipeline.
|----------|-------------|---------|
| `HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS` | Max completion tokens for fact extraction LLM calls | `64000` |
| `HINDSIGHT_API_RETAIN_CHUNK_SIZE` | Max characters per chunk for fact extraction. Larger chunks extract fewer LLM calls but may lose context. | `3000` |
| `HINDSIGHT_API_RETAIN_EXTRACTION_MODE` | Fact extraction mode: `concise`, `verbose`, or `custom` | `concise` |
| `HINDSIGHT_API_RETAIN_EXTRACTION_MODE` | Fact extraction mode: `concise`, `verbose`, `verbatim`, `chunks`, or `custom` | `concise` |
| `HINDSIGHT_API_RETAIN_MISSION` | What this bank should pay attention to during extraction. Steers the LLM without replacing the extraction rules — works alongside any extraction mode. | - |
| `HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS` | Full prompt override for fact extraction (only used when mode is `custom`). Replaces built-in extraction rules entirely. | - |
| `HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS` | Extract causal relationships between facts | `true` |
@ -578,17 +578,19 @@ Controls the retain (memory ingestion) pipeline.
#### Customizing retain: when to use what
There are three levels of customization for the retain pipeline. Start with the simplest that covers your needs:
There are five levels of customization for the retain pipeline. Start with the simplest that covers your needs:
| Goal | Use |
|------|-----|
| Steer what topics to focus on or deprioritize | `HINDSIGHT_API_RETAIN_MISSION` |
| Extract more detail per fact | `HINDSIGHT_API_RETAIN_EXTRACTION_MODE=verbose` |
| Store chunks as-is, LLM extracts metadata | `HINDSIGHT_API_RETAIN_EXTRACTION_MODE=verbatim` |
| Store chunks as-is, zero LLM cost | `HINDSIGHT_API_RETAIN_EXTRACTION_MODE=chunks` |
| Completely replace the extraction rules | `HINDSIGHT_API_RETAIN_EXTRACTION_MODE=custom` + `HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS` |
**`HINDSIGHT_API_RETAIN_MISSION` — steer extraction without replacing it (recommended starting point)**
Tell the bank what to pay attention to during extraction, in plain language. The mission is injected into the extraction prompt alongside the built-in rules — it narrows focus without replacing the underlying logic. Works with any extraction mode (`concise`, `verbose`, `custom`).
Tell the bank what to pay attention to during extraction, in plain language. The mission is injected into the extraction prompt alongside the built-in rules — it narrows focus without replacing the underlying logic. Works with any extraction mode (`concise`, `verbose`, `verbatim`, `custom`). Ignored in `chunks` mode.
```bash
export HINDSIGHT_API_RETAIN_MISSION="Focus on technical decisions, architecture choices, and team member expertise. Deprioritize social or personal information."
@ -598,6 +600,62 @@ export HINDSIGHT_API_RETAIN_MISSION="Focus on technical decisions, architecture
Use when you need richer facts with full context, relationships, and verbosity. Slower and uses more tokens than `concise`.
**`HINDSIGHT_API_RETAIN_EXTRACTION_MODE=verbatim` — store chunks as-is**
Each chunk is stored as a single memory unit with its original text preserved exactly — no summarization or rewriting. The LLM still runs to extract entities, temporal information, and location so the chunk is fully indexed and retrievable. Useful for RAG-style indexing, document ingestion pipelines, or benchmarks where you want the original text in memory rather than LLM-generated summaries.
```bash
export HINDSIGHT_API_RETAIN_EXTRACTION_MODE=verbatim
```
**`retain_strategies` / `retain_default_strategy` — per-call extraction strategy**
Named strategies let you ingest different content types into the same bank using different extraction settings. A strategy is a set of hierarchical field overrides applied on top of the resolved bank config.
Any field in the hierarchical config can be overridden per strategy, including `retain_extraction_mode`, `retain_chunk_size`, `entity_labels`, `entities_allow_free_form`, `retain_mission`, etc.
Configure strategies via the bank config API:
```json
{
"retain_default_strategy": "conversations",
"retain_strategies": {
"conversations": {
"retain_extraction_mode": "concise",
"retain_chunk_size": 3000
},
"documents": {
"retain_extraction_mode": "chunks",
"retain_chunk_size": 800,
"entity_labels": null,
"entities_allow_free_form": false
}
}
}
```
Then specify the strategy at retain time:
```python
# Uses default strategy ("conversations")
client.retain(bank_id, items=[{"content": "Alice joined the team today"}])
# Explicitly use document strategy
client.retain(bank_id, items=[{"content": "...document text..."}], strategy="documents")
```
If no `strategy` is specified in a retain call, `retain_default_strategy` is used. If neither is set, the bank/global config applies directly.
> **Note on chunk size and retrieval fairness**: When mixing strategies with very different chunk sizes in the same bank, `chunks` and `verbatim` memories participate only in semantic retrieval (not entity graph or temporal paths). Smaller chunk sizes (e.g., 800 chars) produce more targeted embeddings and are recommended for document strategies to keep scores comparable with LLM-extracted facts.
**`HINDSIGHT_API_RETAIN_EXTRACTION_MODE=chunks` — zero LLM cost**
Each chunk is stored as-is with no LLM call whatsoever. No entity extraction, no temporal indexing — only embeddings are generated for semantic search. User-provided entities passed via `RetainContent.entities` are the sole source of entity data. Use when ingestion speed and cost matter more than structured metadata.
```bash
export HINDSIGHT_API_RETAIN_EXTRACTION_MODE=chunks
```
**`HINDSIGHT_API_RETAIN_EXTRACTION_MODE=custom` + `HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS` — full control**
Replaces the built-in selectivity rules entirely. The structural parts of the prompt (output format, temporal handling, coreference resolution) remain intact — only the extraction guidelines are replaced.

View file

@ -6085,6 +6085,18 @@
],
"title": "ObservationScopes",
"description": "How to scope observations during consolidation. 'per_tag' runs one consolidation pass per individual tag, creating separate observations for each tag. 'combined' (default) runs a single pass with all tags together. A list of tag lists runs one pass per inner list, giving full control over which combinations to use."
},
"strategy": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"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'."
}
},
"type": "object",
@ -7520,7 +7532,22 @@
}
],
"title": "Operation Id",
"description": "Operation ID for tracking async operations. Use GET /v1/default/banks/{bank_id}/operations to list operations. Only present when async=true."
"description": "Operation ID for tracking async operations. Use GET /v1/default/banks/{bank_id}/operations to list operations. Only present when async=true. When items use different per-item strategies, use operation_ids instead."
},
"operation_ids": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Operation Ids",
"description": "Operation IDs when items were submitted as multiple strategy groups (async=true with mixed per-item strategies). operation_id is set to the first entry for backward compatibility."
},
"usage": {
"anyOf": [