All skills
vectorize-io avatar

/hindsight-docs

@5bfef3c
by vectorize-iovectorize-io/hindsight44k stars
5,845

Complete Hindsight documentation for AI agents. Use this to learn about Hindsight architecture, APIs, configuration, and best practices.

Use this Skill: https://skilld.dev/gh/vectorize-io/hindsight/hindsight-docs

This session only. Nothing lands on disk.

referencesdeveloperapimental-models.md

≈15k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Mental Models

User-curated summaries that provide high-quality, pre-computed answers for common queries.

See Mental Models for the concepts behind this API.

{/* Import raw source files */}

What Are Mental Models?

Mental models are saved reflect responses that you curate for your memory bank. When you create a mental model, Hindsight runs a reflect operation with your source query and stores the result. During future reflect calls, these pre-computed summaries are checked first — providing faster, more consistent answers.

Figure: Mental Models (API). An animated diagram on the docs site; its narration, step by step:

  • create()
    1. You create a mental model: a name, the question it answers, and when it should refresh.
    2. The API saves it right away. Its content does not exist yet.
    3. The content is written in the background. The response is an operation id you can poll.
    4. A refresh is a reflect run on the source query. It reads observations first…
    5. …then raw facts, to check the details.
    6. It writes the document and stores it with the memories it is based on.
  • auto refresh
    1. Later, your agent retains a new fact. Consolidation picks it up.
    2. It writes an observation about Carol.
    3. Before queueing a refresh, consolidation checks that the model’s scope holds a memory newer than the last one it read. If not, no refresh runs and no LLM is spent.
    4. In delta mode it reads only the new memories, and answers with small edits instead of a rewrite.
    5. Lines no edit touches are copied byte for byte. Only Carol’s line is added.
  • reflect()
    1. Now your agent asks a question.
    2. Reflect searches mental models first, by meaning. Team overview matches, and it is up to date.
    3. It covers the question, so the agent can answer from it. If it were stale or off topic, the agent would go on to observations and raw facts.
    4. The answer comes back, citing the mental model it used.

Why Use Mental Models?

Benefit Description
Consistency Same answer every time for common questions
Speed Pre-computed responses are returned instantly
Quality Manually curated summaries you've reviewed
Control Define exactly how key topics should be answered

Hierarchical Retrieval

During reflect, the agent checks sources in priority order:

  1. Mental Models — User-curated summaries (highest priority)
  2. Observations — Consolidated knowledge
  3. Raw Facts — Ground truth memories

Mental models are checked first because they represent your explicitly curated knowledge.


Create a Mental Model

Creating a mental model runs a reflect operation in the background and saves the result:

Python

# Create a mental model (runs reflect in background)
result = client.create_mental_model(
    bank_id=BANK_ID,
    name="Team Communication Preferences",
    source_query="How does the team prefer to communicate?",
    tags=["team", "communication"]
)

# Returns an operation_id - check operations endpoint for completion
print(f"Operation ID: {result.operation_id}")

Node.js

// Create a mental model (runs reflect in background)
const result = await client.createMentalModel(
    BANK_ID,
    'Team Communication Preferences',
    'How does the team prefer to communicate?',
    { tags: ['team', 'communication'] },
);

// Returns an operation_id — check operations endpoint for completion
console.log(`Operation ID: ${result.operation_id}`);

CLI

# Create a mental model (runs reflect in background)
hindsight mental-model create "$BANK_ID" \
  "Team Communication Preferences" \
  "How does the team prefer to communicate?"

Go

// Create a mental model (runs reflect in background)
result, _, _ := client.MentalModelsAPI.CreateMentalModel(ctx, mmBankID).
	CreateMentalModelRequest(hindsight.CreateMentalModelRequest{
		Name:        "Team Communication Preferences",
		SourceQuery: "How does the team prefer to communicate?",
		Tags:        []string{"team", "communication"},
	}).Execute()

// Returns an operation_id — check operations endpoint for completion
fmt.Printf("Operation ID: %s\n", result.GetOperationId())

Parameters

Parameter Type Required Description
name string Yes Human-readable name for the mental model
source_query string Yes The query to run to generate content
id string No Custom ID for the mental model (alphanumeric lowercase with hyphens). Auto-generated if omitted.
tags list No Tags that scope the model during reflect and filter source memories during refresh. Defaults to all_strict matching, so only memories carrying every listed tag are read. See Tags and Visibility.
max_tokens int No Maximum tokens for the mental model content
trigger object No Trigger settings (see Automatic Refresh)

Create with Custom ID

Assign a stable, human-readable ID to a mental model so you can retrieve or update it by name instead of relying on the auto-generated UUID:

Python

# Create a mental model with a specific custom ID
result_with_id = client.create_mental_model(
    bank_id=BANK_ID,
    name="Communication Policy",
    source_query="What are the team's communication guidelines?",
    id="communication-policy"
)

print(f"Created with custom ID: {result_with_id.operation_id}")

Node.js

// Create a mental model with a specific custom ID
const resultWithId = await client.createMentalModel(
    BANK_ID,
    'Communication Policy',
    "What are the team's communication guidelines?",
    { id: 'communication-policy' },
);

console.log(`Created with custom ID: ${resultWithId.operation_id}`);

CLI

# Create a mental model with a specific custom ID
hindsight mental-model create "$BANK_ID" \
  "Communication Policy" \
  "What are the team's communication guidelines?" \
  --id communication-policy

Go

// Create a mental model with a specific custom ID
mmID := "communication-policy"
resultWithID, _, _ := client.MentalModelsAPI.CreateMentalModel(ctx, mmBankID).
	CreateMentalModelRequest(hindsight.CreateMentalModelRequest{
		Id:          *hindsight.NewNullableString(&mmID),
		Name:        "Communication Policy",
		SourceQuery: "What are the team's communication guidelines?",
	}).Execute()

fmt.Printf("Created with custom ID: %s\n", resultWithID.GetOperationId())

💡 Tip

Custom IDs must be lowercase alphanumeric and may contain hyphens (e.g. team-policies, q4-status). If a mental model with that ID already exists, the request is rejected.

Automatic Refresh

Mental models can be configured to automatically refresh when observations are updated. This keeps them in sync with the latest knowledge without manual intervention.

Trigger Settings

Setting Type Default Description
mode "full" | "delta" "full" Refresh strategy. See Refresh Mode below.
refresh_after_consolidation bool false Automatically refresh after observations consolidation
refresh_cron string | null null UTC 5-field cron expression for scheduled refreshes, such as "0 3 * * *" for daily at 03:00 UTC
min_refresh_interval_seconds int | null null Minimum seconds between two automatic refreshes of this model. See Rate-limiting automatic refreshes below. null uses the bank/global default.
tags_match string | null null How the model's tags filter source memories during refresh: any, all, any_strict, all_strict, or exact. When null, a tagged model defaults to all_strict (a memory must carry every one of the model's tags). Set "any" to match memories carrying any of the tags — see Tags and Visibility.
tag_groups list | null null Advanced boolean tag expressions that override flat tags/tags_match entirely. See the Recall tags reference.
fact_types list | null null Restrict which fact types the refresh reads: any of world, experience, observation. null means all three. Must not be an empty list.
exclude_mental_models bool false Hide all other mental models from the refresh, so the model never synthesises from sibling models.
exclude_mental_model_ids list | null null Hide specific mental models by ID from the refresh. The model being refreshed is always excluded from itself.
include_chunks bool | null null Override whether the refresh's internal recall returns raw chunk text. null uses the bank/global recall_include_chunks default.
recall_max_tokens int | null null Override the token budget for facts retrieved during refresh. null uses the bank/global default.
recall_chunks_max_tokens int | null null Override the token budget for raw chunks retrieved during refresh. null uses the bank/global default.
reflect_search_observations_max_tokens int | null null Override the token budget for the refresh's search_observations calls. A smaller budget drops the lowest-ranked observations and shrinks the reflect context. null uses the shipped 5000. A refresh does not read the bank's reflect_default_options.
reflect_search_observations_include_entities bool | null null Override whether search_observations attaches resolved entity names, which can be more than half the tool payload. null means enabled. A refresh does not read the bank's reflect_default_options.
budget string | null null How many agent steps a refresh may spend, as a multiple of the server's reflect_max_iterations: low halves it, mid keeps it, high doubles it. null means mid — a refresh writes a whole document, so it does not inherit the low an ad-hoc reflect defaults to.
response_schema object | null null JSON Schema for structured output. When set, each refresh also stores a structured_output alongside the markdown content. See Structured Output below.
keep_trace bool false Record how each refresh reached its result under reflect_response.trace. See Troubleshoot a Refresh.

When refresh_after_consolidation is enabled, the mental model will be re-generated every time the bank's observations are consolidated — ensuring it always reflects the latest synthesized knowledge.

When refresh_cron is set, Hindsight checks the schedule on the server's mental-model refresh tick and refreshes the model only if memories in its scope have changed since the last refresh. refresh_cron and refresh_after_consolidation are mutually exclusive, so a model refreshes either after consolidation or on a fixed UTC schedule, not both.

last_refresh_failed_at on the model (and on a page in the knowledge tree) carries when that happened, so a list view can show which models have stopped refreshing themselves without reading each one's history.

A failed refresh pauses the automatic ones. A failed refresh is retried by the worker (HINDSIGHT_API_WORKER_MAX_RETRIES, 3 by default) and then stops. Neither refresh_after_consolidation nor refresh_cron queues that model again until a refresh succeeds, so a refresh that cannot work (a prompt too large for the model, an empty account, a delta that will not apply) costs a few attempts instead of an LLM bill every tick. The failure shows in the model's history. Fix the cause and refresh the model yourself: a successful refresh resumes the automatic ones. A refresh cut off by HINDSIGHT_API_REFLECT_WALL_TIMEOUT, which bounds a whole refresh the same way it bounds a reflect, counts as a failure too.

Rate-limiting automatic refreshes

A refresh is a full reflect run: retrieval plus an agentic LLM loop. With refresh_after_consolidation on a bank that ingests continuously, every small retain can therefore pay for a rebuild of every model whose scope it touched — and a handful of models on a chatty bank adds up fast.

min_refresh_interval_seconds puts a floor on how often that may happen:

{
  "trigger": {
    "refresh_after_consolidation": true,
    "min_refresh_interval_seconds": 1800
  }
}

A trigger that fires inside the window is not dropped. Its refresh is queued and parked until the window closes, and every further trigger in the meantime folds into that same queued refresh — so a burst of twenty retains costs one refresh, and that refresh still reads everything the twenty retains added. The only thing you trade away is immediacy: the model can lag its memories by up to the interval.

The floor applies to both automatic triggers (refresh_after_consolidation and refresh_cron). It never applies to a refresh you ask for — the refresh endpoint, the MCP tool, and the control plane's refresh button all run immediately, and additionally release a parked refresh if one is waiting.

Set it per bank (or server-wide) with mental_model_min_refresh_interval_seconds / HINDSIGHT_API_MENTAL_MODEL_MIN_REFRESH_INTERVAL_SECONDS; the per-model value wins when both are set, so min_refresh_interval_seconds: 0 exempts one model that does need to stay current from a bank-wide floor. The default everywhere is 0 — no floor.

While a refresh is parked, its operation stays pending with next_retry_at set to the moment the window closes, so GET /operations shows exactly what it is waiting for.

Structured Output

A mental model's content is always markdown. Set trigger.response_schema to also attach a machine-readable view: a JSON Schema object with a non-empty properties map (nested objects and arrays are supported). Each refresh then stores a structured_output on the model's reflect_response, next to the markdown — you get both the prose and a typed object.

{
  "trigger": {
    "response_schema": {
      "type": "object",
      "properties": {
        "risk_level": { "type": "string" },
        "open_questions": { "type": "array", "items": { "type": "string" } }
      },
      "required": ["risk_level"]
    }
  }
}

Key behaviours:

  • Extracted from the final document. The structured output is parsed from the content that is actually stored — so in delta mode it reflects the whole merged document, not just the facts that changed in that refresh.
  • Fails loudly. If a response_schema is configured but the structured extraction cannot be produced, the refresh fails rather than silently persisting content with no structured view. The model's previous content and structured_output are preserved, and the refresh can be retried.
  • Invalid schemas are rejected at request time: the schema must be an object with at least one property, and each property's type must be one of string, number, integer, boolean, array, or object.

See Reflect → response_schema for how the same schema works on the reflect endpoint. The structured_output value appears on the model's reflect_response (see Response Fields).

Staleness Gating

Both automatic triggers run the same check before spending an LLM call: is there a memory in this model's resolved scope newer than its last refresh? The model's tags/tags_match, tag_groups, and fact_types all apply to that check, so activity elsewhere in the bank does not trigger a rebuild, and a cron tick over an unchanged scope is skipped entirely. Memories still waiting to be consolidated count — they are already stored, so a model whose scope reaches them is considered stale.

Untagged writes never make a tagged model stale. Under tags_match: "any" or "all" a refresh also reads untagged memories, but staleness only counts writes that carry the model's tags. Otherwise, on a bank that mixes tagged and untagged writes, every such model would read stale after nearly every write (#4857). The same goes for tag_groups leaves using those modes; a group that selects untagged memories explicitly (an exact leaf with no tags, or a not) still counts them. A model with no tags has the whole bank as its scope, so any write marks it stale.

Every is_stale you can read is computed by that same rule: the flag on a single mental-model read, the one on the list, the one surfaced to the reflect agent, and the per-page flag on the knowledge-base tree. A model flagged stale is one a refresh would actually rewrite.

Listing does not cost one query per model — the whole page is answered together — so you do not have to approximate. If you are already holding last_memory_write_at from the bank stats endpoint, a model whose last_memory_seen_at is at or after it is provably up to date without asking at all: nothing in the bank changed, so nothing in that model's scope did.

Deletions are invisible to it. Staleness asks whether anything in scope has been written since the model last read the memories. Deleting an in-scope memory leaves no write behind, so it does not raise the flag, and a document that cites the deleted fact keeps reporting itself up to date until something in its scope is written or you refresh it yourself.

Two timestamps answer two different questions. last_refreshed_at is wall-clock: when a refresh last finished. It advances on every refresh that completes — including one that read the scope, found nothing new, and left the document alone — so a client driving refreshes itself can use it to tell "I already did this one" from "this one still needs a call". last_memory_seen_at is a position in the data: the newest in-scope memory the last refresh saw. It stands still while the model's scope is quiet, however many times you refresh, which is what makes it the right side of the staleness comparison. A model with a recent last_refreshed_at and an old last_memory_seen_at is not a contradiction — it means you refreshed a document that had nothing new to say.

What a Refresh Reads

  • A point-in-time snapshot. Each refresh is bounded by a database-time cutoff and only reads memories committed at or before it, so a write landing mid-refresh is never half-included. The watermark recorded afterwards is the newest in-scope memory the refresh actually saw — not now() — so a write that commits just after the snapshot stays "newer" and is picked up on the next round instead of being skipped.
  • Only what's new, in delta mode. A delta refresh scopes its retrieval to memories created since the last refresh, so the LLM reads genuinely new information rather than re-reading the whole bank.
  • Cumulative grounding. Even in delta mode, reflect_response.based_on accumulates: the facts from this refresh are merged with everything previous refreshes relied on, deduplicated by id. The document rests on all of them, not just the latest slice.
  • Nothing, if nothing is relevant. A refresh that finds no topic-relevant memories leaves the content alone and only advances the watermark, so the same empty window stops re-triggering.
  • No LLM call at all, if there is nothing to read. Before the agentic loop starts, a refresh checks whether anything is in reach under this model's own settings: an in-scope memory (tags/tags_match, tag_groups and fact_types all narrow it) inside the window it would read — the whole bank in full mode, everything since last_memory_seen_at in delta mode — or a sibling mental model, unless exclude_mental_models closed that door. When there is nothing, the refresh completes immediately, leaves the document untouched, and spends nothing. This is the common case on a bank that was just created: its pages are refreshed the moment they exist, before anything has been retained.
  • A failed retrieval is not an empty one. If a retrieval step raises — the database, the embedder or the reranker is unavailable — the refresh fails instead of writing what the model came up with without it. Content, structured_output and the watermark are all left untouched, so the retry reads the same window again. This is the distinction that matters: "we looked and there is nothing on this topic" is an answer and gets written; "we could not look" is a failure and gets preserved. Without it, a broken retriever silently replaced a document built over months with a generic "I don't have information about that".

Refresh Mode

Two strategies are available for how a refresh produces the new content:

  • full (default) — every refresh regenerates the entire content from scratch. Simple and predictable: the LLM synthesises a fresh document from the retrieved memories. Best when the document is short, when you want every refresh to potentially restructure the output, or when you're not yet sure what the final shape should be.

  • delta — refresh emits a list of typed operations (add a section, append a bullet, replace a block, remove a stale paragraph) against the document's existing structure, then renders the result. Sections that aren't targeted by any operation are copied through byte-identical — no paraphrasing, no whitespace drift, no list-style normalisation. Best for long-lived "playbook"–style mental models where you want stability across refreshes and only the genuinely changed parts to move.

Figure: Refresh modes: full vs delta. An animated diagram on the docs site; its narration, step by step:

  • full
    1. Full is the default. Every refresh writes the whole document again.
    2. It reads everything in the model’s scope, old and new.
    3. The LLM writes a fresh document. Carol is in, but untouched sections come back reworded too, and small drifts add up over many refreshes.
  • delta
    1. Delta edits the document instead of rewriting it.
    2. It only reads memories newer than the last one the previous refresh saw.
    3. A second LLM call compares the new findings with the current sections and answers with edit operations, not a document.
    4. The operation is applied. Every section it does not touch is copied through byte for byte, and the watermark moves to Sep.
  • delta falls back
    1. Delta needs something stable to edit. Here the source query was changed.
    2. The topic moved, so the old structure may no longer fit. The refresh falls back to a full rewrite. The same happens when the model has no content yet.
    3. It reads the whole scope, like full mode…
    4. …and writes a new document for the new question. The next refresh can edit this one in delta mode again.
How delta mode works

Hindsight keeps an authoritative structured representation of the document — an ordered list of sections, each holding a list of blocks, where a block is one markdown fragment (a paragraph, a list, a table, a code fence) stored exactly as written. The markdown you read is a deterministic render of that structure. Because a block is never re-interpreted, formatting a refresh didn't touch cannot be rewritten by one — a table stays a table. A delta refresh never asks the LLM to rewrite the document; it asks for operations against that structure:

Operation Effect
add_section / remove_section Add or drop a whole section
rename_section Change a section's heading
replace_section_blocks Replace a section's contents wholesale
append_block / insert_block Add a block at the end of, or after a named block in, a section
replace_block / remove_block Change or drop one block

Anything no operation mentions is copied through untouched, so unchanged prose is preserved rather than regenerated and checked. This matters because "preserve the unchanged content" is only a soft constraint on an LLM — generating the next token from a gestalt of the input is what it intrinsically does, so instructed-to-preserve prose drifts over many refreshes.

Sections and blocks are addressed by id, never by position, so an operation cannot land on the wrong one by miscounting. Failure modes are conservative by design: an operation referencing a section or block that doesn't exist — or a block that lives in a different section than the one it names — is dropped rather than guessed at, and the rest of the operations still apply. When every operation points at something missing, the model is asked once more, shown the ids it got wrong and the sections the document actually has. The refresh records which ones were dropped and why, so you can see that part of that round's new information didn't make it into the document.

Delta mode falls back to a full regeneration automatically in two cases:

  1. The mental model has no existing content yet (nothing to anchor edits on).
  2. The source_query has changed since the last refresh (the topic has shifted; the existing structure may no longer apply).

A delta refresh never replaces the document with a partial one. Because delta retrieval only reads memories newer than the last refresh, an answer written from that window covers just the recent slice of the topic — it is material for editing the document, not a replacement for it. So when the edits can't be made at all — the provider call fails, the response can't be read, or every single operation is rejected — the existing content stays exactly as it is and the refresh fails instead of completing. Nothing is lost, the refresh's time window is not advanced, and a retry sees the same memories again. The same holds for an empty answer: a populated document is never overwritten with an empty one. A delta refresh is never turned into a full rewrite behind your back: if a model's delta refreshes keep failing, switch its mode to full.

Use Case Recommended Mode Why
Skill / playbook docs delta Sections live for many refreshes; only specific rules change
Onboarding summaries delta Adding new team members shouldn't restructure the doc
Real-time dashboards full Each refresh is a fresh snapshot
Short FAQ summaries full Whole-document regeneration is cheap and unambiguous

Python

# Create a mental model with automatic refresh enabled
result = client.create_mental_model(
    bank_id=BANK_ID,
    name="Project Status",
    source_query="What is the current project status?",
    trigger={"refresh_cron": "0 3 * * *"}
)

# This mental model checks daily at 03:00 UTC and refreshes when scoped memories changed
print(f"Operation ID: {result.operation_id}")

Node.js

// Create a mental model with automatic refresh enabled
const result2 = await client.createMentalModel(
    BANK_ID,
    'Project Status',
    'What is the current project status?',
    { trigger: { refreshCron: '0 3 * * *' } },
);

// This mental model checks daily at 03:00 UTC and refreshes when scoped memories changed
console.log(`Operation ID: ${result2.operation_id}`);

CLI

# Create a mental model and get its ID for subsequent operations
hindsight mental-model create "$BANK_ID" \
  "Project Status" \
  "What is the current project status?"

Go

// Create a mental model with automatic refresh enabled
refreshTrue := true
result2, _, _ := client.MentalModelsAPI.CreateMentalModel(ctx, mmBankID).
	CreateMentalModelRequest(hindsight.CreateMentalModelRequest{
		Name:        "Project Status",
		SourceQuery: "What is the current project status?",
		Trigger: &hindsight.MentalModelTriggerInput{
			RefreshAfterConsolidation: &refreshTrue,
		},
	}).Execute()

// This mental model will automatically refresh when observations are updated
fmt.Printf("Operation ID: %s\n", result2.GetOperationId())

When to Use Automatic Refresh

Use Case Automatic Refresh Why
Real-time dashboards ✅ Enabled Status should always be current
Policy summaries ❌ Disabled Policies change infrequently, manual refresh preferred
User preferences ✅ Enabled Preferences evolve with new interactions
FAQ answers ❌ Disabled Answers are curated, should be reviewed before updating

💡 Tip

Enable automatic refresh for mental models that need to stay current. Disable it for curated content where you want to review changes before they go live.

List Mental Models

Python

# List all mental models in a bank. The list returns metadata by default;
# detail="content" adds source_query/content/trigger.
mental_models = client.list_mental_models(bank_id=BANK_ID, detail="content")

for mental_model in mental_models.items:
    print(f"- {mental_model.name}: {mental_model.source_query}")

Node.js

// List all mental models in a bank. The list returns metadata by default;
// detail: "content" adds source_query/content/trigger.
const mentalModels = await client.listMentalModels(BANK_ID, { detail: "content" });

for (const mm of mentalModels.items) {
    console.log(`- ${mm.name}: ${mm.source_query}`);
}

CLI

# List all mental models in a bank
hindsight mental-model list "$BANK_ID"

Go

// List all mental models in a bank. The list returns metadata by default;
// Detail("content") adds source_query/content/trigger.
mentalModels, _, _ := client.MentalModelsAPI.ListMentalModels(ctx, mmBankID).Detail("content").Execute()

for _, mm := range mentalModels.GetItems() {
	fmt.Printf("- %s: %s\n", mm.GetName(), mm.GetSourceQuery())
}

Get a Mental Model

Python

# Get a specific mental model
mental_model = client.get_mental_model(
    bank_id=BANK_ID,
    mental_model_id=mental_model_id
)

print(f"Name: {mental_model.name}")
print(f"Content: {mental_model.content}")
print(f"Last refreshed: {mental_model.last_refreshed_at}")

Node.js

// Get a specific mental model
const mentalModel = await client.getMentalModel(BANK_ID, mentalModelId);

console.log(`Name: ${mentalModel.name}`);
console.log(`Content: ${mentalModel.content}`);
console.log(`Last refreshed: ${mentalModel.last_refreshed_at}`);

CLI

# Get a specific mental model
hindsight mental-model get "$BANK_ID" "$MENTAL_MODEL_ID"

Go

// Get a specific mental model
mentalModel, _, _ := client.MentalModelsAPI.GetMentalModel(ctx, mmBankID, mentalModelID).Execute()

fmt.Printf("Name: %s\n", mentalModel.GetName())
fmt.Printf("Content: %s\n", mentalModel.GetContent())
fmt.Printf("Last refreshed: %s\n", mentalModel.GetLastRefreshedAt())

Detail Levels

Both List and Get endpoints accept an optional detail query parameter that controls how much data is returned. This is useful for reducing response size, especially in agent boot flows or MCP clients where context budget is limited.

Level Fields Returned Use Case
metadata id, bank_id, name, tags, is_stale, last_refreshed_at, last_memory_seen_at, created_at Inventory — "what models exist?"
content All metadata fields + source_query, content, max_tokens, trigger Agent boot — "what do the models say?"
full All fields including reflect_response Deep inspection — "what evidence backs this model?"

The two endpoints default differently:

  • List defaults to metadata. Listing is an index — returning every model's synthesized content by default let one request pull a whole bank's knowledge in bulk. Content is opt-in.
  • Get defaults to full. You already named the one model you want.

Python

# List: metadata only, the default (smallest response)
client.list_mental_models(bank_id=BANK_ID)

# List with content but without provenance chains (opt-in)
client.list_mental_models(bank_id=BANK_ID, detail="content")

# Get one model — full detail is the default here
client.get_mental_model(bank_id=BANK_ID, mental_model_id=mental_model_id)

Node.js

// List: metadata only, the default (smallest response)
await client.listMentalModels(BANK_ID);

// List with content but without provenance chains (opt-in)
await client.listMentalModels(BANK_ID, { detail: 'content' });

// Get one model — full detail is the default here
await client.getMentalModel(BANK_ID, mentalModelId);

CLI

# The CLI has no --detail flag; use the HTTP API
# List: metadata only, the default (smallest response)
curl "$HINDSIGHT_URL/v1/default/banks/$BANK_ID/mental-models"

# List with content but without provenance chains (opt-in)
curl "$HINDSIGHT_URL/v1/default/banks/$BANK_ID/mental-models?detail=content"

# Get one model — full detail is the default here
curl "$HINDSIGHT_URL/v1/default/banks/$BANK_ID/mental-models/$MENTAL_MODEL_ID"

Go

// List: metadata only, the default (smallest response)
client.MentalModelsAPI.ListMentalModels(ctx, mmBankID).Execute()

// List with content but without provenance chains (opt-in)
client.MentalModelsAPI.ListMentalModels(ctx, mmBankID).Detail("content").Execute()

// Get one model — full detail is the default here
client.MentalModelsAPI.GetMentalModel(ctx, mmBankID, mentalModelID).Execute()

The detail parameter is available on the get_mental_model MCP tool. The list_mental_models MCP tool does not take it: it always returns metadata (including is_stale), and an agent reads a specific model's content with get_mental_model.

💡 Tip

Use detail=content on the List endpoint for agent orientation flows that genuinely need every model's text. It includes everything the agent needs to understand the models without the heavyweight reflect_response provenance chains, which can exceed 200KB for banks with many models.

📝 Upgrading

The List endpoint previously defaulted to full. A caller that omits detail and reads content, source_query, max_tokens or trigger off the listed items now gets null — pass detail=content explicitly.

Response Fields

Field Type Detail Level Description
id string metadata Unique mental model ID
bank_id string metadata Memory bank ID
name string metadata Human-readable name
tags list metadata Tags for filtering
last_refreshed_at string metadata When a refresh last finished — wall-clock. Advances on every completed refresh, including one that found nothing new. Answers "have I already refreshed this?"
last_memory_seen_at string metadata The newest in-scope memory the last refresh saw. Answers "is this behind the data?" — compare against last_memory_write_at from the stats endpoint
created_at string metadata When the mental model was created
source_query string content The query used to generate content
content string content The generated mental model text
max_tokens int content Maximum tokens for the mental model content
trigger object content Trigger settings (see Automatic Refresh)
reflect_response object full Full reflect response including based_on provenance facts

Refresh a Mental Model

Re-run the source query to update the mental model with current knowledge:

Python

# Refresh a mental model to update with current knowledge
result = client.refresh_mental_model(
    bank_id=BANK_ID,
    mental_model_id=mental_model_id
)

print(f"Refresh operation ID: {result.operation_id}")

Node.js

// Refresh a mental model to update with current knowledge
const refreshResult = await client.refreshMentalModel(BANK_ID, mentalModelId);

console.log(`Refresh operation ID: ${refreshResult.operation_id}`);

CLI

# Refresh a mental model to update with current knowledge
hindsight mental-model refresh "$BANK_ID" "$MENTAL_MODEL_ID"

Go

// Refresh a mental model to update with current knowledge
refreshResult, _, _ := client.MentalModelsAPI.RefreshMentalModel(ctx, mmBankID, mentalModelID).Execute()

fmt.Printf("Refresh operation ID: %s\n", refreshResult.GetOperationId())

Refreshing is useful when:

  • New memories have been retained that affect the topic
  • Observations have been updated
  • You want to ensure the mental model reflects current knowledge

Refreshes coalesce. A model has at most one refresh waiting at a time: if one is already queued and has not started yet, this call returns that operation instead of queueing an identical second one — the queued refresh reads the model as it stands when it runs, so it already covers what you just asked for. Poll the returned operation_id as usual. A refresh that is already running is not reused: it may have read the model before your latest change, so a new operation is queued behind it.

And they run one at a time. A model's refreshes never overlap: the operation queued behind a running one starts when that one finishes, so the model is written by one refresh at a time instead of by whichever happened to finish last. This is per model — a bank refreshes as many different models in parallel as it has workers for.


Troubleshoot a Refresh

When a refresh produces a document you didn't expect — nothing changed, the wrong things changed, or delta edits didn't apply — the stored content alone doesn't say why. Two tools report the reasoning behind a refresh.

Dry run: preview a refresh before it happens

POST /mental-models/{id}/dry-run-refresh runs the real pipeline and reports what a refresh would do. Nothing is written: not the content, structured document, watermark, nor last_refreshed_at. Because nothing is persisted, a delta dry run reads exactly the window the next real refresh will, and repeating it reads that same window again.

Python

# Preview what a refresh would do, without writing anything
preview = client.dry_run_refresh_mental_model(
    bank_id=BANK_ID,
    mental_model_id=mental_model_id
)

print(f"Mode: {preview.effective_mode}, would persist: {preview.would_persist}")
print(preview.diff)

Node.js

// Preview what a refresh would do, without writing anything
const preview = await client.dryRunRefreshMentalModel(BANK_ID, mentalModelId);

console.log(`Mode: ${preview.effective_mode}, would persist: ${preview.would_persist}`);
console.log(preview.diff);

CLI

# Preview what a refresh would do, without writing anything
hindsight mental-model dry-run-refresh "$BANK_ID" "$MENTAL_MODEL_ID"

Go

// Preview what a refresh would do, without writing anything
preview, _, err := client.MentalModelsAPI.DryRunRefreshMentalModel(ctx, mmBankID, mentalModelID).Execute()
if err != nil {
	panic(err)
}

fmt.Printf("Mode: %s, would persist: %v\n", preview.GetEffectiveMode(), preview.GetWouldPersist())
fmt.Println(preview.GetDiff())

The response answers the questions the stored document can't:

Field What it tells you
requested_mode / effective_mode Whether the refresh ran in the mode you configured
mode_fallback_reason Why the delta edits weren't applied: no_baseline_content, source_query_changed, structured_doc_unreadable, delta_ops_failed, or delta_ops_all_skipped (every operation was rejected)
scope The tags, match mode, and fact types that actually filtered memories — not the ones stored on the model
window The created_after/created_before bounds read, and the watermark that would be persisted
facts.retrieved vs facts.used How much retrieval returned versus how much the reflect agent judged relevant
delta_operations The operations emitted, applied and skipped
diff A unified diff from the stored content to the content it would write
outcome / would_persist Whether a real refresh would write, keep the content, or fail
warnings Conditions worth attention, in plain language

The dry run takes no parameters, on purpose. It is the production refresh pipeline with exactly two writes skipped — the content (and with it the structured document and history entry) and the watermark that moves last_refreshed_at. Nothing about how it runs can be configured, because a dry run you can configure stops predicting the refresh it exists to predict. To preview a different configuration, change the model with PATCH and run it again.

⚠️ Warning

A dry run runs the same LLM calls a real refresh does, so it costs the same tokens and takes the same time. It is validated and billed like a refresh.

Keep traces: record every refresh as it happens

A dry run only explains a refresh you run yourself. Refreshes driven by refresh_after_consolidation or refresh_cron happen with nobody watching, and by the time you notice a bad document, the run that produced it is gone.

Setting trigger.keep_trace records the same reasoning on every refresh of that model — scheduled ones included — under reflect_response.trace:

Python

# Record how every refresh (scheduled ones too) reached its result
client.update_mental_model(
    bank_id=BANK_ID,
    mental_model_id=mental_model_id,
    trigger={"mode": "delta", "keep_trace": True}
)

Node.js

// Record how every refresh (scheduled ones too) reached its result
await client.updateMentalModel(BANK_ID, mentalModelId, {
    trigger: { mode: 'delta', keepTrace: true },
});

CLI

# Record how every refresh (scheduled ones too) reached its result
hindsight mental-model update "$BANK_ID" "$MENTAL_MODEL_ID" \
  --trigger-mode delta \
  --trigger-keep-trace true

Go

// Record how every refresh (scheduled ones too) reached its result
mode := "delta"
keepTrace := true
_, _, err = client.MentalModelsAPI.UpdateMentalModel(ctx, mmBankID, mentalModelID).
	UpdateMentalModelRequest(hindsight.UpdateMentalModelRequest{
		Trigger: *hindsight.NewNullableMentalModelTriggerInput(&hindsight.MentalModelTriggerInput{
			Mode:      &mode,
			KeepTrace: &keepTrace,
		}),
	}).Execute()
if err != nil {
	panic(err)
}

Only the latest refresh's trace is kept, and it is recorded even when a refresh fails — which is when it matters most, since a failed refresh leaves nothing else behind to inspect.

What a trace contains

The trace is deliberately shaped like a reflect trace: the calls the agent made, plus the decision specific to a refresh. It is stored on the mental model row and re-read on every fetch, so it holds nothing that can be derived from somewhere else.

Field Contents
effective_mode Whether the run ended up full or delta
mode_fallback_reason Why delta was requested but not applied — no_baseline_content, source_query_changed, structured_doc_unreadable, delta_ops_failed, delta_ops_all_skipped
outcome content_written, content_preserved_no_new_facts, refresh_failed_empty_candidate, or refresh_failed_delta_not_applied (the edits didn't apply, so the document was kept and the refresh failed). The operation record adds two the executor cannot produce, because they happen outside a run: refresh_failed_structured_output and refresh_failed_error (a retrieval tool raised, the agent produced no answer, or something unforeseen escaped the refresh)
tool_calls[] Per call: tool, the agent's reason, the full input, result_count, duration_ms, and the iteration it belongs to
llm_calls[] Per call: scope (agent_1, agent_2, …, final) and duration_ms
delta_operations The operations emitted in delta mode, applied and skipped
usage Token counts across the run's LLM calls
recorded_at, duration_ms, warnings[] When it ran, how long it took, and anything worth a human's attention
What a trace deliberately omits
  • Tool outputs. Only result_count is kept. Recall payloads are large and the trace is re-read on every model fetch, so storing them would grow the row without bound. A count is enough to see that a tool came back empty; when you need the raw prompts and responses, use LLM request tracing, which stores them separately and expires them on its own retention schedule.
  • The evidence. The facts a refresh grounded the document on already live in reflect_response.based_on, in the same shape reflect uses. The trace does not duplicate them.
  • The resolved scope and time window. These describe what the model's current configuration reads rather than what a past run did, so they are returned by the dry run instead of being written on every refresh.

This keeps a stored trace small: a few kilobytes for a typical run, dominated by the tool inputs.


Clear a Mental Model

Clear a mental model's content so the next refresh performs a full re-synthesis from scratch, regardless of the model's trigger mode.

This is useful for delta-mode models that have accumulated drift over many incremental refreshes. Over time, small inaccuracies can compound as each delta refresh only sees new facts since the last. Clearing and then refreshing produces a clean baseline from all facts.

Python

# Clear a mental model's content, then refresh for a full re-synthesis
client.clear_mental_model(
    bank_id=BANK_ID,
    mental_model_id=mental_model_id
)

# Trigger a fresh full rebuild
result = client.refresh_mental_model(
    bank_id=BANK_ID,
    mental_model_id=mental_model_id
)

print(f"Full refresh operation ID: {result.operation_id}")

Node.js

// Clear a mental model's content, then refresh for a full re-synthesis
await client.clearMentalModel(BANK_ID, mentalModelId);

// Trigger a fresh full rebuild
const fullRefreshResult = await client.refreshMentalModel(BANK_ID, mentalModelId);

console.log(`Full refresh operation ID: ${fullRefreshResult.operation_id}`);

CLI

# Clear a mental model's content, then refresh for a full re-synthesis
curl -s -X POST "${HINDSIGHT_URL}/v1/default/banks/${BANK_ID}/mental-models/${MENTAL_MODEL_ID}/clear"

# Trigger a fresh full rebuild
hindsight mental-model refresh "$BANK_ID" "$MENTAL_MODEL_ID"

Go

// Clear a mental model's content, then refresh for a full re-synthesis
client.MentalModelsAPI.ClearMentalModel(ctx, mmBankID, mentalModelID).Execute()

// Trigger a fresh full rebuild
fullRefreshResult, _, _ := client.MentalModelsAPI.RefreshMentalModel(ctx, mmBankID, mentalModelID).Execute()

fmt.Printf("Full refresh operation ID: %s\n", fullRefreshResult.GetOperationId())

The clear operation is synchronous and resets the content to an empty string. The model's configuration (name, source query, trigger settings) is preserved. Since the content is now empty, the next /refresh call will always perform a full regeneration — even if the model's trigger mode is set to delta.

💡 Tip

For long-lived delta-mode mental models, consider scheduling a periodic clear + refresh (e.g. every 48 hours) to keep the content accurate while still benefiting from incremental delta updates in between.

Update a Mental Model

Update the mental model's name:

Python

# Update a mental model's metadata
updated = client.update_mental_model(
    bank_id=BANK_ID,
    mental_model_id=mental_model_id,
    name="Updated Team Communication Preferences",
    trigger={"refresh_after_consolidation": True}  # Enable auto-refresh
)

print(f"Updated name: {updated.name}")

Node.js

// Update a mental model's metadata
const updated = await client.updateMentalModel(BANK_ID, mentalModelId, {
    name: 'Updated Team Communication Preferences',
    trigger: { refreshAfterConsolidation: true },
});

console.log(`Updated name: ${updated.name}`);

CLI

# Update a mental model's metadata
hindsight mental-model update "$BANK_ID" "$MENTAL_MODEL_ID" \
  --name "Updated Team Communication Preferences"

Go

// Update a mental model's metadata
newName := "Updated Team Communication Preferences"
refreshAfter := true
updated, _, _ := client.MentalModelsAPI.UpdateMentalModel(ctx, mmBankID, mentalModelID).
	UpdateMentalModelRequest(hindsight.UpdateMentalModelRequest{
		Name: *hindsight.NewNullableString(&newName),
		Trigger: *hindsight.NewNullableMentalModelTriggerInput(&hindsight.MentalModelTriggerInput{
			RefreshAfterConsolidation: &refreshAfter,
		}),
	}).Execute()

fmt.Printf("Updated name: %s\n", updated.GetName())

Delete a Mental Model

Python

# Delete a mental model
client.delete_mental_model(
    bank_id=BANK_ID,
    mental_model_id=mental_model_id
)

Node.js

// Delete a mental model
await client.deleteMentalModel(BANK_ID, mentalModelId);

CLI

# Delete a mental model
hindsight mental-model delete "$BANK_ID" "$MENTAL_MODEL_ID" -y

Go

// Delete a mental model
client.MentalModelsAPI.DeleteMentalModel(ctx, mmBankID, mentalModelID).Execute()

Tags and Visibility

Mental models support the same tag system as memories. When you assign tags to a mental model, those tags control both which memories it reads during refresh and when it is surfaced during reflect.

How tags affect mental model refresh

⚠️ Warning

Adding tags to a mental model narrows the pool of source memories its refresh can read from. If no memories carry those tags yet, refresh will return empty content (e.g. "I cannot find any information…") even though direct reflect on the same query works. Backfill tags on the relevant memories first, or override the default via trigger.tags_match / trigger.tag_groups. When a mental model is refreshed (manually or automatically), it runs an internal reflect call to regenerate its content. If the mental model has tags, that reflect call uses all_strict tag matching — meaning it will only read memories that carry all of the mental model's tags. Untagged memories are excluded.

Mental model tags: ["user:alice"]

During refresh, it reads:
  ✅ "Alice prefers async communication"     — has "user:alice"
  ✅ "Team uses Slack for announcements"      — has "user:alice" (plus other tags)
  ❌ "Company policy: no meetings on Fridays" — untagged, excluded
  ❌ "Bob dislikes long meetings"             — no "user:alice" tag

This means a mental model tagged ["user:alice"] will also pick up memories tagged ["user:alice", "team"] — extra tags on a memory don't disqualify it. Only the mental model's own tags are required to be present.

Overriding the default with tags_match

The all_strict default is the safest choice for single-tag models, but it filters out everything for a multi-tag model whose memories only carry one tag each. If a model tagged ["projects", "mental-model"] reads from memories tagged narrowly (["project:status"], ["tooling"], …), no single memory carries all the model's tags and the refresh comes back empty.

To match memories that carry any of the model's tags — the same default recall and reflect use — set trigger.tags_match to "any" at creation:

Python

# Override how the model's tags filter source memories on refresh.
# A tagged model defaults to "all_strict" (a memory must carry EVERY tag);
# use "any" when your memories are tagged narrowly (one topic each), so the
# refresh reads any memory carrying at least one of the model's tags.
result = client.create_mental_model(
    bank_id=BANK_ID,
    name="Current Projects",
    source_query="Which projects is the user currently working on?",
    tags=["projects", "mental-model"],
    trigger={"tags_match": "any"}
)

print(f"Operation ID: {result.operation_id}")

Node.js

// Override how the model's tags filter source memories on refresh.
// A tagged model defaults to 'all_strict' (a memory must carry EVERY tag);
// use 'any' when your memories are tagged narrowly (one topic each), so the
// refresh reads any memory carrying at least one of the model's tags.
const result3 = await client.createMentalModel(
    BANK_ID,
    'Current Projects',
    'Which projects is the user currently working on?',
    {
        tags: ['projects', 'mental-model'],
        trigger: { tagsMatch: 'any' },
    },
);

console.log(`Operation ID: ${result3.operation_id}`);

CLI

# Override how the model's tags filter source memories on refresh.
# A tagged model defaults to all_strict (a memory must carry EVERY tag);
# pass --tags-match any when your memories are tagged narrowly (one topic
# each), so the refresh reads any memory carrying at least one of the tags.
hindsight mental-model create "$BANK_ID" \
  "Current Projects" \
  "Which projects is the user currently working on?" \
  --tags projects,mental-model \
  --tags-match any

Go

// Override how the model's tags filter source memories on refresh.
// A tagged model defaults to "all_strict" (a memory must carry EVERY tag);
// use "any" when your memories are tagged narrowly (one topic each), so the
// refresh reads any memory carrying at least one of the model's tags.
result3, _, _ := client.MentalModelsAPI.CreateMentalModel(ctx, mmBankID).
	CreateMentalModelRequest(hindsight.CreateMentalModelRequest{
		Name:        "Current Projects",
		SourceQuery: "Which projects is the user currently working on?",
		Tags:        []string{"projects", "mental-model"},
		Trigger: &hindsight.MentalModelTriggerInput{
			TagsMatch: *hindsight.NewNullableString(hindsight.PtrString("any")),
		},
	}).Execute()

fmt.Printf("Operation ID: %s\n", result3.GetOperationId())

The MCP create_mental_model tool exposes the same option as a top-level tags_match argument. Available modes are any, all, any_strict, all_strict, and exact — see the Recall tags reference for their exact semantics.

How tags affect mental model lookup during reflect

When you call reflect with tags, those same tags are used to filter which mental models the agent can see. A mental model is visible only if its tags overlap with the tags on the reflect request.

For more details on tag matching modes (any, any_strict, all, all_strict) and worked examples, see the Recall tags reference.

Listing mental model tags

GET /v1/default/banks/{bank_id}/tags accepts a source query parameter that selects which tag space to enumerate:

  • source=memories (default) — tags attached to memory units.
  • source=mental_models — tags attached to mental models in this bank.

Use the mental_models source to populate autocomplete or filter UIs over mental-model tags, distinct from the (typically larger) memory tag set.


History

Every time a mental model's content changes (via refresh or manual update), the previous version is saved with a timestamp. You can retrieve the full change log with the history endpoint:

Python

# Get the change history of a mental model
history = client.get_mental_model_history(
    bank_id=BANK_ID,
    mental_model_id=mental_model_id
)

for entry in history:
    print(f"Changed at: {entry['changed_at']}")
    print(f"Previous content: {entry['previous_content']}")

Node.js

// Get the change history of a mental model
const history = await client.getMentalModelHistory(BANK_ID, mentalModelId);

for (const entry of history) {
    console.log(`Changed at: ${entry.changed_at}`);
    console.log(`Previous content: ${entry.previous_content}`);
}

CLI

# Get the change history of a mental model
hindsight mental-model history "$BANK_ID" "$MENTAL_MODEL_ID"

Go

// Get the change history of a mental model
history, _, _ := client.MentalModelsAPI.GetMentalModelHistory(ctx, mmBankID, mentalModelID).Execute()

if entries, ok := history.([]interface{}); ok {
	for _, entry := range entries {
		if e, ok := entry.(map[string]interface{}); ok {
			fmt.Printf("Changed at: %v\n", e["changed_at"])
			fmt.Printf("Previous content: %v\n", e["previous_content"])
		}
	}
}

Response

The endpoint returns a list of history entries, most recent first:

Field Type Description
previous_content string | null The content before this change (null if not available, and always null on a failure record)
changed_at string ISO 8601 timestamp of when the change occurred
kind "refresh_failed" | absent Present only on a failure record — a refresh that refused to write. Absent on version snapshots
outcome string | absent On a failure record: the operation outcome, e.g. refresh_failed_error
failure_reason string | absent On a failure record: why it refused — retrieval_failed, no_answer, unexpected_error, empty_candidate, structured_doc_unreadable, delta_ops_failed, delta_ops_all_skipped, delta_not_applied, structured_output_failed
error_message string | absent On a failure record: the exception, as the operation reports it

Each version entry captures the content before the change and when it happened. The current content is returned by the standard Get a Mental Model endpoint.

Failures are recorded too. A refresh that refuses to write produces no version, so before this it left no trace on the model at all — a document whose refreshes had been failing for a week was indistinguishable from one nobody had refreshed, and its stored reflect_response still described the last run that succeeded. Those runs now append a failure record carrying the reason and the exception. Read them by their kind: a client that only wants versions filters kind out, and one that wants to know whether the model is current checks whether the newest entry is a failure.

Retention is applied per kind, so a run of failures cannot evict the version history — each is capped at HINDSIGHT_API_MENTAL_MODEL_HISTORY_MAX_ENTRIES independently. A retried refresh records one entry per attempt.

📝 Note

History tracking is enabled by default. Set HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY=false to disable it, and HINDSIGHT_API_MENTAL_MODEL_HISTORY_MAX_ENTRIES to change how many versions are kept per model (default 50).

Use Cases

Use Case Example
FAQ Answers Pre-compute answers to common customer questions
Onboarding Summaries "What should new team members know?"
Status Reports "What's the current project status?" refreshed weekly
Policy Summaries "What are our security policies?"

Next Steps

  • Reflect — How the agentic loop uses mental models
  • Observations — How knowledge is consolidated
  • Operations — Track async mental model creation

Source: SKILL.md on GitHub

1 alerttoday5 checks · Risk SAFE
  • Gen Agent Trust Hubtoday

    The skill is a comprehensive documentation set for the Hindsight memory system, providing architecture overviews, API references, and integration guides for multiple AI agent frameworks. No security risks were identified in the documentation or provided examples.

  • Sockettoday

    No alerts

  • Snyktoday

    Risk: LOW · No issues

  • Runlayer6mo

    30/42 files flagged

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at 5bfef3c. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 17 hours ago.

Activeupdated 2 months ago

README badge

README badge for vectorize-io/hindsight/hindsight-docs