Compare commits

...
Author SHA1 Message Date
Nicolò Boschi 0022d427d3 feat(integrations): add hindsight-opencode-coding plugin
Reflect-only long-term memory for coding agents in OpenCode, with a git+chat
backfill and (opt-in) live session write-back.

- reflect + INJECT: on a task, reflect() the symptom and push the root-cause
  answer into the system prompt (no tools/recall).
- backfill: every commit (full message + full diff, commit timestamp + git
  metadata) under a 'git' retain strategy; each chat as a JSON user/assistant
  transcript with custom extraction (<=2 coherent facts) under a 'chat' strategy;
  observations on; optional codebase knowledge pages.
- live write-back (opt-in HINDSIGHT_RETAIN_SESSIONS): every N turns upsert the
  tool-filtered transcript under a stable conversation:<sessionID> document_id.
2026-07-02 13:55:40 +02:00
Nicolò Boschi 1f9bad0858 feat(knowledge-base): default pages to living-document trigger + 4096 tokens
Client-created pages had no server curation applying a trigger, so they fell back
to the plain mental-model default (no refresh, full mode, all fact types). Make a
knowledge page a living document by default: when the client omits `trigger`, use
observation-only + delta + exclude_mental_models + refresh_after_consolidation;
when it omits `max_tokens`, default to 4096 (vs the mental-model 2048). Clients
can still override either.
2026-07-02 10:30:40 +02:00
Nicolò Boschi 138bf02f29 refactor(knowledge-base): drop server-side curation + folder missions
The knowledge base is now purely client-managed (CRUD over folders/pages); the
server no longer auto-curates. Removes the folder curator entirely and the
folder `mission` concept, and leads the sidebar with Knowledge Base.

- Remove engine/knowledge_curator.py, the curate_folder task (handler + dispatch
  + submit_async_curate_folder / _bank_folders), the post-consolidation curation
  hook, and the folder-create / mission-update curation triggers.
- Remove folder `mission` and `last_curated_at` (columns + engine + API + UI);
  keep `managed` as a client-set flag. Migration a5b6 now adds `managed` only;
  the last_curated_at migration is dropped and the unique-index migration
  repointed. Single alembic head preserved.
- API: KnowledgeNode/CreateFolderRequest/UpdateNodeRequest lose `mission`;
  PATCH node handles name/parent_id only.
- Control plane: sidebar leads with Knowledge Base (before Memories); remove the
  mission field, edit-mission dialog, and mission display from the KB view.
- Delete the curator tests; regenerate OpenAPI + SDK clients.
2026-07-02 10:30:39 +02:00
Nicolò Boschi df178aae8a refactor(hindsight-fs): mirror the knowledge-base tree, not mental models
Re-point hindsight-fs at the knowledge base so it projects a bank's folder/page
hierarchy as nested directories + .md files, instead of a flat list of mental
models.

- client: fetch GET /knowledge-base/tree + /export (two calls, any bank size)
  and join by page id; replaces the paginated mental-models list.
- format: planMirror() walks the tree into folder dirs + page files at nested
  paths (slug per segment, collision-safe); pages render the page's OKF doc.
- sync: create folder dirs, write pages at nested paths, prune removed pages and
  emptied folders; state keyed by relative path + tracked dirs.
- config/cli: drop the mental-model `detail` flag; `list` prints folders+pages;
  help/README updated. Tests rewritten for the tree/export model.

Verified live against a bank's knowledge base: the `people` folder mirrors to
people/anna.md + people/marco.md with OKF frontmatter.
2026-07-02 10:30:39 +02:00
Nicolò Boschi a43026b8f4 feat(hindsight-fs): mirror a bank's mental models as a live local folder
Add @vectorize-io/hindsight-fs, a CLI under hindsight-tools/ that mirrors a
Hindsight bank's mental models as real markdown files (YAML frontmatter + body)
in a local directory, refreshed from the API on an interval. Once mounted,
ordinary shell tools (ls, cat, grep, find, ...) work against current memory.

- Pull-based sync engine: full list each tick, write changed/new/tampered
  files, skip unchanged (content-hashed), prune deleted models. Atomic writes;
  a transient API error never wipes the mirror.
- One-way mirror enforced two ways: files are read-only (0444) so agent edits
  fail with EACCES, plus a tamper-revert backstop that compares on-disk bytes
  and overwrites drift on the next pass. --writable opts out.
- Commands: mount/start/stop/restart/sync/status/list/logs/unmount. Background
  daemon via detached process + pidfile; per-mount config is remembered.
- status doubles as a healthcheck: --json report and a non-zero exit when the
  mount is dead/failed/stale (--stale-after overrides the threshold).
- Tests: unit (sync engine, frontmatter, health) + e2e that spawns the real
  CLI against a mock API and exercises real bash commands. 26 tests.
2026-07-02 10:30:39 +02:00
Nicolò Boschi 5c425e276e feat(knowledge-base): self-curating knowledge base (OKF pages + folder missions)
Server-side knowledge base: a hierarchy of folders and pages over mental
models, projected to the Open Knowledge Format, with a mission-driven curator
that maintains pages automatically after each consolidation.

- knowledge_pages table (PG + Oracle): parent_id tree, kind folder/page,
  mission, managed, last_curated_at; partial unique index on (folder, name)
  for concurrency-safe dedup; added to BACKUP_TABLES.
- api/okf.py: OKF serializer (frontmatter + body, index/log, constellation graph).
- engine/knowledge_curator.py: folder curator (LLM op plan + safe apply); reads
  new memories since last curation (delta, not recall); ops create/merge/delete
  page + spawn sub-folder (bounded depth<=3, <=8). Runs as an async curate_folder
  task on folder/mission create and after consolidation. Curator pages use an
  observation-only delta trigger with exclude_mental_models.
- MemoryEngine: folder/page CRUD, tree, curate, async submit + worker handler.
- /v1/default/banks/{bank}/knowledge-base/* endpoints.
- Control plane: knowledge-base tree view + constellation toggle, missions,
  OKF page panel + bundle export; proxies, client, sidebar, i18n.
- Tests: okf unit, knowledge-base HTTP, curator apply + dedup guard, hs_llm_core e2e.
- Regenerated OpenAPI + SDK clients + docs-skill.
2026-07-02 10:30:39 +02:00
Nicolò Boschi 265192e509 docs: restore audio in v0.8.4 release-notes video 2026-07-01 14:26:54 +02:00
Nicolò Boschi 8f2cee4568 docs: changelog and blog post for v0.8.4 (#2474)
* docs: changelog and blog post for v0.8.4

* docs: add compressed release-notes video for v0.8.4 blog
2026-07-01 14:17:12 +02:00
Nicolò Boschi 92f433c904 Release v0.8.4
- Update version to 0.8.4 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- hindsight-all npm wrapper: hindsight-all-npm
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Sync documentation to version-0.8
2026-07-01 13:43:13 +02:00
Parafee41 f8ce15b9bf show full memory details in explorer (#2490) 2026-07-01 13:37:08 +02:00
Nicolò Boschi d68f618969 feat(stats): distributed bank_stats cache + ?refresh param + stats perf suite (#2495)
* feat(stats): distributed (table-backed) bank_stats cache on PostgreSQL

get_bank_stats aggregates over memory_links/unit_entities — a multi-second scan
on large banks. It was cached per-process (in-memory), so every API worker
recomputed once per TTL and the first caller after expiry stalled.

Add a bank_stats_cache table and a DistributedBankStatsCache that shares one
worker's computation across all workers. Same get_or_load/invalidate contract as
the in-memory cache, so the hot path is a single PK SELECT on a hit; only a miss
runs the existing _compute_bank_stats loader and UPSERTs the row (ON CONFLICT,
no lock — concurrent misses recompute, last write wins). All DB touches are
best-effort: an unreachable/missing cache table degrades to computing uncached
rather than failing the endpoint. PostgreSQL only; Oracle keeps the in-memory
cache (selected by dialect at construction).

* feat(stats): add ?refresh query param to force fresh /stats (default off)

Adds force_refresh to get_bank_stats (and both cache backends): when set, the
cached value is bypassed and recomputed, and the fresh result refreshes the
cache for subsequent callers. Exposed on GET /stats as ?refresh=true (default
false). Regenerated OpenAPI spec + clients.

* test(perf): add stats benchmark suite + huge prod-sim scale

New 'stats' perf suite measures get_bank_stats: uncached aggregation latency
(node/link counts + entity rollup) vs cached, run with the result cache disabled
so the headline numbers are the real per-poll cost. Adds a 'huge' prod-simulation
scale that bulk-loads ~500k units / ~17.8M physical memory_links via COPY (entity
links derived from unit_entities, not stored).

* test(stats): exclude bank_stats_cache from backup guard + HTTP refresh test

- bank_stats_cache is a derived TTL cache (no FK to banks, repopulates on
  demand), so exclude it from test_backup_tables_covers_entire_schema rather
  than back up stale cache rows — a restore starts it cold.
- Add a ?refresh=true assertion to the /stats HTTP integration test.

* fix(cli): pass refresh arg to get_agent_stats after ?refresh param

The new /stats ?refresh query param adds a positional arg to the progenitor-
generated get_agent_stats; the CLI reads the cached value, so pass None.
2026-07-01 13:35:54 +02:00
Nicolò Boschi 33e9db64a1 test: fix CI regressions (Vertex/litellmrouter construction, dedup config, trace recorder leak) (#2491)
* test(consolidation): fix dedup merge-path tests missing text-search config

The dedup merge/update path builds a search_vector UPDATE clause from
config.text_search_extension (+ _native_language) since #2425, but the
_dedup_reconcile_create / _dedup_reconcile_update test configs only set
consolidation_dedup_threshold, so the two merge-path tests raised
AttributeError: 'types.SimpleNamespace' object has no attribute
'text_search_extension' on main.

Add the two fields (production defaults native/english) to those configs.
The clause reuses $1, so the existing positional-arg assertions are unchanged.

* test(fact-extraction): pass Vertex AI settings when building LLMConfig

Regression: LLMConfig was refactored to use vertexai_project_id/region/
service_account_key as-passed (the caller resolves the global-config fallback),
but the llm_config fixture never forwarded them. So with the CI provider set to
vertexai, LLMConfig raised "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID is required"
even though the env var was set — the test errored in the test-api job.

Forward the three Vertex settings from config (mirroring MemoryEngine's own
LLMConfig construction). Verified: LLMConfig(provider="vertexai", ...) now
constructs with project_id passed, and still raises when it is omitted.

* test(llm-provider): forward provider-specific settings in _make_llm

_make_llm() built an LLMProvider from the env-selected provider without
forwarding provider-specific settings, so the vertexai and litellmrouter
acceptance-matrix jobs failed at construction ("VERTEXAI_PROJECT_ID is required"
/ "litellmrouter requires a config object"). LLMProvider uses these as-passed
(it does not resolve them from global config), so forward
vertexai_project_id/region/service_account_key and litellmrouter_config.

* test(llm-trace): drop leaked span recorders after each test (#2229)

Root cause of the flaky test_llm_trace::test_disabled_writes_no_rows:
MemoryEngine.__init__ registers its LLM-trace recorder in a process-global
registry, and only close() removes it. Tests that construct an engine directly
(test_per_operation_llm_config, test_llm_reasoning_effort_env, etc.) never
close it, leaking an ENABLED recorder. Locally those recorders' writes fail
(uninitialized backend), but in CI a leaked recorder with a live backend
records a later test's LLM calls into the shared DB — so test_disabled_writes_
no_rows sees rows for its bank even though its own recorder is disabled
(assert N == 0). Reproduced: after test_per_operation_llm_config the registry
holds 8 enabled recorders.

Add an autouse fixture that snapshots the registry and removes anything a test
leaked. Verified: the registry drops from 8 leaked recorders back to 1.
_teardown_memory_engine already guards the fixtures; this guards direct
constructions. 53 trace + leak-risk tests pass together under -n2.
2026-07-01 13:35:22 +02:00
Nicolò Boschi 0c8699dc20 docs: document CODEX_HOME isolation for long-running Codex services (#2496)
Hindsight already honors CODEX_HOME (openai-codex LLM + embeddings), but it
was only mentioned in the 0.8.3 changelog. Long-running services sharing
~/.codex/auth.json with another Codex process can get their refresh token
rotated out, leaving /reflect broken while /health stays green.

Add a 'Isolating Codex auth for long-running services' section to the Models
docs and a pointer next to the openai-codex snippet in configuration.

Refs #2476
2026-07-01 12:04:31 +02:00
Nicolò Boschi 251c451fc3 chore(search): remove HINDSIGHT_API_LAZY_RERANKER flag (#2478)
Lazy reranker init was the only mode in which CrossEncoderReranker.ensure_initialized()
could double-load the model: its check-then-act over the `await` is a real race, but
in the default (eager) path init_cross_encoder() runs at startup — single-threaded,
before any request — so the per-request guard always short-circuits and the window
never opens (see PR #2445 discussion).

Rather than guard the lazy path with a lock, drop the flag entirely. The cross-encoder
is now always initialized eagerly at startup, which removes the race by construction and
the first-recall latency cliff. The only thing the flag bought was skipping an ~80MB
model load for retain-only deployments — not worth the extra config surface and the
concurrency footgun.

- Remove ENV_LAZY_RERANKER, the config field, and from_env() wiring
- Remove the lazy_reranker constructor param; always append init_cross_encoder()
- Drop the now-dead kwarg/env from tests; rename the ensure_initialized timeout tests
- Update docs + regenerate the docs skill mirror

ensure_initialized() is kept as a cheap idempotent guard on the recall path.
2026-07-01 12:01:29 +02:00
Evo 8ed49e4387 fix(retain): honor configured LLM temperature in the batch fact-extraction path (#2469 follow-up) (#2485)
* fix(retain): honor configured LLM temperature in batch fact-extraction

#2469 de-hardcoded the streaming path but the batch _build_request_body still
sent temperature=0.1 unconditionally, so HINDSIGHT_API_LLM_TEMPERATURE=none was
ignored and Azure GPT-5.5 batch retain kept rejecting requests. Omit the field
when the configured retain temperature is None, mirroring LLMProvider.call.

* test(retain): cover batch _build_request_body temperature threading
2026-07-01 11:56:00 +02:00
Parafee41 82afa76182 fix(cli): explore header selection (#2489) 2026-07-01 11:49:25 +02:00
DK09876 4c307ce4e7 release(openhands): v0.1.1 2026-06-30 07:38:51 -07:00
DK09876 252b013243 release(continue): v0.1.1 2026-06-30 07:38:31 -07:00
DK09876 a6b0f82124 release(aider): v0.1.1 2026-06-30 07:33:43 -07:00
Nicolò Boschi a27754fb15 fix(llm): make per-operation temperature configurable (#2459) (#2469)
* fix(llm): make per-operation temperature configurable (#2459)

Internal LLM calls used hardcoded temperatures (verification 0.0, fact
extraction 0.1, reflect thinking 0.9, consolidation 0.0, bank mission 0.3).
Models like Azure gpt-5.5 reject any explicit temperature other than their
default, breaking retain/reflect/verification.

Expose each as an env knob with a global override:
- HINDSIGHT_API_LLM_TEMPERATURE (global) + _VERIFICATION/_RETAIN/_REFLECT/
  _CONSOLIDATION/_MISSION (per-operation override).
- Resolution: per-operation env -> global env -> historical default.
- A value of none/default/off/empty omits the temperature parameter entirely,
  so HINDSIGHT_API_LLM_TEMPERATURE=none fixes gpt-5.5 in one variable.

call() already drops temperature=None across providers, so the None config
value naturally omits the param. Defaults preserve prior behavior exactly
(fully backwards compatible). Server-level/static config.

* test(llm): verify per-operation temperature reaches the LLM call

MockLLM now records the temperature it receives, and a new pipeline test
drives the real engine: retain forwards 0.1 to fact extraction, the reflect
thinking path forwards 0.9, and HINDSIGHT_API_LLM_TEMPERATURE=none omits the
parameter (None) on a live call.

* test(llm): set llm_temperature_retain on the fact-extraction retry mock config

The retry tests build a MagicMock(spec=HindsightConfig); dataclass
annotation-only fields aren't in the spec, so the new llm_temperature_retain
field (now read at the extraction call site) must be set explicitly.
2026-06-30 16:25:14 +02:00
Nicolò Boschi 40fe7aac86 fix(llm): propagate per-scope LLM timeout + retry policy to the provider (#2452) (#2470)
The per-operation LLM request settings were resolved into HindsightConfig but
never reached the provider that uses them, so configuring them was a silent
no-op:

- *_llm_timeout (retain/reflect/consolidation) and the global llm_timeout never
  reached the provider impl; it fell back to HINDSIGHT_API_LLM_TIMEOUT/120s, so
  HINDSIGHT_API_RETAIN_LLM_TIMEOUT=300 did nothing ("LiteLLM call exceeded
  timeout=120.0s").
- reflect_llm_max_retries/initial_backoff/max_backoff and
  consolidation_llm_initial_backoff/max_backoff were never consumed; reflect and
  consolidation used the hardcoded call()/call_with_tools() defaults (10/5),
  ignoring the documented "falls back to llm_max_retries" contract.

Fix: resolve each operation's effective request defaults (per-op override else
global) in MemoryEngine and carry them on the LLMProvider:

- timeout is threaded config -> LLMProvider -> create_llm_provider -> provider
  impl for the providers that honour a configurable request timeout (LiteLLM,
  LiteLLM Router, OpenAI-compatible, Nous). None preserves each provider's own
  default, so Anthropic/Gemini keep their bespoke timeouts and the no-config
  path is byte-identical.
- max_retries/initial_backoff/max_backoff become LLMProvider instance defaults
  that call()/call_with_tools() use when the per-call arg is omitted. Explicit
  per-call args (retain's resolved values, reflect's fast structured-extraction
  path) still win; providers built without config (from_env, tests) keep the
  10/5 method fallback.

The four operation scopes (default/retain/reflect/consolidation) and multi-LLM
chain members all share their operation's resolved values via a small
_LLMCallDefaults bundle.

max_concurrent is intentionally left as-is (process-global semaphores read from
env at startup, server-level only); the docs are clarified to call out that
distinction.

Also fixes a pre-existing breakage in test_llm_router_provider's __new__-based
helper (missing _default_headers after #2466) so the suite is green.

Tests: tests/test_llm_timeout_propagation.py covers provider-impl timeout
threading, the call() retry-policy fallback/override, and per-op
resolution/fallback in MemoryEngine.
2026-06-30 16:25:02 +02:00
Nicolò Boschi 7393400f34 release(openclaw): v0.9.0 2026-06-30 11:25:18 +02:00
3b7d18d474 fix(openclaw): skip synthetic tool_result user messages in sliceLastTurnsByUserBoundary (#2307)
* fix: skip synthetic tool_result user messages in sliceLastTurnsByUserBoundary

OpenClaw normalizes tool_result blocks into role:"user" messages with a
tool_result content block. The sliceLastTurnsByUserBoundary function used
to count every role:"user" message as a turn boundary, causing synthetic
tool_result messages to fill the retention window and exclude actual user
input from retained transcripts.

This change adds a hasRealTextContent guard that skips user messages
containing only tool_result blocks, ensuring only genuine user text is
counted as turn boundaries for both retain and recall window slicing.

Fixes: retained transcripts missing user input when tool calls are present

* fix: skip synthetic tool_result user messages in sliceLastTurnsByUserBoundary

* fix: skip synthetic tool_result user messages in sliceLastTurnsByUserBoundary

* style(openclaw): prettier-format hasRealTextContent block

---------

Co-authored-by: Kumaxs <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-30 11:23:09 +02:00
Nicolò Boschi 6e18858e32 release(cursor-cli): v0.3.0 2026-06-30 11:20:40 +02:00
84e67efbf4 fix(cursor-cli): parse Cursor 3.x role-nested agent transcripts (#2465)
* fix(cursor-cli): parse Cursor 3.x role-nested agent transcripts

Cursor CLI writes agent-transcripts/*.jsonl as
{role, message: {content: [blocks]}} without a top-level type field.
The retain hook's transcript reader only handled flat and type-nested
SDK envelopes, so real transcripts parsed to zero messages and retain
appeared to succeed while storing nothing.

Port the third parser branch from the Cursor editor integration and add
a regression test. Closes the gap flagged as "Should fix #4" during
review of #1975.

Co-authored-by: Cursor <[email protected]>

* feat(cursor-cli): gate text-mode tool markers behind includeTools (default off)

The shared transcript parser surfaced [tool_use]/[tool_result] markers in
the plain-text view, changing what lands in recall queries and light retain.
Gate those markers behind a new includeTools config flag (default off), so
the default light read keeps only natural-language text as before.

Also collapse the now-dead user/assistant event_type branches in the rich
reader (handled by _parse_transcript_entry) and drop the redundant
_extract_text_from_blocks helper, folding the three text/rich finalization
paths into a single _finalize_entry.

---------

Co-authored-by: mutex <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-30 11:19:29 +02:00
ef2e8ab7ff fix(openclaw): apply configured defaults to dynamic banks (#2441)
* fix(openclaw): apply configured defaults to dynamic banks

Co-authored-by: Cursor <[email protected]>

* fix(openclaw): route knowledge tools through identity resolution for user-scoped banks

Knowledge tool factories now use resolveAndCacheIdentity before deriving bank IDs,
matching auto-recall/retain so PluginToolContext sessions hit the correct per-user
bank. Unresolved user identity returns a clear tool error instead of querying
anonymous/openclaw fallbacks, and bank defaults are applied before execution.

Co-authored-by: Cursor <[email protected]>

* fix(agent-sdk): stop mapping max_results to recall max_tokens

NemoClaw passed max_results=25 expecting a result-count cap, but the SDK
used it as max_tokens=25 and starved recall. max_tokens now defaults to
1024 from max_tokens only; max_results slices the results array (1-50).

Co-authored-by: Cursor <[email protected]>

* refactor(openclaw): drop dead alias exports + tighten entityLabels shape

- Remove unused @deprecated hasConfiguredMissions/applyConfiguredMissions
  aliases (new exports nothing imports).
- normalizeEntityLabels now only accepts the server's shapes (a list, or a
  { attributes: [...] } object); a plain keyed object is dropped client-side
  instead of being sent and silently ignored by parse_entity_labels.
- Update docs (types.ts, plugin.json, README) and tests to match.

* fix(agent-sdk): drop unsupported max_results from recall tool

The recall tool's max_results was previously aliased to the recall token
budget (a no-op for result count). Rather than make it a real cap, remove
it entirely — the tool accepts only max_tokens; use recallTopK for an
auto-recall count cap.

Also document the new per-user dynamic bank defaults (retainExtractionMode,
enableObservations, enableAutoConsolidation, dispositions, entityLabels) on
the docs-site OpenClaw page and fix its stale max_results guidance.

---------

Co-authored-by: DK09876 <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-30 11:03:26 +02:00
Issam Bousfiha cc45e16904 feat(opencode): add env var overrides for retain and recall options (#2336)
* feat(config): add env var overrides for retain and recall options

Add missing environment variable overrides for configuration options
that were only settable via plugin options or config file:
- HINDSIGHT_RETAIN_EVERY_N_TURNS
- HINDSIGHT_RETAIN_OVERLAP_TURNS
- HINDSIGHT_RECALL_TAGS / HINDSIGHT_RETAIN_TAGS
- HINDSIGHT_RECALL_TAGS_MATCH
- HINDSIGHT_RECALL_PROMPT_PREAMBLE
- HINDSIGHT_RECALL_CONTEXT

* feat(config): add HINDSIGHT_BANK_ID_PREFIX env override

* fix(opencode): rename HINDSIGHT_RECALL_CONTEXT to HINDSIGHT_RETAIN_CONTEXT

The env var HINDSIGHT_RECALL_CONTEXT mapped to retainContext, which
breaks the naming convention where RECALL_* maps to recall* properties
and RETAIN_* maps to retain* properties.
2026-06-30 11:00:54 +02:00
Minghao Xiao 82b01ace5e fix(reflect): unwrap JSON answer envelopes (#2345)
* fix(reflect): unwrap JSON answer envelopes

* fix(reflect): clarify leaked done argument recovery
2026-06-30 10:53:45 +02:00
Parafee41 962140eef6 feat(claude-code): Add recall tag filters to memory hook (#2331)
* Add recall tag filters

* Support per-bank recall tag filters

* Use recall tag config names
2026-06-30 10:53:09 +02:00
EvoandNicolò Boschi 5e73d5ff62 fix(llm): wire default_headers into LiteLLM-backed providers (#2458) (#2466)
* fix(llm): wire default_headers into LiteLLM-backed providers (#2458)

HINDSIGHT_API_LLM_DEFAULT_HEADERS is documented and parsed but only wired
into the Anthropic provider, so it silently no-ops for the litellm /
litellmrouter / bedrock providers -- the proxy-routing providers where
custom headers (auditing, policy, request-tracing) matter most. The
create_llm_provider docstring even noted "other providers may opt in as
needed"; this opts the LiteLLM-backed providers in.

Forward the configured headers to litellm.acompletion via the extra_headers
kwarg, mirroring the existing Anthropic default_headers wiring. setdefault
keeps any explicit per-call extra_headers authoritative, and the dict is
defensively copied on construction and per call to avoid cross-request
contamination. LiteLLMRouterLLM inherits this through its **kwargs forward
to the shared LiteLLM base.

Adds regression tests covering storage, the acompletion extra_headers path,
the no-headers omission, router forwarding, and copy-isolation.

Closes #2458

* fix(llm): forward default_headers from LiteLLM Router call path

The Router subclass overrides _build_common_kwargs without calling super(),
so stored default_headers never reached acompletion for the litellmrouter
provider. Inject extra_headers in the override too, and replace the
storage-only router test with call()-driven coverage.

* style: apply ruff format to migrations.py (pre-existing lint drift)

Newer ruff collapses two multi-line log strings that now fit the line
length. The file was byte-identical to main; this brings it in sync with
the lint gate so verify-generated-files passes.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-30 10:52:26 +02:00
Evoandr266-tech 2c47b8b0d5 docs: document SDK version and MCP metadata helpers (#2291)
Co-authored-by: r266-tech <[email protected]>
2026-06-30 10:51:58 +02:00
Nicolò BoschiandChris Latimer 00968a1ce4 fix(retain): preserve exception message in fact_extraction error summary (#2468)
`test_extraction_failure_at_retry_cap_fails_terminally` (added in #2418,
guarding the recovered-worker path from #2413) asserts that when fact
extraction fails terminally, the original exception message survives
into `async_operations.error_message` so an operator can tell apart a
structured-JSON parse failure from a rate-limit reset from a network
5xx — all of which can surface as the same exception types in different
code paths.

The formatter was joining only `type(err).__name__`, producing rows like
"chunk 0: RuntimeError". The exception message was discarded, leaving
worker failures unactionable and silently defeating the test. The test
ran for the first time on this branch (its original PR's test-api job
was skipped) and surfaced the bug.

Add the message to the summary: "chunk 0: RuntimeError: structured JSON
parse failed after all retain_extract_facts attempts". Same shape, just
the field the test was added to enforce.

Drive-by: pre-existing, unrelated to the include_entity_links work in
this PR — but the test is wired in now and CI won't go green without it.

Co-authored-by: Chris Latimer <[email protected]>
2026-06-30 10:48:39 +02:00
EvoandNicolò Boschi b7080a16cf fix(llm-trace): stash litellm tool-call usage so token cost survives arg-parse failures (#2444)
* fix(llm-trace): stash litellm tool-call usage so token cost survives arg-parse failures (completes #2396)

* test(llm-trace): cover litellm tool-call arg-parse usage stash

Add a real-provider regression test for the fix in this PR: the existing
wrapper-level tools test uses a provider that already stashes, so it does
not guard LiteLLMLLM.call_with_tools. This drives the real provider with a
billed response whose tool arguments are malformed JSON and asserts the
error trace keeps the provider-reported tokens (input/output/cached). The
LiteLLMRouterLLM subclass inherits call_with_tools, so it is covered too.

Verified it fails (input_tokens=None) when the stash line is removed.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-30 10:46:23 +02:00
Nicolò Boschi c0aed313f4 fix(clients): thread recall min_scores through the maintained TypeScript SDK wrapper (#2467) 2026-06-30 10:39:22 +02:00
Nicolò Boschi 2c53629420 release(langgraph): v0.3.0 2026-06-30 10:30:12 +02:00
Parafee41andNicolò Boschi 760bfc7447 fix(langgraph): resolve tool bank IDs from config (#2443)
* fix(langgraph): resolve tool bank IDs from config

* review(langgraph): rename injected config param to avoid shadowing

Rename the injected RunnableConfig tool parameter to runnable_config so it
no longer shadows the outer Hindsight config = get_config().

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-30 10:28:54 +02:00
Evo cce0a2cb39 fix(clients): thread recall min_scores through the maintained Python SDK wrapper (#2446)
#2422 added the public RecallRequest.min_scores (per-stage score floors) to
the HTTP/MCP API and the generated clients, but the hand-maintained
high-level Python wrapper (hindsight_client.recall/arecall) never got it, so
high-level SDK users can't use the feature without dropping to the raw
generated client.

Thread an optional min_scores dict through recall()/arecall() into
RecallRequest, mirroring the existing tag_groups dict->from_dict pattern.
Unknown keys raise ValueError so a misspelled floor fails loud instead of
silently applying no filter. Parity test mirrors
tests/test_recall_prefer_observations.py.

Follow-up to #2422.
2026-06-30 10:23:39 +02:00
Parafee41 1c1cf4ce56 fix(consolidation): default missing dedup action to keep (#2454)
* fix(consolidation): default missing dedup action to keep

* sync generated test formatting
2026-06-30 10:19:48 +02:00
Sanderhoff-alt a99a1ebf9b chore(docs): sync hindsight docs skill references (#2461)
Update generated hindsight-docs skill references with Requesty provider
entries that are already present in the source documentation.

This keeps the generated skill bundle in sync with the docs generator so
pre-commit no longer rewrites these files.
2026-06-30 10:18:32 +02:00
Sanderhoff-alt 12a6739fc9 refactor(extensions): centralize operation names (#2419)
Add PrecheckOperation, BankReadOperation, and BankWriteOperation
StrEnum types for operation validator hook contexts. Use them at
every precheck and validate_bank_read/write call site while
preserving string comparison compatibility for existing extensions.

Tests:
- uv run pytest tests/test_extensions.py -q
- ./scripts/hooks/lint.sh
2026-06-30 10:18:20 +02:00
Sanderhoff-alt d8ee10a78d chore(repo): remove playwright debug artifacts (#2460)
Remove accidentally committed Playwright MCP logs, page snapshots, and
root-level screenshot artifacts.

Ignore future Playwright MCP output so local browser debugging does not
show up as repository changes.
2026-06-30 10:18:05 +02:00
Evo ab01144b26 fix(config): thread groq/openai service_tier into constructed LLM providers (#2438)
HINDSIGHT_API_LLM_GROQ_SERVICE_TIER (default "auto") and
HINDSIGHT_API_LLM_OPENAI_SERVICE_TIER (OpenAI Flex, "50% cheaper") are parsed
into HindsightConfig but were never threaded into any constructed LLM provider.
The per-operation LLMConfig builds in memory_engine.py and LLMProvider.from_env
thread bedrock_service_tier and gemini_service_tier from config, but omitted
groq/openai, so setting either knob was a silent no-op. groq is the default
provider, so the cost-tier control was dead on the default path.

The constructor already accepts both fields and the providers already consume
them (gated on provider == "groq"/"openai"), so this only wires the missing
feed-in from config, mirroring the existing bedrock/gemini lines.
2026-06-30 10:16:59 +02:00
Evo 072b3278ba fix(config): thread groq/openai service_tier into constructed LLM providers (#2438)
HINDSIGHT_API_LLM_GROQ_SERVICE_TIER (default "auto") and
HINDSIGHT_API_LLM_OPENAI_SERVICE_TIER (OpenAI Flex, "50% cheaper") are parsed
into HindsightConfig but were never threaded into any constructed LLM provider.
The per-operation LLMConfig builds in memory_engine.py and LLMProvider.from_env
thread bedrock_service_tier and gemini_service_tier from config, but omitted
groq/openai, so setting either knob was a silent no-op. groq is the default
provider, so the cost-tier control was dead on the default path.

The constructor already accepts both fields and the providers already consume
them (gated on provider == "groq"/"openai"), so this only wires the missing
feed-in from config, mirroring the existing bedrock/gemini lines.
2026-06-30 10:14:56 +02:00
Nicolò Boschi 74e82a3ea8 fix(parsers): handle UTF-8 text files with ASCII prefix in markitdown (#2456)
markitdown samples only the first chunk for charset detection, so a UTF-8
file (e.g. a JSON transcript) with a long ASCII-only prefix is mis-detected
as ASCII. Its JSON/ipynb converter then reads the whole file with the wrong
charset and crashes on the first multibyte byte during converter selection,
before the plain-text converter can run.

Pass an explicit UTF-8 charset hint to markitdown for text-like files whose
bytes are valid UTF-8, sidestepping the faulty detection. Binary and genuinely
non-UTF-8 files fall back to markitdown's own detection.
2026-06-30 10:14:27 +02:00
Evo a5b752a983 docs(api): correct stale memory-type taxonomy in published READMEs (#2447)
The top feature bullet of both primary published packages (hindsight-api
and hindsight-api-slim) says 'World facts, bank actions, and formed
opinions', but the live recall taxonomy is world/experience/observation:
'opinion' was removed (recall now 422-rejects it) and 'bank' was renamed
to 'experience'. Sync the bullet to VALID_RECALL_FACT_TYPES so the first
thing a PyPI/GitHub visitor reads matches the actual API contract.

Scoped to the taxonomy bullet only; opinion *formation* as a behavior is
unchanged.
2026-06-30 10:12:53 +02:00
Parafee41 7b878f89a7 fix(retain): clarify fact type boundary for user rules (#2440)
* clarify retain fact type boundary

* sync generated test formatting
2026-06-30 10:08:55 +02:00
Evo 1b92c8230f docs(api): drop removed 'opinion' fact_type from MemoryFact schema description (#2439)
The `MemoryFact.fact_type` field description still advertises 'opinion' as a
valid value, but it was removed from the fact-type enum: the DB CheckConstraint
and VALID_RECALL_FACT_TYPES now allow only 'world', 'experience', and
'observation', and the API hard-rejects 'opinion'. An SDK/API consumer reading
the response schema is misled into thinking 'opinion' is a real fact_type.

Drop 'opinion' so the schema description matches what the API actually returns
and accepts. Follow-up to the opinion-fact-type cleanup in #2198/#2302/#2335.
2026-06-30 10:08:09 +02:00
Parafee41 0178d91333 docs: fix operation image alt text (#2436)
* docs: fix operation image alt text

* Sync linted slim tests
2026-06-30 10:07:06 +02:00
Evoandr266-tech 017b8d7271 Respect vector extension during migration bootstrap (#2426)
Co-authored-by: r266-tech <[email protected]>
2026-06-30 10:06:29 +02:00
qxxaaandNicolò Boschi 21176f8ee8 Fix(consolidation): populate search_vector on observation INSERT/UPDATE in consolidator (#2425)
* fix: populate search_vector on observation INSERT/UPDATE in consolidator

The consolidator creates and updates observations without populating the
search_vector tsvector column. Under the native text search extension,
this means observations are invisible to BM25 full-text retrieval - the
BM25 arm returns 0 candidates regardless of query content.

Four code paths write observation text to memory_units:
1. _dedup_reconcile_create (merge into existing twin)
2. _dedup_reconcile_update (drift-merge into different twin)
3. _execute_update_action (LLM rewrite of existing observation)
4. _create_observation_directly (new observation INSERT)

None populated search_vector. This patch adds conditional tsvector
generation gated on config.text_search_extension == 'native', matching
the existing pattern in ops_postgresql.insert_facts_batch. Non-native
backends (pg_textsearch, pgroonga, pg_search) continue to leave
search_vector NULL as they index base text columns directly.

The INSERT path (Site 4) splits the existing else branch into an
explicit elif/else to avoid applying native tsvector logic to backends
that don't use it.

Fixes: observations invisible to BM25 retrieval arm.

* Implement test for search vector population in observations

Add test for observation creation with native search vector

* style: run lint

* fix(consolidation): backfill search_vector for existing native observations

The writer fix only populates search_vector for observations created or
updated after deploy. Observations already written under the native
backend keep a NULL search_vector and stay invisible to BM25 until
re-consolidated. Add migration c3f7a1b9d2e4 to backfill them, gated on the
native tsvector column type and scoped to fact_type='observation' with a
NULL search_vector (idempotent). Matches the writer's text-only tsvector
and the configured native language.

* chore: remove accidentally committed git-lfs hooks

post-checkout/post-commit/post-merge/pre-push were git-lfs stubs picked
up from the contributor's local hookspath and committed by mistake. They
are unrelated to this change; the project's real .githooks/pre-commit is
left intact.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-30 10:05:11 +02:00
Ben 74bdfc9475 Blog: Entity resolution in agent memory (#2424)
* Add entity resolution deep-dive blog post

Technical deep-dive on entity resolution in agent memory, grounded in
Hindsight's implementation: name similarity + a co-occurrence graph +
temporal recency (no embeddings/LLM for resolution), the 0.6 merge
threshold, and the conservative-merge design.
2026-06-29 14:22:04 -04:00
555 changed files with 24494 additions and 34058 deletions
+11
View File
@@ -10,6 +10,17 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# Reasoning effort for providers/models that support it. Examples: low, medium, high, xhigh.
# HINDSIGHT_API_LLM_REASONING_EFFORT=low
# Sampling temperature for internal LLM calls. Set a number in [0.0, 2.0], or `none`
# to omit the temperature parameter entirely (required for models that reject explicit
# temperatures, e.g. Azure gpt-5.5). The global override below applies to every operation;
# per-operation overrides (defaults: verification=0.0, retain=0.1, reflect=0.9,
# consolidation=0.0) take precedence.
# HINDSIGHT_API_LLM_TEMPERATURE=none
# HINDSIGHT_API_LLM_TEMPERATURE_VERIFICATION=0.0
# HINDSIGHT_API_LLM_TEMPERATURE_RETAIN=0.1
# HINDSIGHT_API_LLM_TEMPERATURE_REFLECT=0.9
# HINDSIGHT_API_LLM_TEMPERATURE_CONSOLIDATION=0.0
# Example: Anthropic Claude configuration
# HINDSIGHT_API_LLM_PROVIDER=anthropic
# HINDSIGHT_API_LLM_API_KEY=your-anthropic-api-key
+1
View File
@@ -6,6 +6,7 @@ dist/
wheels/
*.egg-info
.mcp.json
.playwright-mcp/
.osgrep
# Virtual environments
.venv
@@ -1,101 +0,0 @@
[ 2810ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 2864ms] [LOG] [HMR] connected @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 3193ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 3194ms] [LOG] [Fast Refresh] done in 244ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 3195ms] [WARNING] Image with src "http://localhost:9999/logo.png" has either width or height modified, but not the other. If you use CSS to change the size of your image, also include the styles 'width: "auto"' or 'height: "auto"' to maintain the aspect ratio. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 3296ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 3749ms] [LOG] [Fast Refresh] done in 553ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 3847ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:9999/api/version:0
[ 3848ms] [ERROR] Error loading features: Error: Failed to get version
at ControlPlaneClient.fetchApi (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:219:31)
at async FeaturesProvider.useEffect.loadFeatures (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:1039:42) @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 3894ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:9999/api/banks:0
[ 3894ms] [ERROR] Error loading banks: Error: Failed to fetch banks
at ControlPlaneClient.fetchApi (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:219:31)
at async loadBanks (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:924:30) @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 4149ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:9999/api/stats/locomo-demo-v3/memories-timeseries?period=7d&time_field=created_at:0
[ 4149ms] [ERROR] Error loading memories timeseries: Error: Failed to fetch memories timeseries
at ControlPlaneClient.fetchApi (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:219:31)
at async loadTimeseries (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-stats-view_tsx_09_9kz8._.js:1530:26) @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 4204ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:9999/api/operations/locomo-demo-v3?limit=10&exclude_parents=true:0
[ 4204ms] [ERROR] Error loading operations: Error: Failed to fetch operations
at ControlPlaneClient.fetchApi (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:219:31)
at async BankOperationsView.useCallback[loadOperations] (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-operations-view_tsx_1xto18i._.js:378:33) @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 4251ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:9999/api/stats/locomo-demo-v3:0
[ 4251ms] [ERROR] Error loading bank stats: Error: Failed to fetch stats
at ControlPlaneClient.fetchApi (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:219:31)
at async Promise.all (index 0)
at async loadData (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-stats-view_tsx_09_9kz8._.js:1515:51) @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 4305ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:9999/api/banks/locomo-demo-v3/mental-models:0
[ 4350ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:9999/api/version:0
[ 4411ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:9999/api/stats/locomo-demo-v3:0
[ 4471ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:9999/api/profile/locomo-demo-v3:0
[ 4526ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:9999/api/banks/locomo-demo-v3/directives:0
[ 8276ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:9999/api/stats/locomo-demo-v3:0
[ 8277ms] [ERROR] Error loading bank stats: Error: Failed to fetch stats
at ControlPlaneClient.fetchApi (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:219:31)
at async Promise.all (index 0)
at async loadData (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-stats-view_tsx_09_9kz8._.js:1515:51) @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 8298ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 8413ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:9999/api/banks/locomo-demo-v3/mental-models:0
[ 8419ms] [LOG] [Fast Refresh] done in 223ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 8453ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:9999/api/stats/locomo-demo-v3/memories-timeseries?period=7d&time_field=created_at:0
[ 8454ms] [ERROR] Error loading memories timeseries: Error: Failed to fetch memories timeseries
at ControlPlaneClient.fetchApi (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:219:31)
at async loadTimeseries (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-stats-view_tsx_09_9kz8._.js:1530:26) @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 8570ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 8627ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:9999/api/operations/locomo-demo-v3?limit=10&exclude_parents=true:0
[ 8628ms] [ERROR] Error loading operations: Error: Failed to fetch operations
at ControlPlaneClient.fetchApi (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:219:31)
at async BankOperationsView.useCallback[loadOperations] (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-operations-view_tsx_1xto18i._.js:378:33) @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 8681ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:9999/api/banks/locomo-demo-v3/directives:0
[ 8681ms] [ERROR] Error refreshing stats: Error: Failed to list directives
at ControlPlaneClient.fetchApi (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:219:31)
at async Promise.all (index 1)
at async loadData (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-profile-view_tsx_046c-92._.js:126:53) @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 8687ms] [LOG] [Fast Refresh] done in 217ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 8797ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 8891ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:9999/api/stats/locomo-demo-v3:0
[ 8898ms] [LOG] [Fast Refresh] done in 201ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 13292ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 13306ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:9999/api/stats/locomo-demo-v3:0
[ 13307ms] [ERROR] Error loading bank stats: Error: Failed to fetch stats
at ControlPlaneClient.fetchApi (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:219:31)
at async Promise.all (index 0)
at async loadData (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-stats-view_tsx_09_9kz8._.js:1515:51) @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 13409ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:9999/api/banks/locomo-demo-v3/mental-models:0
[ 13413ms] [LOG] [Fast Refresh] done in 221ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 13515ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:9999/api/stats/locomo-demo-v3/memories-timeseries?period=7d&time_field=created_at:0
[ 13515ms] [ERROR] Error loading memories timeseries: Error: Failed to fetch memories timeseries
at ControlPlaneClient.fetchApi (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:219:31)
at async loadTimeseries (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-stats-view_tsx_09_9kz8._.js:1530:26) @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 13557ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:9999/api/operations/locomo-demo-v3?limit=10&exclude_parents=true:0
[ 13558ms] [ERROR] Error loading operations: Error: Failed to fetch operations
at ControlPlaneClient.fetchApi (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:219:31)
at async BankOperationsView.useCallback[loadOperations] (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-operations-view_tsx_1xto18i._.js:378:33) @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 13622ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:9999/api/banks/locomo-demo-v3/directives:0
[ 13624ms] [ERROR] Error refreshing stats: Error: Failed to list directives
at ControlPlaneClient.fetchApi (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:219:31)
at async Promise.all (index 1)
at async loadData (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-profile-view_tsx_046c-92._.js:126:53) @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 13664ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:9999/api/stats/locomo-demo-v3:0
[ 18234ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:9999/api/stats/locomo-demo-v3:0
[ 18235ms] [ERROR] Error loading bank stats: Error: Failed to fetch stats
at ControlPlaneClient.fetchApi (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:219:31)
at async Promise.all (index 0)
at async loadData (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-stats-view_tsx_09_9kz8._.js:1515:51) @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 18270ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:9999/api/banks/locomo-demo-v3/mental-models:0
[ 18313ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:9999/api/stats/locomo-demo-v3/memories-timeseries?period=7d&time_field=created_at:0
[ 18313ms] [ERROR] Error loading memories timeseries: Error: Failed to fetch memories timeseries
at ControlPlaneClient.fetchApi (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:219:31)
at async loadTimeseries (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-stats-view_tsx_09_9kz8._.js:1530:26) @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 18360ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:9999/api/operations/locomo-demo-v3?limit=10&exclude_parents=true:0
[ 18360ms] [ERROR] Error loading operations: Error: Failed to fetch operations
at ControlPlaneClient.fetchApi (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:219:31)
at async BankOperationsView.useCallback[loadOperations] (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-operations-view_tsx_1xto18i._.js:378:33) @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 18393ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:9999/api/banks/locomo-demo-v3/directives:0
[ 18394ms] [ERROR] Error refreshing stats: Error: Failed to list directives
at ControlPlaneClient.fetchApi (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:219:31)
at async Promise.all (index 1)
at async loadData (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-profile-view_tsx_046c-92._.js:126:53) @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 18436ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:9999/api/stats/locomo-demo-v3:0
@@ -1,13 +0,0 @@
[ 97ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 110ms] [LOG] [HMR] connected @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 256ms] [WARNING] Image with src "http://localhost:9999/logo.png" has either width or height modified, but not the other. If you use CSS to change the size of your image, also include the styles 'width: "auto"' or 'height: "auto"' to maintain the aspect ratio. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 287ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:9999/api/banks:0
[ 287ms] [ERROR] Error loading banks: Error: Failed to fetch banks
at ControlPlaneClient.fetchApi (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:219:31)
at async loadBanks (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:924:30) @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 370ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:9999/api/version:0
[ 371ms] [ERROR] Error loading features: Error: Failed to get version
at ControlPlaneClient.fetchApi (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:219:31)
at async FeaturesProvider.useEffect.loadFeatures (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:1039:42) @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 450ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:9999/api/version:0
[ 518ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:9999/api/graph?bank_id=locomo-demo-v3&type=world&limit=1000:0
@@ -1,3 +0,0 @@
[ 128ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 143ms] [LOG] [HMR] connected @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 290ms] [WARNING] Image with src "http://localhost:9999/logo.png" has either width or height modified, but not the other. If you use CSS to change the size of your image, also include the styles 'width: "auto"' or 'height: "auto"' to maintain the aspect ratio. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
@@ -1,7 +0,0 @@
[ 93ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 103ms] [LOG] [HMR] connected @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 242ms] [WARNING] Image with src "http://localhost:9999/logo.png" has either width or height modified, but not the other. If you use CSS to change the size of your image, also include the styles 'width: "auto"' or 'height: "auto"' to maintain the aspect ratio. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 160582ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 160633ms] [LOG] [Fast Refresh] done in 90ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 167220ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 167249ms] [LOG] [Fast Refresh] done in 70ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
@@ -1,317 +0,0 @@
[ 177ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 187ms] [LOG] [HMR] connected @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 356ms] [WARNING] Image with src "http://localhost:9999/logo.png" has either width or height modified, but not the other. If you use CSS to change the size of your image, also include the styles 'width: "auto"' or 'height: "auto"' to maintain the aspect ratio. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 197479ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 198209ms] [ERROR] IntlError: MISSING_MESSAGE: Could not resolve `nav.bank.copyName` in messages for locale `en`.
at getFallbackFromErrorAndNotify (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3595:23)
at translateBaseFn (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3625:28)
at translateFn (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3653:24)
at eval (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-selector_tsx_1264hts._.js?id=%255Bproject%255D%252Fhindsight-control-plane%252Fsrc%252Fcomponents%252Fbank-selector.tsx+%255Bapp-client%255D+%2528ecmascript%2529:585:87)
at Array.map (<anonymous>)
at BankSelectorInner (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-selector_tsx_1264hts._.js?id=%255Bproject%255D%252Fhindsight-control-plane%252Fsrc%252Fcomponents%252Fbank-selector.tsx+%255Bapp-client%255D+%2528ecmascript%2529:539:71)
at Object.react_stack_bottom_frame (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:15037:24)
at renderWithHooks (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:4620:24)
at updateFunctionComponent (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:6081:21)
at beginWork (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:6691:24)
at runWithFiberInDEV (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:965:74)
at performUnitOfWork (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9555:97)
at workLoopSync (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9449:40)
at renderRootSync (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9433:13)
at performWorkOnRoot (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9061:186)
at performSyncWorkOnRoot (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:10263:9)
at flushSyncWorkAcrossRoots_impl (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:10179:316)
at flushSyncWork$1 (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9230:86)
at Object.scheduleRefresh (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:299:13)
at http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:391:33
at Set.forEach (<anonymous>)
at Object.performReactRefresh (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:384:38)
at applyUpdate (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:878:31)
at http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:886:13 @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 198213ms] [ERROR] IntlError: MISSING_MESSAGE: Could not resolve `nav.bank.copyName` in messages for locale `en`.
at getFallbackFromErrorAndNotify (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3595:23)
at translateBaseFn (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3625:28)
at translateFn (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3653:24)
at eval (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-selector_tsx_1264hts._.js?id=%255Bproject%255D%252Fhindsight-control-plane%252Fsrc%252Fcomponents%252Fbank-selector.tsx+%255Bapp-client%255D+%2528ecmascript%2529:586:80)
at Array.map (<anonymous>)
at BankSelectorInner (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-selector_tsx_1264hts._.js?id=%255Bproject%255D%252Fhindsight-control-plane%252Fsrc%252Fcomponents%252Fbank-selector.tsx+%255Bapp-client%255D+%2528ecmascript%2529:539:71)
at Object.react_stack_bottom_frame (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:15037:24)
at renderWithHooks (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:4620:24)
at updateFunctionComponent (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:6081:21)
at beginWork (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:6691:24)
at runWithFiberInDEV (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:965:74)
at performUnitOfWork (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9555:97)
at workLoopSync (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9449:40)
at renderRootSync (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9433:13)
at performWorkOnRoot (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9061:186)
at performSyncWorkOnRoot (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:10263:9)
at flushSyncWorkAcrossRoots_impl (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:10179:316)
at flushSyncWork$1 (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9230:86)
at Object.scheduleRefresh (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:299:13)
at http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:391:33
at Set.forEach (<anonymous>)
at Object.performReactRefresh (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:384:38)
at applyUpdate (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:878:31)
at http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:886:13 @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 198213ms] [ERROR] IntlError: MISSING_MESSAGE: Could not resolve `nav.bank.copyName` in messages for locale `en`.
at getFallbackFromErrorAndNotify (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3595:23)
at translateBaseFn (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3625:28)
at translateFn (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3653:24)
at eval (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-selector_tsx_1264hts._.js?id=%255Bproject%255D%252Fhindsight-control-plane%252Fsrc%252Fcomponents%252Fbank-selector.tsx+%255Bapp-client%255D+%2528ecmascript%2529:585:87)
at Array.map (<anonymous>)
at BankSelectorInner (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-selector_tsx_1264hts._.js?id=%255Bproject%255D%252Fhindsight-control-plane%252Fsrc%252Fcomponents%252Fbank-selector.tsx+%255Bapp-client%255D+%2528ecmascript%2529:539:71)
at Object.react_stack_bottom_frame (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:15037:24)
at renderWithHooks (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:4620:24)
at updateFunctionComponent (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:6081:21)
at beginWork (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:6691:24)
at runWithFiberInDEV (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:965:74)
at performUnitOfWork (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9555:97)
at workLoopSync (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9449:40)
at renderRootSync (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9433:13)
at performWorkOnRoot (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9061:186)
at performSyncWorkOnRoot (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:10263:9)
at flushSyncWorkAcrossRoots_impl (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:10179:316)
at flushSyncWork$1 (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9230:86)
at Object.scheduleRefresh (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:299:13)
at http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:391:33
at Set.forEach (<anonymous>)
at Object.performReactRefresh (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:384:38)
at applyUpdate (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:878:31)
at http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:886:13 @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 198213ms] [ERROR] IntlError: MISSING_MESSAGE: Could not resolve `nav.bank.copyName` in messages for locale `en`.
at getFallbackFromErrorAndNotify (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3595:23)
at translateBaseFn (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3625:28)
at translateFn (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3653:24)
at eval (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-selector_tsx_1264hts._.js?id=%255Bproject%255D%252Fhindsight-control-plane%252Fsrc%252Fcomponents%252Fbank-selector.tsx+%255Bapp-client%255D+%2528ecmascript%2529:586:80)
at Array.map (<anonymous>)
at BankSelectorInner (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-selector_tsx_1264hts._.js?id=%255Bproject%255D%252Fhindsight-control-plane%252Fsrc%252Fcomponents%252Fbank-selector.tsx+%255Bapp-client%255D+%2528ecmascript%2529:539:71)
at Object.react_stack_bottom_frame (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:15037:24)
at renderWithHooks (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:4620:24)
at updateFunctionComponent (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:6081:21)
at beginWork (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:6691:24)
at runWithFiberInDEV (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:965:74)
at performUnitOfWork (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9555:97)
at workLoopSync (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9449:40)
at renderRootSync (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9433:13)
at performWorkOnRoot (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9061:186)
at performSyncWorkOnRoot (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:10263:9)
at flushSyncWorkAcrossRoots_impl (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:10179:316)
at flushSyncWork$1 (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9230:86)
at Object.scheduleRefresh (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:299:13)
at http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:391:33
at Set.forEach (<anonymous>)
at Object.performReactRefresh (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:384:38)
at applyUpdate (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:878:31)
at http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:886:13 @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 198213ms] [ERROR] IntlError: MISSING_MESSAGE: Could not resolve `nav.bank.copyName` in messages for locale `en`.
at getFallbackFromErrorAndNotify (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3595:23)
at translateBaseFn (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3625:28)
at translateFn (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3653:24)
at eval (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-selector_tsx_1264hts._.js?id=%255Bproject%255D%252Fhindsight-control-plane%252Fsrc%252Fcomponents%252Fbank-selector.tsx+%255Bapp-client%255D+%2528ecmascript%2529:585:87)
at Array.map (<anonymous>)
at BankSelectorInner (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-selector_tsx_1264hts._.js?id=%255Bproject%255D%252Fhindsight-control-plane%252Fsrc%252Fcomponents%252Fbank-selector.tsx+%255Bapp-client%255D+%2528ecmascript%2529:539:71)
at Object.react_stack_bottom_frame (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:15037:24)
at renderWithHooks (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:4620:24)
at updateFunctionComponent (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:6081:21)
at beginWork (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:6691:24)
at runWithFiberInDEV (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:965:74)
at performUnitOfWork (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9555:97)
at workLoopSync (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9449:40)
at renderRootSync (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9433:13)
at performWorkOnRoot (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9061:186)
at performSyncWorkOnRoot (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:10263:9)
at flushSyncWorkAcrossRoots_impl (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:10179:316)
at flushSyncWork$1 (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9230:86)
at Object.scheduleRefresh (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:299:13)
at http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:391:33
at Set.forEach (<anonymous>)
at Object.performReactRefresh (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:384:38)
at applyUpdate (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:878:31)
at http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:886:13 @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 198213ms] [ERROR] IntlError: MISSING_MESSAGE: Could not resolve `nav.bank.copyName` in messages for locale `en`.
at getFallbackFromErrorAndNotify (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3595:23)
at translateBaseFn (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3625:28)
at translateFn (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3653:24)
at eval (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-selector_tsx_1264hts._.js?id=%255Bproject%255D%252Fhindsight-control-plane%252Fsrc%252Fcomponents%252Fbank-selector.tsx+%255Bapp-client%255D+%2528ecmascript%2529:586:80)
at Array.map (<anonymous>)
at BankSelectorInner (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-selector_tsx_1264hts._.js?id=%255Bproject%255D%252Fhindsight-control-plane%252Fsrc%252Fcomponents%252Fbank-selector.tsx+%255Bapp-client%255D+%2528ecmascript%2529:539:71)
at Object.react_stack_bottom_frame (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:15037:24)
at renderWithHooks (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:4620:24)
at updateFunctionComponent (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:6081:21)
at beginWork (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:6691:24)
at runWithFiberInDEV (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:965:74)
at performUnitOfWork (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9555:97)
at workLoopSync (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9449:40)
at renderRootSync (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9433:13)
at performWorkOnRoot (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9061:186)
at performSyncWorkOnRoot (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:10263:9)
at flushSyncWorkAcrossRoots_impl (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:10179:316)
at flushSyncWork$1 (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9230:86)
at Object.scheduleRefresh (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:299:13)
at http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:391:33
at Set.forEach (<anonymous>)
at Object.performReactRefresh (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:384:38)
at applyUpdate (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:878:31)
at http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:886:13 @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 198215ms] [ERROR] IntlError: MISSING_MESSAGE: Could not resolve `nav.bank.copyName` in messages for locale `en`.
at getFallbackFromErrorAndNotify (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3595:23)
at translateBaseFn (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3625:28)
at translateFn (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3653:24)
at eval (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-selector_tsx_1264hts._.js?id=%255Bproject%255D%252Fhindsight-control-plane%252Fsrc%252Fcomponents%252Fbank-selector.tsx+%255Bapp-client%255D+%2528ecmascript%2529:585:87)
at Array.map (<anonymous>)
at BankSelectorInner (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-selector_tsx_1264hts._.js?id=%255Bproject%255D%252Fhindsight-control-plane%252Fsrc%252Fcomponents%252Fbank-selector.tsx+%255Bapp-client%255D+%2528ecmascript%2529:539:71)
at Object.react_stack_bottom_frame (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:15037:24)
at renderWithHooksAgain (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:4675:24)
at renderWithHooks (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:4626:28)
at updateFunctionComponent (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:6081:21)
at beginWork (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:6691:24)
at runWithFiberInDEV (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:965:74)
at performUnitOfWork (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9555:97)
at workLoopSync (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9449:40)
at renderRootSync (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9433:13)
at performWorkOnRoot (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9061:186)
at performSyncWorkOnRoot (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:10263:9)
at flushSyncWorkAcrossRoots_impl (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:10179:316)
at flushSyncWork$1 (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9230:86)
at Object.scheduleRefresh (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:299:13)
at http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:391:33
at Set.forEach (<anonymous>)
at Object.performReactRefresh (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:384:38)
at applyUpdate (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:878:31)
at http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:886:13 @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 198215ms] [ERROR] IntlError: MISSING_MESSAGE: Could not resolve `nav.bank.copyName` in messages for locale `en`.
at getFallbackFromErrorAndNotify (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3595:23)
at translateBaseFn (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3625:28)
at translateFn (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3653:24)
at eval (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-selector_tsx_1264hts._.js?id=%255Bproject%255D%252Fhindsight-control-plane%252Fsrc%252Fcomponents%252Fbank-selector.tsx+%255Bapp-client%255D+%2528ecmascript%2529:586:80)
at Array.map (<anonymous>)
at BankSelectorInner (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-selector_tsx_1264hts._.js?id=%255Bproject%255D%252Fhindsight-control-plane%252Fsrc%252Fcomponents%252Fbank-selector.tsx+%255Bapp-client%255D+%2528ecmascript%2529:539:71)
at Object.react_stack_bottom_frame (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:15037:24)
at renderWithHooksAgain (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:4675:24)
at renderWithHooks (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:4626:28)
at updateFunctionComponent (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:6081:21)
at beginWork (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:6691:24)
at runWithFiberInDEV (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:965:74)
at performUnitOfWork (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9555:97)
at workLoopSync (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9449:40)
at renderRootSync (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9433:13)
at performWorkOnRoot (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9061:186)
at performSyncWorkOnRoot (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:10263:9)
at flushSyncWorkAcrossRoots_impl (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:10179:316)
at flushSyncWork$1 (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9230:86)
at Object.scheduleRefresh (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:299:13)
at http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:391:33
at Set.forEach (<anonymous>)
at Object.performReactRefresh (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:384:38)
at applyUpdate (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:878:31)
at http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:886:13 @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 198215ms] [ERROR] IntlError: MISSING_MESSAGE: Could not resolve `nav.bank.copyName` in messages for locale `en`.
at getFallbackFromErrorAndNotify (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3595:23)
at translateBaseFn (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3625:28)
at translateFn (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3653:24)
at eval (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-selector_tsx_1264hts._.js?id=%255Bproject%255D%252Fhindsight-control-plane%252Fsrc%252Fcomponents%252Fbank-selector.tsx+%255Bapp-client%255D+%2528ecmascript%2529:585:87)
at Array.map (<anonymous>)
at BankSelectorInner (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-selector_tsx_1264hts._.js?id=%255Bproject%255D%252Fhindsight-control-plane%252Fsrc%252Fcomponents%252Fbank-selector.tsx+%255Bapp-client%255D+%2528ecmascript%2529:539:71)
at Object.react_stack_bottom_frame (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:15037:24)
at renderWithHooksAgain (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:4675:24)
at renderWithHooks (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:4626:28)
at updateFunctionComponent (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:6081:21)
at beginWork (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:6691:24)
at runWithFiberInDEV (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:965:74)
at performUnitOfWork (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9555:97)
at workLoopSync (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9449:40)
at renderRootSync (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9433:13)
at performWorkOnRoot (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9061:186)
at performSyncWorkOnRoot (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:10263:9)
at flushSyncWorkAcrossRoots_impl (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:10179:316)
at flushSyncWork$1 (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9230:86)
at Object.scheduleRefresh (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:299:13)
at http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:391:33
at Set.forEach (<anonymous>)
at Object.performReactRefresh (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:384:38)
at applyUpdate (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:878:31)
at http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:886:13 @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 198215ms] [ERROR] IntlError: MISSING_MESSAGE: Could not resolve `nav.bank.copyName` in messages for locale `en`.
at getFallbackFromErrorAndNotify (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3595:23)
at translateBaseFn (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3625:28)
at translateFn (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3653:24)
at eval (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-selector_tsx_1264hts._.js?id=%255Bproject%255D%252Fhindsight-control-plane%252Fsrc%252Fcomponents%252Fbank-selector.tsx+%255Bapp-client%255D+%2528ecmascript%2529:586:80)
at Array.map (<anonymous>)
at BankSelectorInner (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-selector_tsx_1264hts._.js?id=%255Bproject%255D%252Fhindsight-control-plane%252Fsrc%252Fcomponents%252Fbank-selector.tsx+%255Bapp-client%255D+%2528ecmascript%2529:539:71)
at Object.react_stack_bottom_frame (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:15037:24)
at renderWithHooksAgain (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:4675:24)
at renderWithHooks (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:4626:28)
at updateFunctionComponent (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:6081:21)
at beginWork (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:6691:24)
at runWithFiberInDEV (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:965:74)
at performUnitOfWork (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9555:97)
at workLoopSync (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9449:40)
at renderRootSync (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9433:13)
at performWorkOnRoot (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9061:186)
at performSyncWorkOnRoot (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:10263:9)
at flushSyncWorkAcrossRoots_impl (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:10179:316)
at flushSyncWork$1 (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9230:86)
at Object.scheduleRefresh (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:299:13)
at http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:391:33
at Set.forEach (<anonymous>)
at Object.performReactRefresh (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:384:38)
at applyUpdate (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:878:31)
at http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:886:13 @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 198215ms] [ERROR] IntlError: MISSING_MESSAGE: Could not resolve `nav.bank.copyName` in messages for locale `en`.
at getFallbackFromErrorAndNotify (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3595:23)
at translateBaseFn (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3625:28)
at translateFn (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3653:24)
at eval (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-selector_tsx_1264hts._.js?id=%255Bproject%255D%252Fhindsight-control-plane%252Fsrc%252Fcomponents%252Fbank-selector.tsx+%255Bapp-client%255D+%2528ecmascript%2529:585:87)
at Array.map (<anonymous>)
at BankSelectorInner (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-selector_tsx_1264hts._.js?id=%255Bproject%255D%252Fhindsight-control-plane%252Fsrc%252Fcomponents%252Fbank-selector.tsx+%255Bapp-client%255D+%2528ecmascript%2529:539:71)
at Object.react_stack_bottom_frame (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:15037:24)
at renderWithHooksAgain (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:4675:24)
at renderWithHooks (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:4626:28)
at updateFunctionComponent (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:6081:21)
at beginWork (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:6691:24)
at runWithFiberInDEV (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:965:74)
at performUnitOfWork (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9555:97)
at workLoopSync (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9449:40)
at renderRootSync (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9433:13)
at performWorkOnRoot (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9061:186)
at performSyncWorkOnRoot (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:10263:9)
at flushSyncWorkAcrossRoots_impl (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:10179:316)
at flushSyncWork$1 (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9230:86)
at Object.scheduleRefresh (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:299:13)
at http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:391:33
at Set.forEach (<anonymous>)
at Object.performReactRefresh (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:384:38)
at applyUpdate (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:878:31)
at http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:886:13 @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 198216ms] [ERROR] IntlError: MISSING_MESSAGE: Could not resolve `nav.bank.copyName` in messages for locale `en`.
at getFallbackFromErrorAndNotify (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3595:23)
at translateBaseFn (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3625:28)
at translateFn (http://localhost:9999/_next/static/chunks/node_modules_1ytpq7s._.js:3653:24)
at eval (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-selector_tsx_1264hts._.js?id=%255Bproject%255D%252Fhindsight-control-plane%252Fsrc%252Fcomponents%252Fbank-selector.tsx+%255Bapp-client%255D+%2528ecmascript%2529:586:80)
at Array.map (<anonymous>)
at BankSelectorInner (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_components_bank-selector_tsx_1264hts._.js?id=%255Bproject%255D%252Fhindsight-control-plane%252Fsrc%252Fcomponents%252Fbank-selector.tsx+%255Bapp-client%255D+%2528ecmascript%2529:539:71)
at Object.react_stack_bottom_frame (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:15037:24)
at renderWithHooksAgain (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:4675:24)
at renderWithHooks (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:4626:28)
at updateFunctionComponent (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:6081:21)
at beginWork (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:6691:24)
at runWithFiberInDEV (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:965:74)
at performUnitOfWork (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9555:97)
at workLoopSync (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9449:40)
at renderRootSync (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9433:13)
at performWorkOnRoot (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9061:186)
at performSyncWorkOnRoot (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:10263:9)
at flushSyncWorkAcrossRoots_impl (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:10179:316)
at flushSyncWork$1 (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:9230:86)
at Object.scheduleRefresh (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js:299:13)
at http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:391:33
at Set.forEach (<anonymous>)
at Object.performReactRefresh (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:384:38)
at applyUpdate (http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:878:31)
at http://localhost:9999/_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js:886:13 @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[ 198220ms] [LOG] [Fast Refresh] done in 824ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 198368ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 198424ms] [LOG] [Fast Refresh] done in 158ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 198686ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 198784ms] [LOG] [Fast Refresh] done in 199ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 249494ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 249512ms] [LOG] [Fast Refresh] done in 75ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
@@ -1,256 +0,0 @@
[ 915ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 923ms] [LOG] [HMR] connected @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 1064ms] [WARNING] Image with src "http://localhost:9999/logo.png" has either width or height modified, but not the other. If you use CSS to change the size of your image, also include the styles 'width: "auto"' or 'height: "auto"' to maintain the aspect ratio. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 41527ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[ 41548ms] [LOG] [Fast Refresh] done in 75ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[245534553ms] [LOG] [HMR] connected @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[245534675ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[245534692ms] [LOG] [Fast Refresh] done in 118ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[250969674ms] [LOG] [HMR] connected @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[254160733ms] [LOG] [HMR] connected @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[254160869ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[254160894ms] [LOG] [Fast Refresh] done in 126ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[258259347ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[258260528ms] [LOG] [Fast Refresh] done in 1288ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[258723297ms] [LOG] [HMR] connected @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[258775713ms] [LOG] [HMR] connected @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[261264735ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[261269535ms] [LOG] [Fast Refresh] done in 4906ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[261546917ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[261547880ms] [LOG] [Fast Refresh] done in 1134ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[261663747ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[261664233ms] [LOG] [Fast Refresh] done in 587ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[342782138ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[342783538ms] [WARNING] [Fast Refresh] performing full reload
Fast Refresh will perform a full reload when you edit a file that's imported by modules outside of the React rendering tree.
You might have a file which exports a React component but also exports a value that is imported by a non-React component file.
Consider migrating the non-React component export to a separate file and importing it into both files.
It is also possible the parent component of the component you edited is a class component, which disables Fast Refresh.
Fast Refresh requires at least one parent function component in your React tree. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[342784058ms] [LOG] [Fast Refresh] done in 1772ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[342784780ms] [WARNING] [Fast Refresh] performing full reload
Fast Refresh will perform a full reload when you edit a file that's imported by modules outside of the React rendering tree.
You might have a file which exports a React component but also exports a value that is imported by a non-React component file.
Consider migrating the non-React component export to a separate file and importing it into both files.
It is also possible the parent component of the component you edited is a class component, which disables Fast Refresh.
Fast Refresh requires at least one parent function component in your React tree. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[342785284ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[342785307ms] [LOG] [HMR] connected @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[342785496ms] [WARNING] Image with src "http://localhost:9999/logo.png" has either width or height modified, but not the other. If you use CSS to change the size of your image, also include the styles 'width: "auto"' or 'height: "auto"' to maintain the aspect ratio. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[343047107ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[343048273ms] [WARNING] [Fast Refresh] performing full reload
Fast Refresh will perform a full reload when you edit a file that's imported by modules outside of the React rendering tree.
You might have a file which exports a React component but also exports a value that is imported by a non-React component file.
Consider migrating the non-React component export to a separate file and importing it into both files.
It is also possible the parent component of the component you edited is a class component, which disables Fast Refresh.
Fast Refresh requires at least one parent function component in your React tree. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[343048852ms] [LOG] [Fast Refresh] done in 1255ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[343048891ms] [WARNING] [Fast Refresh] performing full reload
Fast Refresh will perform a full reload when you edit a file that's imported by modules outside of the React rendering tree.
You might have a file which exports a React component but also exports a value that is imported by a non-React component file.
Consider migrating the non-React component export to a separate file and importing it into both files.
It is also possible the parent component of the component you edited is a class component, which disables Fast Refresh.
Fast Refresh requires at least one parent function component in your React tree. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[343049198ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[343049742ms] [LOG] [Fast Refresh] done in 645ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[343049828ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[343049841ms] [LOG] [HMR] connected @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[343049993ms] [WARNING] Image with src "http://localhost:9999/logo.png" has either width or height modified, but not the other. If you use CSS to change the size of your image, also include the styles 'width: "auto"' or 'height: "auto"' to maintain the aspect ratio. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[345632147ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[345633682ms] [WARNING] [Fast Refresh] performing full reload
Fast Refresh will perform a full reload when you edit a file that's imported by modules outside of the React rendering tree.
You might have a file which exports a React component but also exports a value that is imported by a non-React component file.
Consider migrating the non-React component export to a separate file and importing it into both files.
It is also possible the parent component of the component you edited is a class component, which disables Fast Refresh.
Fast Refresh requires at least one parent function component in your React tree. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[345633938ms] [LOG] [Fast Refresh] done in 1702ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[345633963ms] [WARNING] [Fast Refresh] performing full reload
Fast Refresh will perform a full reload when you edit a file that's imported by modules outside of the React rendering tree.
You might have a file which exports a React component but also exports a value that is imported by a non-React component file.
Consider migrating the non-React component export to a separate file and importing it into both files.
It is also possible the parent component of the component you edited is a class component, which disables Fast Refresh.
Fast Refresh requires at least one parent function component in your React tree. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[345634647ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[345634678ms] [LOG] [HMR] connected @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[345634837ms] [WARNING] Image with src "http://localhost:9999/logo.png" has either width or height modified, but not the other. If you use CSS to change the size of your image, also include the styles 'width: "auto"' or 'height: "auto"' to maintain the aspect ratio. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[345684130ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[345685275ms] [WARNING] [Fast Refresh] performing full reload
Fast Refresh will perform a full reload when you edit a file that's imported by modules outside of the React rendering tree.
You might have a file which exports a React component but also exports a value that is imported by a non-React component file.
Consider migrating the non-React component export to a separate file and importing it into both files.
It is also possible the parent component of the component you edited is a class component, which disables Fast Refresh.
Fast Refresh requires at least one parent function component in your React tree. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[345685434ms] [LOG] [Fast Refresh] done in 1243ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[345685484ms] [WARNING] [Fast Refresh] performing full reload
Fast Refresh will perform a full reload when you edit a file that's imported by modules outside of the React rendering tree.
You might have a file which exports a React component but also exports a value that is imported by a non-React component file.
Consider migrating the non-React component export to a separate file and importing it into both files.
It is also possible the parent component of the component you edited is a class component, which disables Fast Refresh.
Fast Refresh requires at least one parent function component in your React tree. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[345685649ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[345685690ms] [LOG] [Fast Refresh] done in 142ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[345686169ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[345686179ms] [LOG] [HMR] connected @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[345686314ms] [WARNING] Image with src "http://localhost:9999/logo.png" has either width or height modified, but not the other. If you use CSS to change the size of your image, also include the styles 'width: "auto"' or 'height: "auto"' to maintain the aspect ratio. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[345686333ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[345686379ms] [LOG] [Fast Refresh] done in 146ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[357996647ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[357998178ms] [WARNING] [Fast Refresh] performing full reload
Fast Refresh will perform a full reload when you edit a file that's imported by modules outside of the React rendering tree.
You might have a file which exports a React component but also exports a value that is imported by a non-React component file.
Consider migrating the non-React component export to a separate file and importing it into both files.
It is also possible the parent component of the component you edited is a class component, which disables Fast Refresh.
Fast Refresh requires at least one parent function component in your React tree. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[357998523ms] [LOG] [Fast Refresh] done in 1726ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[357998605ms] [WARNING] [Fast Refresh] performing full reload
Fast Refresh will perform a full reload when you edit a file that's imported by modules outside of the React rendering tree.
You might have a file which exports a React component but also exports a value that is imported by a non-React component file.
Consider migrating the non-React component export to a separate file and importing it into both files.
It is also possible the parent component of the component you edited is a class component, which disables Fast Refresh.
Fast Refresh requires at least one parent function component in your React tree. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[357999819ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[357999851ms] [LOG] [HMR] connected @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[358000036ms] [WARNING] Image with src "http://localhost:9999/logo.png" has either width or height modified, but not the other. If you use CSS to change the size of your image, also include the styles 'width: "auto"' or 'height: "auto"' to maintain the aspect ratio. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[358003608ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:9999/api/banks:0
[358003608ms] [ERROR] Error loading banks: Error: HTTP 500
at ControlPlaneClient.fetchApi (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:228:31)
at async loadBanks (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:933:30) @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[358003695ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:9999/api/graph?bank_id=cluster-demo&type=observation&limit=1000:0
[358003736ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:9999/api/banks/cluster-demo/observations/scopes:0
[358007258ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:9999/api/banks/cluster-demo/observations/scopes:0
[358308031ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[358309424ms] [WARNING] [Fast Refresh] performing full reload
Fast Refresh will perform a full reload when you edit a file that's imported by modules outside of the React rendering tree.
You might have a file which exports a React component but also exports a value that is imported by a non-React component file.
Consider migrating the non-React component export to a separate file and importing it into both files.
It is also possible the parent component of the component you edited is a class component, which disables Fast Refresh.
Fast Refresh requires at least one parent function component in your React tree. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[358309434ms] [LOG] [Fast Refresh] done in 1494ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[358309664ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[358310163ms] [LOG] [Fast Refresh] done in 601ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[358310217ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[358310226ms] [LOG] [HMR] connected @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[358310366ms] [WARNING] Image with src "http://localhost:9999/logo.png" has either width or height modified, but not the other. If you use CSS to change the size of your image, also include the styles 'width: "auto"' or 'height: "auto"' to maintain the aspect ratio. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[358310395ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[358310402ms] [LOG] [Fast Refresh] done in 109ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[358313929ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:9999/api/banks:0
[358313930ms] [ERROR] Error loading banks: Error: Failed to fetch banks
at ControlPlaneClient.fetchApi (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:219:31)
at async loadBanks (http://localhost:9999/_next/static/chunks/hindsight-control-plane_src_09ldbjk._.js:924:30) @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:3318
[358314000ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:9999/api/graph?bank_id=cluster-demo&type=observation&limit=1000:0
[358314029ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:9999/api/banks/cluster-demo/observations/scopes:0
[358317549ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:9999/api/banks/cluster-demo/observations/scopes:0
[430163774ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[430165839ms] [WARNING] [Fast Refresh] performing full reload
Fast Refresh will perform a full reload when you edit a file that's imported by modules outside of the React rendering tree.
You might have a file which exports a React component but also exports a value that is imported by a non-React component file.
Consider migrating the non-React component export to a separate file and importing it into both files.
It is also possible the parent component of the component you edited is a class component, which disables Fast Refresh.
Fast Refresh requires at least one parent function component in your React tree. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[430165884ms] [LOG] [Fast Refresh] done in 2220ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[430165884ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[430166287ms] [LOG] [Fast Refresh] done in 117ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[430167720ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[430167745ms] [LOG] [HMR] connected @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[430167941ms] [WARNING] Image with src "http://localhost:9999/logo.png" has either width or height modified, but not the other. If you use CSS to change the size of your image, also include the styles 'width: "auto"' or 'height: "auto"' to maintain the aspect ratio. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[430167941ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[430168007ms] [LOG] [Fast Refresh] done in 198ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[430168269ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[430168278ms] [LOG] [Fast Refresh] done in 111ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[430168514ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[430168519ms] [LOG] [Fast Refresh] done in 106ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[430245962ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[430247979ms] [WARNING] [Fast Refresh] performing full reload
Fast Refresh will perform a full reload when you edit a file that's imported by modules outside of the React rendering tree.
You might have a file which exports a React component but also exports a value that is imported by a non-React component file.
Consider migrating the non-React component export to a separate file and importing it into both files.
It is also possible the parent component of the component you edited is a class component, which disables Fast Refresh.
Fast Refresh requires at least one parent function component in your React tree. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[430248692ms] [WARNING] [Fast Refresh] performing full reload
Fast Refresh will perform a full reload when you edit a file that's imported by modules outside of the React rendering tree.
You might have a file which exports a React component but also exports a value that is imported by a non-React component file.
Consider migrating the non-React component export to a separate file and importing it into both files.
It is also possible the parent component of the component you edited is a class component, which disables Fast Refresh.
Fast Refresh requires at least one parent function component in your React tree. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[430248710ms] [LOG] [Fast Refresh] done in 2832ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[430252503ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[430252672ms] [LOG] [Fast Refresh] done in 270ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[430252866ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[430252942ms] [LOG] [HMR] connected @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[430253185ms] [WARNING] Image with src "http://localhost:9999/logo.png" has either width or height modified, but not the other. If you use CSS to change the size of your image, also include the styles 'width: "auto"' or 'height: "auto"' to maintain the aspect ratio. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[430253287ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[430253424ms] [LOG] [Fast Refresh] done in 238ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[431414177ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[431417650ms] [WARNING] [Fast Refresh] performing full reload
Fast Refresh will perform a full reload when you edit a file that's imported by modules outside of the React rendering tree.
You might have a file which exports a React component but also exports a value that is imported by a non-React component file.
Consider migrating the non-React component export to a separate file and importing it into both files.
It is also possible the parent component of the component you edited is a class component, which disables Fast Refresh.
Fast Refresh requires at least one parent function component in your React tree. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[431423427ms] [WARNING] [Fast Refresh] performing full reload
Fast Refresh will perform a full reload when you edit a file that's imported by modules outside of the React rendering tree.
You might have a file which exports a React component but also exports a value that is imported by a non-React component file.
Consider migrating the non-React component export to a separate file and importing it into both files.
It is also possible the parent component of the component you edited is a class component, which disables Fast Refresh.
Fast Refresh requires at least one parent function component in your React tree. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[431423463ms] [LOG] [Fast Refresh] done in 9359ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[431423725ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[431423755ms] [LOG] [Fast Refresh] done in 131ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[431424487ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[431424746ms] [LOG] [HMR] connected @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[431425112ms] [WARNING] Image with src "http://localhost:9999/logo.png" has either width or height modified, but not the other. If you use CSS to change the size of your image, also include the styles 'width: "auto"' or 'height: "auto"' to maintain the aspect ratio. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[431425235ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[431425478ms] [LOG] [Fast Refresh] done in 343ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[431426290ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[431426379ms] [LOG] [Fast Refresh] done in 190ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[431427470ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[431427485ms] [LOG] [Fast Refresh] done in 116ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[432835456ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[432835589ms] [LOG] [Fast Refresh] done in 246ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[432835840ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[432838224ms] [WARNING] [Fast Refresh] performing full reload
Fast Refresh will perform a full reload when you edit a file that's imported by modules outside of the React rendering tree.
You might have a file which exports a React component but also exports a value that is imported by a non-React component file.
Consider migrating the non-React component export to a separate file and importing it into both files.
It is also possible the parent component of the component you edited is a class component, which disables Fast Refresh.
Fast Refresh requires at least one parent function component in your React tree. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[432838319ms] [LOG] [Fast Refresh] done in 2480ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[432839972ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[432839994ms] [LOG] [HMR] connected @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[432840167ms] [WARNING] Image with src "http://localhost:9999/logo.png" has either width or height modified, but not the other. If you use CSS to change the size of your image, also include the styles 'width: "auto"' or 'height: "auto"' to maintain the aspect ratio. @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[432933812ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
[432934449ms] [LOG] [Fast Refresh] done in 740ms @ http://localhost:9999/_next/static/chunks/node_modules_next_dist_1ybzpk2._.js:2477
File diff suppressed because it is too large Load Diff
@@ -1,93 +0,0 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- generic [ref=e4]:
- img "Hindsight" [ref=e5]
- combobox [ref=e7]:
- generic [ref=e8]: locomo-demo-v3
- img
- button "Add Document" [ref=e10]:
- img
- generic [ref=e11]: Add Document
- link "GitHub" [ref=e12] [cursor=pointer]:
- /url: https://github.com/vectorize-io/hindsight
- img [ref=e13]
- generic [ref=e16]: GitHub
- button "Switch to dark mode" [ref=e18]:
- img
- button "Change language" [ref=e19]:
- img
- generic [ref=e20]:
- complementary [ref=e21]:
- navigation [ref=e22]:
- list [ref=e23]:
- listitem [ref=e24]:
- link "Memories" [ref=e25] [cursor=pointer]:
- /url: /banks/locomo-demo-v3?view=data
- img [ref=e26]
- listitem [ref=e30]:
- link "Recall" [ref=e31] [cursor=pointer]:
- /url: /banks/locomo-demo-v3?view=recall
- img [ref=e32]
- listitem [ref=e35]:
- link "Reflect" [ref=e36] [cursor=pointer]:
- /url: /banks/locomo-demo-v3?view=reflect
- img [ref=e37]
- listitem [ref=e40]:
- link "Documents" [ref=e41] [cursor=pointer]:
- /url: /banks/locomo-demo-v3?view=documents
- img [ref=e42]
- listitem [ref=e45]:
- link "Entities" [ref=e46] [cursor=pointer]:
- /url: /banks/locomo-demo-v3?view=entities
- img [ref=e47]
- listitem [ref=e52]:
- link "Bank Configuration" [ref=e53] [cursor=pointer]:
- /url: /banks/locomo-demo-v3?view=profile
- img [ref=e54]
- button "Expand sidebar" [ref=e58]:
- img [ref=e59]
- main [ref=e61]:
- generic [ref=e63]:
- generic [ref=e64]:
- generic [ref=e65]:
- heading "Bank Configuration" [level=1] [ref=e66]
- paragraph [ref=e67]: Manage bank settings, profile, and operations.
- button "Actions" [ref=e68]:
- text: Actions
- img
- generic [ref=e70]:
- button "General" [ref=e71]: General
- button "Webhooks" [ref=e73]
- button "Audit LogsOff" [ref=e74]
- button "LLM RequestsOff" [ref=e75]
- generic [ref=e77]:
- paragraph [ref=e78]: Overview statistics and background operations for this memory bank.
- generic [ref=e79]:
- img [ref=e81]
- generic [ref=e84]:
- generic [ref=e85]:
- generic [ref=e86]:
- generic [ref=e87]:
- heading "Background Operations" [level=3] [ref=e88]
- button "Refresh operations" [disabled] [ref=e89]:
- img [ref=e90]
- paragraph [ref=e95]: 0 operations
- generic [ref=e96]:
- combobox [ref=e97]:
- generic:
- generic:
- generic: All types
- img [ref=e98]
- generic [ref=e100]:
- button "All" [ref=e101]
- button "Pending" [ref=e102]
- button "Processing" [ref=e103]
- button "Completed" [ref=e104]
- button "Failed" [ref=e105]
- button "Cancelled" [ref=e106]
- paragraph [ref=e108]: No operations
- generic [ref=e110]:
- img [ref=e111]
- generic [ref=e114]: Loading profile...
- region "Notifications alt+T"
- alert [ref=e115]
@@ -1,66 +0,0 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- generic [ref=e4]:
- img "Hindsight" [ref=e5]
- combobox [ref=e7]:
- generic [ref=e8]: locomo-demo-v3
- img
- button "Add Document" [ref=e10]:
- img
- generic [ref=e11]: Add Document
- link "GitHub" [ref=e12] [cursor=pointer]:
- /url: https://github.com/vectorize-io/hindsight
- img [ref=e13]
- generic [ref=e16]: GitHub
- button "Switch to dark mode" [ref=e18]:
- img
- button "Change language" [ref=e19]:
- img
- generic [ref=e20]:
- complementary [ref=e21]:
- navigation [ref=e22]:
- list [ref=e23]:
- listitem [ref=e24]:
- link "Memories" [ref=e25] [cursor=pointer]:
- /url: /banks/locomo-demo-v3?view=data
- img [ref=e26]
- listitem [ref=e30]:
- link "Recall" [ref=e31] [cursor=pointer]:
- /url: /banks/locomo-demo-v3?view=recall
- img [ref=e32]
- listitem [ref=e35]:
- link "Reflect" [ref=e36] [cursor=pointer]:
- /url: /banks/locomo-demo-v3?view=reflect
- img [ref=e37]
- listitem [ref=e40]:
- link "Documents" [ref=e41] [cursor=pointer]:
- /url: /banks/locomo-demo-v3?view=documents
- img [ref=e42]
- listitem [ref=e45]:
- link "Entities" [ref=e46] [cursor=pointer]:
- /url: /banks/locomo-demo-v3?view=entities
- img [ref=e47]
- listitem [ref=e52]:
- link "Bank Configuration" [ref=e53] [cursor=pointer]:
- /url: /banks/locomo-demo-v3?view=profile
- img [ref=e54]
- button "Expand sidebar" [ref=e58]:
- img [ref=e59]
- main [ref=e61]:
- generic [ref=e63]:
- heading "Memories" [level=1] [ref=e64]
- paragraph [ref=e65]: View and explore different types of memories stored in this memory bank.
- generic [ref=e67]:
- button "World Facts" [ref=e68]: World Facts
- button "Experience" [ref=e70]
- button "ObservationsOff" [ref=e71]
- button "Mental Models" [ref=e72]
- generic [ref=e74]:
- paragraph [ref=e75]: Objective facts about the world received from external sources.
- generic [ref=e77]:
- img [ref=e78]
- paragraph [ref=e83]: Loading memories...
- region "Notifications alt+T"
- button "Open Next.js Dev Tools" [ref=e89] [cursor=pointer]:
- img [ref=e90]
- alert [ref=e93]
@@ -1,66 +0,0 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- generic [ref=e4]:
- img "Hindsight" [ref=e5]
- combobox [ref=e7]:
- generic [ref=e8]: cluster-demo
- img
- button "Add Document" [ref=e10]:
- img
- generic [ref=e11]: Add Document
- link "GitHub" [ref=e12] [cursor=pointer]:
- /url: https://github.com/vectorize-io/hindsight
- img [ref=e13]
- generic [ref=e16]: GitHub
- button "Switch to dark mode" [ref=e18]:
- img
- button "Change language" [ref=e19]:
- img
- generic [ref=e20]:
- complementary [ref=e21]:
- navigation [ref=e22]:
- list [ref=e23]:
- listitem [ref=e24]:
- link "Memories" [ref=e25] [cursor=pointer]:
- /url: /banks/cluster-demo?view=data
- img [ref=e26]
- listitem [ref=e30]:
- link "Recall" [ref=e31] [cursor=pointer]:
- /url: /banks/cluster-demo?view=recall
- img [ref=e32]
- listitem [ref=e35]:
- link "Reflect" [ref=e36] [cursor=pointer]:
- /url: /banks/cluster-demo?view=reflect
- img [ref=e37]
- listitem [ref=e40]:
- link "Documents" [ref=e41] [cursor=pointer]:
- /url: /banks/cluster-demo?view=documents
- img [ref=e42]
- listitem [ref=e45]:
- link "Entities" [ref=e46] [cursor=pointer]:
- /url: /banks/cluster-demo?view=entities
- img [ref=e47]
- listitem [ref=e52]:
- link "Bank Configuration" [ref=e53] [cursor=pointer]:
- /url: /banks/cluster-demo?view=profile
- img [ref=e54]
- button "Expand sidebar" [ref=e58]:
- img [ref=e59]
- main [ref=e61]:
- generic [ref=e63]:
- heading "Memories" [level=1] [ref=e64]
- paragraph [ref=e65]: View and explore different types of memories stored in this memory bank.
- generic [ref=e67]:
- button "World Facts" [ref=e68]: World Facts
- button "Experience" [ref=e70]
- button "ObservationsOff" [ref=e71]
- button "Mental Models" [ref=e72]
- generic [ref=e74]:
- paragraph [ref=e75]: Objective facts about the world received from external sources.
- generic [ref=e77]:
- img [ref=e78]
- paragraph [ref=e83]: Loading memories...
- region "Notifications alt+T"
- button "Open Next.js Dev Tools" [ref=e89] [cursor=pointer]:
- img [ref=e90]
- alert [ref=e93]
@@ -1,113 +0,0 @@
- generic [ref=e1]:
- generic [ref=e2]:
- generic [ref=e4]:
- img "Hindsight" [ref=e5]
- combobox [ref=e7]:
- generic [ref=e8]: cluster-demo
- img
- button "Add Document" [ref=e10]:
- img
- generic [ref=e11]: Add Document
- link "GitHub" [ref=e12] [cursor=pointer]:
- /url: https://github.com/vectorize-io/hindsight
- img [ref=e13]
- generic [ref=e16]: GitHub
- button "Switch to dark mode" [ref=e18]:
- img
- button "Change language" [ref=e19]:
- img
- generic [ref=e20]:
- complementary [ref=e21]:
- navigation [ref=e22]:
- list [ref=e23]:
- listitem [ref=e24]:
- link "Memories" [ref=e25] [cursor=pointer]:
- /url: /banks/cluster-demo?view=data
- img [ref=e26]
- listitem [ref=e30]:
- link "Recall" [ref=e31] [cursor=pointer]:
- /url: /banks/cluster-demo?view=recall
- img [ref=e32]
- listitem [ref=e35]:
- link "Reflect" [ref=e36] [cursor=pointer]:
- /url: /banks/cluster-demo?view=reflect
- img [ref=e37]
- listitem [ref=e40]:
- link "Documents" [ref=e41] [cursor=pointer]:
- /url: /banks/cluster-demo?view=documents
- img [ref=e42]
- listitem [ref=e45]:
- link "Entities" [ref=e46] [cursor=pointer]:
- /url: /banks/cluster-demo?view=entities
- img [ref=e47]
- listitem [ref=e52]:
- link "Bank Configuration" [ref=e53] [cursor=pointer]:
- /url: /banks/cluster-demo?view=profile
- img [ref=e54]
- button "Expand sidebar" [ref=e58]:
- img [ref=e59]
- main [ref=e61]:
- generic [ref=e63]:
- heading "Memories" [level=1] [ref=e64]
- paragraph [ref=e65]: View and explore different types of memories stored in this memory bank.
- generic [ref=e67]:
- button "World Facts" [ref=e68]
- button "Experience" [active] [ref=e70]: Experience
- button "Observations" [ref=e94]
- button "Mental Models" [ref=e72]
- generic [ref=e170]:
- paragraph [ref=e171]: The bank's own actions, interactions, and first-person experiences.
- generic [ref=e172]:
- generic [ref=e174]:
- generic [ref=e175]:
- img
- textbox "Filter by text or context (press Enter)..." [ref=e176]
- generic [ref=e178]:
- img
- textbox "Filter by tag…" [ref=e179]
- generic [ref=e180]:
- generic [ref=e182]: 1 total memories
- generic [ref=e183]:
- button "Constellation" [ref=e184]:
- img [ref=e185]
- text: Constellation
- button "Graph" [ref=e192]:
- img [ref=e193]
- text: Graph
- button "Table" [ref=e198]:
- img [ref=e199]
- text: Table
- button "Timeline" [ref=e200]:
- img [ref=e201]
- text: Timeline
- generic [ref=e203]:
- generic [ref=e207]:
- button "Share" [ref=e208] [cursor=pointer]:
- img [ref=e209]
- text: Share
- button "Fullscreen" [ref=e212] [cursor=pointer]:
- img [ref=e213]
- text: Fullscreen
- button "Hide panel" [ref=e218]:
- img [ref=e219]
- generic [ref=e222]:
- heading "Constellation View" [level=3] [ref=e223]
- paragraph [ref=e224]: Canvas-rendered memory map with spatial label deconfliction. Scroll to zoom, drag to pan, hover to explore entity connections. Click a memory to view details.
- generic [ref=e225]:
- heading "Color by" [level=4] [ref=e226]
- combobox [ref=e227]:
- generic: Mentioned
- img [ref=e228]
- generic [ref=e230]:
- heading "Link types" [level=4] [ref=e231]
- generic [ref=e234] [cursor=pointer]: semantic
- generic [ref=e237] [cursor=pointer]: temporal
- generic [ref=e240] [cursor=pointer]: entity
- generic [ref=e243] [cursor=pointer]: causal
- generic [ref=e244]:
- generic [ref=e245]: "Nodes: 1"
- generic [ref=e246]: "Links: 1"
- region "Notifications alt+T"
- button "Open Next.js Dev Tools" [ref=e89] [cursor=pointer]:
- img [ref=e90]
- alert [ref=e93]
@@ -1,126 +0,0 @@
- generic [ref=e1]:
- generic [ref=e2]:
- generic [ref=e4]:
- img "Hindsight" [ref=e5]
- combobox [ref=e7]:
- generic [ref=e8]: cluster-demo
- img
- button "Add Document" [ref=e10]:
- img
- generic [ref=e11]: Add Document
- link "GitHub" [ref=e12] [cursor=pointer]:
- /url: https://github.com/vectorize-io/hindsight
- img [ref=e13]
- generic [ref=e16]: GitHub
- button "Switch to dark mode" [ref=e18]:
- img
- button "Change language" [ref=e19]:
- img
- generic [ref=e20]:
- complementary [ref=e21]:
- navigation [ref=e22]:
- list [ref=e23]:
- listitem [ref=e24]:
- link "Memories" [ref=e25] [cursor=pointer]:
- /url: /banks/cluster-demo?view=data
- img [ref=e26]
- listitem [ref=e30]:
- link "Recall" [ref=e31] [cursor=pointer]:
- /url: /banks/cluster-demo?view=recall
- img [ref=e32]
- listitem [ref=e35]:
- link "Reflect" [ref=e36] [cursor=pointer]:
- /url: /banks/cluster-demo?view=reflect
- img [ref=e37]
- listitem [ref=e40]:
- link "Documents" [ref=e41] [cursor=pointer]:
- /url: /banks/cluster-demo?view=documents
- img [ref=e42]
- listitem [ref=e45]:
- link "Entities" [ref=e46] [cursor=pointer]:
- /url: /banks/cluster-demo?view=entities
- img [ref=e47]
- listitem [ref=e52]:
- link "Bank Configuration" [ref=e53] [cursor=pointer]:
- /url: /banks/cluster-demo?view=profile
- img [ref=e54]
- button "Expand sidebar" [ref=e58]:
- img [ref=e59]
- main [ref=e61]:
- generic [ref=e63]:
- heading "Memories" [level=1] [ref=e64]
- paragraph [ref=e65]: View and explore different types of memories stored in this memory bank.
- generic [ref=e67]:
- button "World Facts" [ref=e68]
- button "Experience" [ref=e70]
- button "Observations" [active] [ref=e94]: Observations
- button "Mental Models" [ref=e72]
- generic [ref=e248]:
- paragraph [ref=e249]: Consolidated knowledge synthesized from facts — patterns, preferences, and learnings that emerge from accumulated evidence.
- generic [ref=e250]:
- generic [ref=e252]:
- generic [ref=e253]:
- img
- textbox "Filter by text or context (press Enter)..." [ref=e254]
- generic [ref=e256]:
- img
- textbox "Filter by tag…" [ref=e257]
- combobox "Scope" [ref=e258]:
- img
- generic [ref=e260]: All scopes
- img
- generic [ref=e261]:
- generic [ref=e262]:
- generic [ref=e263]: 163 total memories
- 'generic "All memories consolidated (last: 6/11/2026, 5:58:22 PM)" [ref=e264]':
- img [ref=e265]
- text: In Sync
- generic [ref=e268]:
- button "Constellation" [ref=e269]:
- img [ref=e270]
- text: Constellation
- button "Graph" [ref=e277]:
- img [ref=e278]
- text: Graph
- button "Table" [ref=e283]:
- img [ref=e284]
- text: Table
- button "Timeline" [ref=e285]:
- img [ref=e286]
- text: Timeline
- generic [ref=e288]:
- generic [ref=e292]:
- button "Share" [ref=e293] [cursor=pointer]:
- img [ref=e294]
- text: Share
- button "Fullscreen" [ref=e297] [cursor=pointer]:
- img [ref=e298]
- text: Fullscreen
- button "Hide panel" [ref=e303]:
- img [ref=e304]
- generic [ref=e307]:
- heading "Constellation View" [level=3] [ref=e308]
- paragraph [ref=e309]: Canvas-rendered memory map with spatial label deconfliction. Scroll to zoom, drag to pan, hover to explore entity connections. Click a memory to view details.
- generic [ref=e310]:
- generic [ref=e311]:
- img [ref=e312]
- heading "Group by scope" [level=4] [ref=e316]
- switch [ref=e317] [cursor=pointer]
- generic [ref=e318]:
- heading "Color by" [level=4] [ref=e319]
- combobox [ref=e320]:
- generic: Mentioned
- img [ref=e321]
- generic [ref=e323]:
- heading "Link types" [level=4] [ref=e324]
- generic [ref=e327] [cursor=pointer]: semantic
- generic [ref=e330] [cursor=pointer]: temporal
- generic [ref=e333] [cursor=pointer]: entity
- generic [ref=e336] [cursor=pointer]: causal
- generic [ref=e337]:
- generic [ref=e338]: "Nodes: 163"
- generic [ref=e339]: "Links: 12563"
- region "Notifications alt+T"
- button "Open Next.js Dev Tools" [ref=e89] [cursor=pointer]:
- img [ref=e90]
- alert [ref=e93]
@@ -1,66 +0,0 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- generic [ref=e4]:
- img "Hindsight" [ref=e5]
- combobox [ref=e7]:
- generic [ref=e8]: cluster-demo
- img
- button "Add Document" [ref=e10]:
- img
- generic [ref=e11]: Add Document
- link "GitHub" [ref=e12] [cursor=pointer]:
- /url: https://github.com/vectorize-io/hindsight
- img [ref=e13]
- generic [ref=e16]: GitHub
- button "Switch to dark mode" [ref=e18]:
- img
- button "Change language" [ref=e19]:
- img
- generic [ref=e20]:
- complementary [ref=e21]:
- navigation [ref=e22]:
- list [ref=e23]:
- listitem [ref=e24]:
- link "Memories" [ref=e25] [cursor=pointer]:
- /url: /banks/cluster-demo?view=data
- img [ref=e26]
- listitem [ref=e30]:
- link "Recall" [ref=e31] [cursor=pointer]:
- /url: /banks/cluster-demo?view=recall
- img [ref=e32]
- listitem [ref=e35]:
- link "Reflect" [ref=e36] [cursor=pointer]:
- /url: /banks/cluster-demo?view=reflect
- img [ref=e37]
- listitem [ref=e40]:
- link "Documents" [ref=e41] [cursor=pointer]:
- /url: /banks/cluster-demo?view=documents
- img [ref=e42]
- listitem [ref=e45]:
- link "Entities" [ref=e46] [cursor=pointer]:
- /url: /banks/cluster-demo?view=entities
- img [ref=e47]
- listitem [ref=e52]:
- link "Bank Configuration" [ref=e53] [cursor=pointer]:
- /url: /banks/cluster-demo?view=profile
- img [ref=e54]
- button "Expand sidebar" [ref=e58]:
- img [ref=e59]
- main [ref=e61]:
- generic [ref=e63]:
- heading "Memories" [level=1] [ref=e64]
- paragraph [ref=e65]: View and explore different types of memories stored in this memory bank.
- generic [ref=e67]:
- button "World Facts" [ref=e68]
- button "Experience" [ref=e69]: Experience
- button "ObservationsOff" [ref=e71]
- button "Mental Models" [ref=e72]
- generic [ref=e74]:
- paragraph [ref=e75]: The bank's own actions, interactions, and first-person experiences.
- generic [ref=e77]:
- img [ref=e78]
- paragraph [ref=e83]: Loading memories...
- region "Notifications alt+T"
- button "Open Next.js Dev Tools" [ref=e89] [cursor=pointer]:
- img [ref=e90]
- alert [ref=e93]
@@ -1,66 +0,0 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- generic [ref=e4]:
- img "Hindsight" [ref=e5]
- combobox [ref=e7]:
- generic [ref=e8]: cluster-demo
- img
- button "Add Document" [ref=e10]:
- img
- generic [ref=e11]: Add Document
- link "GitHub" [ref=e12] [cursor=pointer]:
- /url: https://github.com/vectorize-io/hindsight
- img [ref=e13]
- generic [ref=e16]: GitHub
- button "Switch to dark mode" [ref=e18]:
- img
- button "Change language" [ref=e19]:
- img
- generic [ref=e20]:
- complementary [ref=e21]:
- navigation [ref=e22]:
- list [ref=e23]:
- listitem [ref=e24]:
- link "Memories" [ref=e25] [cursor=pointer]:
- /url: /banks/cluster-demo?view=data
- img [ref=e26]
- listitem [ref=e30]:
- link "Recall" [ref=e31] [cursor=pointer]:
- /url: /banks/cluster-demo?view=recall
- img [ref=e32]
- listitem [ref=e35]:
- link "Reflect" [ref=e36] [cursor=pointer]:
- /url: /banks/cluster-demo?view=reflect
- img [ref=e37]
- listitem [ref=e40]:
- link "Documents" [ref=e41] [cursor=pointer]:
- /url: /banks/cluster-demo?view=documents
- img [ref=e42]
- listitem [ref=e45]:
- link "Entities" [ref=e46] [cursor=pointer]:
- /url: /banks/cluster-demo?view=entities
- img [ref=e47]
- listitem [ref=e52]:
- link "Bank Configuration" [ref=e53] [cursor=pointer]:
- /url: /banks/cluster-demo?view=profile
- img [ref=e54]
- button "Expand sidebar" [ref=e58]:
- img [ref=e59]
- main [ref=e61]:
- generic [ref=e63]:
- heading "Memories" [level=1] [ref=e64]
- paragraph [ref=e65]: View and explore different types of memories stored in this memory bank.
- generic [ref=e67]:
- button "World Facts" [ref=e68]: World Facts
- button "Experience" [ref=e70]
- button "Observations" [ref=e71]
- button "Mental Models" [ref=e72]
- generic [ref=e74]:
- paragraph [ref=e75]: Objective facts about the world received from external sources.
- generic [ref=e77]:
- img [ref=e78]
- paragraph [ref=e83]: Loading memories...
- region "Notifications alt+T"
- button "Open Next.js Dev Tools" [ref=e89] [cursor=pointer]:
- img [ref=e90]
- alert [ref=e93]
@@ -1,113 +0,0 @@
- generic [ref=e1]:
- generic [ref=e2]:
- generic [ref=e4]:
- img "Hindsight" [ref=e5]
- combobox [ref=e7]:
- generic [ref=e8]: cluster-demo
- img
- button "Add Document" [ref=e10]:
- img
- generic [ref=e11]: Add Document
- link "GitHub" [ref=e12] [cursor=pointer]:
- /url: https://github.com/vectorize-io/hindsight
- img [ref=e13]
- generic [ref=e16]: GitHub
- button "Switch to dark mode" [ref=e18]:
- img
- button "Change language" [ref=e19]:
- img
- generic [ref=e20]:
- complementary [ref=e21]:
- navigation [ref=e22]:
- list [ref=e23]:
- listitem [ref=e24]:
- link "Memories" [ref=e25] [cursor=pointer]:
- /url: /banks/cluster-demo?view=data
- img [ref=e26]
- listitem [ref=e30]:
- link "Recall" [ref=e31] [cursor=pointer]:
- /url: /banks/cluster-demo?view=recall
- img [ref=e32]
- listitem [ref=e35]:
- link "Reflect" [ref=e36] [cursor=pointer]:
- /url: /banks/cluster-demo?view=reflect
- img [ref=e37]
- listitem [ref=e40]:
- link "Documents" [ref=e41] [cursor=pointer]:
- /url: /banks/cluster-demo?view=documents
- img [ref=e42]
- listitem [ref=e45]:
- link "Entities" [ref=e46] [cursor=pointer]:
- /url: /banks/cluster-demo?view=entities
- img [ref=e47]
- listitem [ref=e52]:
- link "Bank Configuration" [ref=e53] [cursor=pointer]:
- /url: /banks/cluster-demo?view=profile
- img [ref=e54]
- button "Expand sidebar" [ref=e58]:
- img [ref=e59]
- main [ref=e61]:
- generic [ref=e63]:
- heading "Memories" [level=1] [ref=e64]
- paragraph [ref=e65]: View and explore different types of memories stored in this memory bank.
- generic [ref=e67]:
- button "World Facts" [ref=e68]
- button "Experience" [active] [ref=e70]: Experience
- button "Observations" [ref=e71]
- button "Mental Models" [ref=e72]
- generic [ref=e95]:
- paragraph [ref=e96]: The bank's own actions, interactions, and first-person experiences.
- generic [ref=e97]:
- generic [ref=e99]:
- generic [ref=e100]:
- img
- textbox "Filter by text or context (press Enter)..." [ref=e101]
- generic [ref=e103]:
- img
- textbox "Filter by tag…" [ref=e104]
- generic [ref=e105]:
- generic [ref=e107]: 1 total memories
- generic [ref=e108]:
- button "Constellation" [ref=e109]:
- img [ref=e110]
- text: Constellation
- button "Graph" [ref=e117]:
- img [ref=e118]
- text: Graph
- button "Table" [ref=e123]:
- img [ref=e124]
- text: Table
- button "Timeline" [ref=e125]:
- img [ref=e126]
- text: Timeline
- generic [ref=e128]:
- generic [ref=e132]:
- button "Share" [ref=e133] [cursor=pointer]:
- img [ref=e134]
- text: Share
- button "Fullscreen" [ref=e137] [cursor=pointer]:
- img [ref=e138]
- text: Fullscreen
- button "Hide panel" [ref=e143]:
- img [ref=e144]
- generic [ref=e147]:
- heading "Constellation View" [level=3] [ref=e148]
- paragraph [ref=e149]: Canvas-rendered memory map with spatial label deconfliction. Scroll to zoom, drag to pan, hover to explore entity connections. Click a memory to view details.
- generic [ref=e150]:
- heading "Color by" [level=4] [ref=e151]
- combobox [ref=e152]:
- generic: Mentioned
- img [ref=e153]
- generic [ref=e155]:
- heading "Link types" [level=4] [ref=e156]
- generic [ref=e159] [cursor=pointer]: semantic
- generic [ref=e162] [cursor=pointer]: temporal
- generic [ref=e165] [cursor=pointer]: entity
- generic [ref=e168] [cursor=pointer]: causal
- generic [ref=e169]:
- generic [ref=e170]: "Nodes: 1"
- generic [ref=e171]: "Links: 1"
- region "Notifications alt+T"
- button "Open Next.js Dev Tools" [ref=e89] [cursor=pointer]:
- img [ref=e90]
- alert [ref=e93]
@@ -1,113 +0,0 @@
- generic [ref=e1]:
- generic [ref=e2]:
- generic [ref=e4]:
- img "Hindsight" [ref=e5]
- combobox [ref=e7]:
- generic [ref=e8]: cluster-demo
- img
- button "Add Document" [ref=e10]:
- img
- generic [ref=e11]: Add Document
- link "GitHub" [ref=e12] [cursor=pointer]:
- /url: https://github.com/vectorize-io/hindsight
- img [ref=e13]
- generic [ref=e16]: GitHub
- button "Switch to dark mode" [ref=e18]:
- img
- button "Change language" [ref=e19]:
- img
- generic [ref=e20]:
- complementary [ref=e21]:
- navigation [ref=e22]:
- list [ref=e23]:
- listitem [ref=e24]:
- link "Memories" [ref=e25] [cursor=pointer]:
- /url: /banks/cluster-demo?view=data
- img [ref=e26]
- listitem [ref=e30]:
- link "Recall" [ref=e31] [cursor=pointer]:
- /url: /banks/cluster-demo?view=recall
- img [ref=e32]
- listitem [ref=e35]:
- link "Reflect" [ref=e36] [cursor=pointer]:
- /url: /banks/cluster-demo?view=reflect
- img [ref=e37]
- listitem [ref=e40]:
- link "Documents" [ref=e41] [cursor=pointer]:
- /url: /banks/cluster-demo?view=documents
- img [ref=e42]
- listitem [ref=e45]:
- link "Entities" [ref=e46] [cursor=pointer]:
- /url: /banks/cluster-demo?view=entities
- img [ref=e47]
- listitem [ref=e52]:
- link "Bank Configuration" [ref=e53] [cursor=pointer]:
- /url: /banks/cluster-demo?view=profile
- img [ref=e54]
- button "Expand sidebar" [ref=e58]:
- img [ref=e59]
- main [ref=e61]:
- generic [ref=e63]:
- heading "Memories" [level=1] [ref=e64]
- paragraph [ref=e65]: View and explore different types of memories stored in this memory bank.
- generic [ref=e67]:
- button "World Facts" [ref=e68]
- button "Experience" [active] [ref=e70]: Experience
- button "Observations" [ref=e71]
- button "Mental Models" [ref=e72]
- generic [ref=e95]:
- paragraph [ref=e96]: The bank's own actions, interactions, and first-person experiences.
- generic [ref=e97]:
- generic [ref=e99]:
- generic [ref=e100]:
- img
- textbox "Filter by text or context (press Enter)..." [ref=e101]
- generic [ref=e103]:
- img
- textbox "Filter by tag…" [ref=e104]
- generic [ref=e105]:
- generic [ref=e107]: 1 total memories
- generic [ref=e108]:
- button "Constellation" [ref=e109]:
- img [ref=e110]
- text: Constellation
- button "Graph" [ref=e117]:
- img [ref=e118]
- text: Graph
- button "Table" [ref=e123]:
- img [ref=e124]
- text: Table
- button "Timeline" [ref=e125]:
- img [ref=e126]
- text: Timeline
- generic [ref=e128]:
- generic [ref=e132]:
- button "Share" [ref=e133] [cursor=pointer]:
- img [ref=e134]
- text: Share
- button "Fullscreen" [ref=e137] [cursor=pointer]:
- img [ref=e138]
- text: Fullscreen
- button "Hide panel" [ref=e143]:
- img [ref=e144]
- generic [ref=e147]:
- heading "Constellation View" [level=3] [ref=e148]
- paragraph [ref=e149]: Canvas-rendered memory map with spatial label deconfliction. Scroll to zoom, drag to pan, hover to explore entity connections. Click a memory to view details.
- generic [ref=e150]:
- heading "Color by" [level=4] [ref=e151]
- combobox [ref=e152]:
- generic: Mentioned
- img [ref=e153]
- generic [ref=e155]:
- heading "Link types" [level=4] [ref=e156]
- generic [ref=e159] [cursor=pointer]: semantic
- generic [ref=e162] [cursor=pointer]: temporal
- generic [ref=e165] [cursor=pointer]: entity
- generic [ref=e168] [cursor=pointer]: causal
- generic [ref=e169]:
- generic [ref=e170]: "Nodes: 1"
- generic [ref=e171]: "Links: 1"
- region "Notifications alt+T"
- button "Open Next.js Dev Tools" [ref=e89] [cursor=pointer]:
- img [ref=e90]
- alert [ref=e93]
@@ -1,126 +0,0 @@
- generic [ref=e1]:
- generic [ref=e2]:
- generic [ref=e4]:
- img "Hindsight" [ref=e5]
- combobox [ref=e7]:
- generic [ref=e8]: cluster-demo
- img
- button "Add Document" [ref=e10]:
- img
- generic [ref=e11]: Add Document
- link "GitHub" [ref=e12] [cursor=pointer]:
- /url: https://github.com/vectorize-io/hindsight
- img [ref=e13]
- generic [ref=e16]: GitHub
- button "Switch to dark mode" [ref=e18]:
- img
- button "Change language" [ref=e19]:
- img
- generic [ref=e20]:
- complementary [ref=e21]:
- navigation [ref=e22]:
- list [ref=e23]:
- listitem [ref=e24]:
- link "Memories" [ref=e25] [cursor=pointer]:
- /url: /banks/cluster-demo?view=data
- img [ref=e26]
- listitem [ref=e30]:
- link "Recall" [ref=e31] [cursor=pointer]:
- /url: /banks/cluster-demo?view=recall
- img [ref=e32]
- listitem [ref=e35]:
- link "Reflect" [ref=e36] [cursor=pointer]:
- /url: /banks/cluster-demo?view=reflect
- img [ref=e37]
- listitem [ref=e40]:
- link "Documents" [ref=e41] [cursor=pointer]:
- /url: /banks/cluster-demo?view=documents
- img [ref=e42]
- listitem [ref=e45]:
- link "Entities" [ref=e46] [cursor=pointer]:
- /url: /banks/cluster-demo?view=entities
- img [ref=e47]
- listitem [ref=e52]:
- link "Bank Configuration" [ref=e53] [cursor=pointer]:
- /url: /banks/cluster-demo?view=profile
- img [ref=e54]
- button "Expand sidebar" [ref=e58]:
- img [ref=e59]
- main [ref=e61]:
- generic [ref=e63]:
- heading "Memories" [level=1] [ref=e64]
- paragraph [ref=e65]: View and explore different types of memories stored in this memory bank.
- generic [ref=e67]:
- button "World Facts" [ref=e68]
- button "Experience" [ref=e70]
- button "Observations" [active] [ref=e71]: Observations
- button "Mental Models" [ref=e72]
- generic [ref=e173]:
- paragraph [ref=e174]: Consolidated knowledge synthesized from facts — patterns, preferences, and learnings that emerge from accumulated evidence.
- generic [ref=e175]:
- generic [ref=e177]:
- generic [ref=e178]:
- img
- textbox "Filter by text or context (press Enter)..." [ref=e179]
- generic [ref=e181]:
- img
- textbox "Filter by tag…" [ref=e182]
- combobox "Scope" [ref=e183]:
- img
- generic [ref=e185]: All scopes
- img
- generic [ref=e186]:
- generic [ref=e187]:
- generic [ref=e188]: 163 total memories
- 'generic "All memories consolidated (last: 6/11/2026, 5:58:22 PM)" [ref=e189]':
- img [ref=e190]
- text: In Sync
- generic [ref=e193]:
- button "Constellation" [ref=e194]:
- img [ref=e195]
- text: Constellation
- button "Graph" [ref=e202]:
- img [ref=e203]
- text: Graph
- button "Table" [ref=e208]:
- img [ref=e209]
- text: Table
- button "Timeline" [ref=e210]:
- img [ref=e211]
- text: Timeline
- generic [ref=e213]:
- generic [ref=e217]:
- button "Share" [ref=e218] [cursor=pointer]:
- img [ref=e219]
- text: Share
- button "Fullscreen" [ref=e222] [cursor=pointer]:
- img [ref=e223]
- text: Fullscreen
- button "Hide panel" [ref=e228]:
- img [ref=e229]
- generic [ref=e232]:
- heading "Constellation View" [level=3] [ref=e233]
- paragraph [ref=e234]: Canvas-rendered memory map with spatial label deconfliction. Scroll to zoom, drag to pan, hover to explore entity connections. Click a memory to view details.
- generic [ref=e235]:
- generic [ref=e236]:
- img [ref=e237]
- heading "Group by scope" [level=4] [ref=e241]
- switch [ref=e242] [cursor=pointer]
- generic [ref=e243]:
- heading "Color by" [level=4] [ref=e244]
- combobox [ref=e245]:
- generic: Mentioned
- img [ref=e246]
- generic [ref=e248]:
- heading "Link types" [level=4] [ref=e249]
- generic [ref=e252] [cursor=pointer]: semantic
- generic [ref=e255] [cursor=pointer]: temporal
- generic [ref=e258] [cursor=pointer]: entity
- generic [ref=e261] [cursor=pointer]: causal
- generic [ref=e262]:
- generic [ref=e263]: "Nodes: 163"
- generic [ref=e264]: "Links: 12563"
- region "Notifications alt+T"
- button "Open Next.js Dev Tools" [ref=e89] [cursor=pointer]:
- img [ref=e90]
- alert [ref=e93]
File diff suppressed because it is too large Load Diff
@@ -1,128 +0,0 @@
- generic [ref=e1]:
- generic [ref=e2]:
- generic [ref=e4]:
- img "Hindsight" [ref=e5]
- combobox [ref=e7]:
- generic [ref=e8]: cluster-demo
- img
- button "Add Document" [ref=e10]:
- img
- generic [ref=e11]: Add Document
- link "GitHub" [ref=e12] [cursor=pointer]:
- /url: https://github.com/vectorize-io/hindsight
- img [ref=e13]
- generic [ref=e16]: GitHub
- button "Switch to dark mode" [ref=e18]:
- img
- button "Change language" [ref=e19]:
- img
- generic [ref=e20]:
- complementary [ref=e21]:
- navigation [ref=e22]:
- list [ref=e23]:
- listitem [ref=e24]:
- link "Memories" [ref=e25] [cursor=pointer]:
- /url: /banks/cluster-demo?view=data
- img [ref=e26]
- listitem [ref=e30]:
- link "Recall" [ref=e31] [cursor=pointer]:
- /url: /banks/cluster-demo?view=recall
- img [ref=e32]
- listitem [ref=e35]:
- link "Reflect" [ref=e36] [cursor=pointer]:
- /url: /banks/cluster-demo?view=reflect
- img [ref=e37]
- listitem [ref=e40]:
- link "Documents" [ref=e41] [cursor=pointer]:
- /url: /banks/cluster-demo?view=documents
- img [ref=e42]
- listitem [ref=e45]:
- link "Entities" [ref=e46] [cursor=pointer]:
- /url: /banks/cluster-demo?view=entities
- img [ref=e47]
- listitem [ref=e52]:
- link "Bank Configuration" [ref=e53] [cursor=pointer]:
- /url: /banks/cluster-demo?view=profile
- img [ref=e54]
- button "Expand sidebar" [ref=e58]:
- img [ref=e59]
- main [ref=e61]:
- generic [ref=e63]:
- heading "Memories" [level=1] [ref=e64]
- paragraph [ref=e65]: View and explore different types of memories stored in this memory bank.
- generic [ref=e67]:
- button "World Facts" [ref=e68]
- button "Experience" [ref=e70]
- button "Observations" [ref=e71]: Observations
- button "Mental Models" [ref=e72]
- generic [ref=e173]:
- paragraph [ref=e174]: Consolidated knowledge synthesized from facts — patterns, preferences, and learnings that emerge from accumulated evidence.
- generic [ref=e175]:
- generic [ref=e177]:
- generic [ref=e178]:
- img
- textbox "Filter by text or context (press Enter)..." [ref=e179]
- generic [ref=e181]:
- img
- textbox "Filter by tag…" [ref=e182]
- combobox "Scope" [active] [ref=e183]:
- img
- generic [ref=e184]:
- generic [ref=e185]: "#eng"
- generic [ref=e1724]: (8)
- img
- generic [ref=e186]:
- generic [ref=e187]:
- generic [ref=e188]: 8 total memories
- 'generic "All memories consolidated (last: 6/11/2026, 5:58:22 PM)" [ref=e189]':
- img [ref=e190]
- text: In Sync
- generic [ref=e193]:
- button "Constellation" [ref=e194]:
- img [ref=e195]
- text: Constellation
- button "Graph" [ref=e202]:
- img [ref=e203]
- text: Graph
- button "Table" [ref=e208]:
- img [ref=e209]
- text: Table
- button "Timeline" [ref=e210]:
- img [ref=e211]
- text: Timeline
- generic [ref=e213]:
- generic [ref=e217]:
- button "Share" [ref=e218] [cursor=pointer]:
- img [ref=e219]
- text: Share
- button "Fullscreen" [ref=e222] [cursor=pointer]:
- img [ref=e223]
- text: Fullscreen
- button "Hide panel" [ref=e228]:
- img [ref=e229]
- generic [ref=e232]:
- heading "Constellation View" [level=3] [ref=e233]
- paragraph [ref=e234]: Canvas-rendered memory map with spatial label deconfliction. Scroll to zoom, drag to pan, hover to explore entity connections. Click a memory to view details.
- generic [ref=e235]:
- generic [ref=e236]:
- img [ref=e237]
- heading "Group by scope" [level=4] [ref=e241]
- switch [ref=e242] [cursor=pointer]
- generic [ref=e243]:
- heading "Color by" [level=4] [ref=e244]
- combobox [ref=e245]:
- generic: Mentioned
- img [ref=e246]
- generic [ref=e248]:
- heading "Link types" [level=4] [ref=e249]
- generic [ref=e252] [cursor=pointer]: semantic
- generic [ref=e255] [cursor=pointer]: temporal
- generic [ref=e258] [cursor=pointer]: entity
- generic [ref=e261] [cursor=pointer]: causal
- generic [ref=e262]:
- generic [ref=e263]: "Nodes: 8"
- generic [ref=e264]: "Links: 62"
- region "Notifications alt+T"
- button "Open Next.js Dev Tools" [ref=e89] [cursor=pointer]:
- img [ref=e90]
- alert [ref=e93]
@@ -1,66 +0,0 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- generic [ref=e4]:
- img "Hindsight" [ref=e5]
- combobox [ref=e7]:
- generic [ref=e8]: cluster-demo
- img
- button "Add Document" [ref=e10]:
- img
- generic [ref=e11]: Add Document
- link "GitHub" [ref=e12] [cursor=pointer]:
- /url: https://github.com/vectorize-io/hindsight
- img [ref=e13]
- generic [ref=e16]: GitHub
- button "Switch to dark mode" [ref=e18]:
- img
- button "Change language" [ref=e19]:
- img
- generic [ref=e20]:
- complementary [ref=e21]:
- navigation [ref=e22]:
- list [ref=e23]:
- listitem [ref=e24]:
- link "Memories" [ref=e25] [cursor=pointer]:
- /url: /banks/cluster-demo?view=data
- img [ref=e26]
- listitem [ref=e30]:
- link "Recall" [ref=e31] [cursor=pointer]:
- /url: /banks/cluster-demo?view=recall
- img [ref=e32]
- listitem [ref=e35]:
- link "Reflect" [ref=e36] [cursor=pointer]:
- /url: /banks/cluster-demo?view=reflect
- img [ref=e37]
- listitem [ref=e40]:
- link "Documents" [ref=e41] [cursor=pointer]:
- /url: /banks/cluster-demo?view=documents
- img [ref=e42]
- listitem [ref=e45]:
- link "Entities" [ref=e46] [cursor=pointer]:
- /url: /banks/cluster-demo?view=entities
- img [ref=e47]
- listitem [ref=e52]:
- link "Bank Configuration" [ref=e53] [cursor=pointer]:
- /url: /banks/cluster-demo?view=profile
- img [ref=e54]
- button "Expand sidebar" [ref=e58]:
- img [ref=e59]
- main [ref=e61]:
- generic [ref=e63]:
- heading "Memories" [level=1] [ref=e64]
- paragraph [ref=e65]: View and explore different types of memories stored in this memory bank.
- generic [ref=e67]:
- button "World Facts" [ref=e68]: World Facts
- button "Experience" [ref=e70]
- button "ObservationsOff" [ref=e71]
- button "Mental Models" [ref=e72]
- generic [ref=e74]:
- paragraph [ref=e75]: Objective facts about the world received from external sources.
- generic [ref=e77]:
- img [ref=e78]
- paragraph [ref=e83]: Loading memories...
- region "Notifications alt+T"
- button "Open Next.js Dev Tools" [ref=e89] [cursor=pointer]:
- img [ref=e90]
- alert [ref=e93]
@@ -1,113 +0,0 @@
- generic [ref=e1]:
- generic [ref=e2]:
- generic [ref=e4]:
- img "Hindsight" [ref=e5]
- combobox [ref=e7]:
- generic [ref=e8]: cluster-demo
- img
- button "Add Document" [ref=e10]:
- img
- generic [ref=e11]: Add Document
- link "GitHub" [ref=e12] [cursor=pointer]:
- /url: https://github.com/vectorize-io/hindsight
- img [ref=e13]
- generic [ref=e16]: GitHub
- button "Switch to dark mode" [ref=e18]:
- img
- button "Change language" [ref=e19]:
- img
- generic [ref=e20]:
- complementary [ref=e21]:
- navigation [ref=e22]:
- list [ref=e23]:
- listitem [ref=e24]:
- link "Memories" [ref=e25] [cursor=pointer]:
- /url: /banks/cluster-demo?view=data
- img [ref=e26]
- listitem [ref=e30]:
- link "Recall" [ref=e31] [cursor=pointer]:
- /url: /banks/cluster-demo?view=recall
- img [ref=e32]
- listitem [ref=e35]:
- link "Reflect" [ref=e36] [cursor=pointer]:
- /url: /banks/cluster-demo?view=reflect
- img [ref=e37]
- listitem [ref=e40]:
- link "Documents" [ref=e41] [cursor=pointer]:
- /url: /banks/cluster-demo?view=documents
- img [ref=e42]
- listitem [ref=e45]:
- link "Entities" [ref=e46] [cursor=pointer]:
- /url: /banks/cluster-demo?view=entities
- img [ref=e47]
- listitem [ref=e52]:
- link "Bank Configuration" [ref=e53] [cursor=pointer]:
- /url: /banks/cluster-demo?view=profile
- img [ref=e54]
- button "Expand sidebar" [ref=e58]:
- img [ref=e59]
- main [ref=e61]:
- generic [ref=e63]:
- heading "Memories" [level=1] [ref=e64]
- paragraph [ref=e65]: View and explore different types of memories stored in this memory bank.
- generic [ref=e67]:
- button "World Facts" [ref=e68]
- button "Experience" [active] [ref=e70]: Experience
- button "Observations" [ref=e94]
- button "Mental Models" [ref=e72]
- generic [ref=e170]:
- paragraph [ref=e171]: The bank's own actions, interactions, and first-person experiences.
- generic [ref=e172]:
- generic [ref=e174]:
- generic [ref=e175]:
- img
- textbox "Filter by text or context (press Enter)..." [ref=e176]
- generic [ref=e178]:
- img
- textbox "Filter by tag…" [ref=e179]
- generic [ref=e180]:
- generic [ref=e182]: 1 total memories
- generic [ref=e183]:
- button "Constellation" [ref=e184]:
- img [ref=e185]
- text: Constellation
- button "Graph" [ref=e192]:
- img [ref=e193]
- text: Graph
- button "Table" [ref=e198]:
- img [ref=e199]
- text: Table
- button "Timeline" [ref=e200]:
- img [ref=e201]
- text: Timeline
- generic [ref=e203]:
- generic [ref=e207]:
- button "Share" [ref=e208] [cursor=pointer]:
- img [ref=e209]
- text: Share
- button "Fullscreen" [ref=e212] [cursor=pointer]:
- img [ref=e213]
- text: Fullscreen
- button "Hide panel" [ref=e218]:
- img [ref=e219]
- generic [ref=e222]:
- heading "Constellation View" [level=3] [ref=e223]
- paragraph [ref=e224]: Canvas-rendered memory map with spatial label deconfliction. Scroll to zoom, drag to pan, hover to explore entity connections. Click a memory to view details.
- generic [ref=e225]:
- heading "Color by" [level=4] [ref=e226]
- combobox [ref=e227]:
- generic: Mentioned
- img [ref=e228]
- generic [ref=e230]:
- heading "Link types" [level=4] [ref=e231]
- generic [ref=e234] [cursor=pointer]: semantic
- generic [ref=e237] [cursor=pointer]: temporal
- generic [ref=e240] [cursor=pointer]: entity
- generic [ref=e243] [cursor=pointer]: causal
- generic [ref=e244]:
- generic [ref=e245]: "Nodes: 1"
- generic [ref=e246]: "Links: 1"
- region "Notifications alt+T"
- button "Open Next.js Dev Tools" [ref=e89] [cursor=pointer]:
- img [ref=e90]
- alert [ref=e93]
@@ -1,126 +0,0 @@
- generic [ref=e1]:
- generic [ref=e2]:
- generic [ref=e4]:
- img "Hindsight" [ref=e5]
- combobox [ref=e7]:
- generic [ref=e8]: cluster-demo
- img
- button "Add Document" [ref=e10]:
- img
- generic [ref=e11]: Add Document
- link "GitHub" [ref=e12] [cursor=pointer]:
- /url: https://github.com/vectorize-io/hindsight
- img [ref=e13]
- generic [ref=e16]: GitHub
- button "Switch to dark mode" [ref=e18]:
- img
- button "Change language" [ref=e19]:
- img
- generic [ref=e20]:
- complementary [ref=e21]:
- navigation [ref=e22]:
- list [ref=e23]:
- listitem [ref=e24]:
- link "Memories" [ref=e25] [cursor=pointer]:
- /url: /banks/cluster-demo?view=data
- img [ref=e26]
- listitem [ref=e30]:
- link "Recall" [ref=e31] [cursor=pointer]:
- /url: /banks/cluster-demo?view=recall
- img [ref=e32]
- listitem [ref=e35]:
- link "Reflect" [ref=e36] [cursor=pointer]:
- /url: /banks/cluster-demo?view=reflect
- img [ref=e37]
- listitem [ref=e40]:
- link "Documents" [ref=e41] [cursor=pointer]:
- /url: /banks/cluster-demo?view=documents
- img [ref=e42]
- listitem [ref=e45]:
- link "Entities" [ref=e46] [cursor=pointer]:
- /url: /banks/cluster-demo?view=entities
- img [ref=e47]
- listitem [ref=e52]:
- link "Bank Configuration" [ref=e53] [cursor=pointer]:
- /url: /banks/cluster-demo?view=profile
- img [ref=e54]
- button "Expand sidebar" [ref=e58]:
- img [ref=e59]
- main [ref=e61]:
- generic [ref=e63]:
- heading "Memories" [level=1] [ref=e64]
- paragraph [ref=e65]: View and explore different types of memories stored in this memory bank.
- generic [ref=e67]:
- button "World Facts" [ref=e68]
- button "Experience" [ref=e70]
- button "Observations" [active] [ref=e94]: Observations
- button "Mental Models" [ref=e72]
- generic [ref=e248]:
- paragraph [ref=e249]: Consolidated knowledge synthesized from facts — patterns, preferences, and learnings that emerge from accumulated evidence.
- generic [ref=e250]:
- generic [ref=e252]:
- generic [ref=e253]:
- img
- textbox "Filter by text or context (press Enter)..." [ref=e254]
- generic [ref=e256]:
- img
- textbox "Filter by tag…" [ref=e257]
- combobox "Scope" [ref=e258]:
- img
- generic [ref=e260]: All scopes
- img
- generic [ref=e261]:
- generic [ref=e262]:
- generic [ref=e263]: 163 total memories
- 'generic "All memories consolidated (last: 6/11/2026, 5:58:22 PM)" [ref=e264]':
- img [ref=e265]
- text: In Sync
- generic [ref=e268]:
- button "Constellation" [ref=e269]:
- img [ref=e270]
- text: Constellation
- button "Graph" [ref=e277]:
- img [ref=e278]
- text: Graph
- button "Table" [ref=e283]:
- img [ref=e284]
- text: Table
- button "Timeline" [ref=e285]:
- img [ref=e286]
- text: Timeline
- generic [ref=e288]:
- generic [ref=e292]:
- button "Share" [ref=e293] [cursor=pointer]:
- img [ref=e294]
- text: Share
- button "Fullscreen" [ref=e297] [cursor=pointer]:
- img [ref=e298]
- text: Fullscreen
- button "Hide panel" [ref=e303]:
- img [ref=e304]
- generic [ref=e307]:
- heading "Constellation View" [level=3] [ref=e308]
- paragraph [ref=e309]: Canvas-rendered memory map with spatial label deconfliction. Scroll to zoom, drag to pan, hover to explore entity connections. Click a memory to view details.
- generic [ref=e310]:
- generic [ref=e311]:
- img [ref=e312]
- heading "Group by scope" [level=4] [ref=e316]
- switch [ref=e317] [cursor=pointer]
- generic [ref=e318]:
- heading "Color by" [level=4] [ref=e319]
- combobox [ref=e320]:
- generic: Mentioned
- img [ref=e321]
- generic [ref=e323]:
- heading "Link types" [level=4] [ref=e324]
- generic [ref=e327] [cursor=pointer]: semantic
- generic [ref=e330] [cursor=pointer]: temporal
- generic [ref=e333] [cursor=pointer]: entity
- generic [ref=e336] [cursor=pointer]: causal
- generic [ref=e337]:
- generic [ref=e338]: "Nodes: 163"
- generic [ref=e339]: "Links: 12563"
- region "Notifications alt+T"
- button "Open Next.js Dev Tools" [ref=e89] [cursor=pointer]:
- img [ref=e90]
- alert [ref=e93]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

+2 -2
View File
@@ -250,7 +250,7 @@ Recall performs 4 retrieval strategies in parallel:
- Graph: Entity/temporal/causal links
- Temporal: Time range filtering
![Retain Operation](hindsight-docs/static/img/recall-operation.webp)
![Recall Operation](hindsight-docs/static/img/recall-operation.webp)
The individual results from the retrievals are merged, then ordered by relevance using reciprocal rank fusion and a cross-encoder reranking model.
@@ -276,7 +276,7 @@ client = Hindsight(base_url="http://localhost:8888")
client.reflect(bank_id="my-bank", query="What should I know about Alice?")
```
![Retain Operation](hindsight-docs/static/img/reflect-operation.webp)
![Reflect Operation](hindsight-docs/static/img/reflect-operation.webp)
---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.8.3
appVersion: "0.8.3"
version: 0.8.4
appVersion: "0.8.4"
keywords:
- ai
- memory
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@vectorize-io/hindsight-all",
"version": "0.8.3",
"version": "0.8.4",
"description": "Node.js programmatic lifecycle manager for Hindsight — embeds a local hindsight daemon in a Node application. Pair with @vectorize-io/hindsight-client for memory operations.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
+2 -2
View File
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-all-slim"
version = "0.8.3"
version = "0.8.4"
description = "Hindsight: Agent Memory That Works Like Human Memory - Slim All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim==0.8.3",
"hindsight-api-slim==0.8.4",
"hindsight-client>=0.0.7",
"hindsight-embed>=0.1.0",
]
+3 -3
View File
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-all"
version = "0.8.3"
version = "0.8.4"
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim[all]==0.8.3",
"hindsight-api-slim[all]==0.8.4",
"hindsight-client>=0.0.7",
"hindsight-embed>=0.1.0",
]
@@ -21,7 +21,7 @@ hindsight-embed = { workspace = true }
[project.optional-dependencies]
local-llm = [
"hindsight-api-slim[local-llm]==0.8.3",
"hindsight-api-slim[local-llm]==0.8.4",
]
test = [
"pytest>=7.0.0",
+1 -1
View File
@@ -121,7 +121,7 @@ This runs a stdio-based MCP server that can be used directly with MCP-compatible
- **Entity Graph** — Automatic entity extraction and relationship tracking
- **Temporal Reasoning** — Native support for time-based queries
- **Disposition Traits** — Configurable skepticism, literalism, and empathy influence opinion formation
- **Three Memory Types** — World facts, bank actions, and formed opinions with confidence scores
- **Three Memory Types** — World facts, experience facts (the bank's own actions), and observations
## Documentation
+1 -1
View File
@@ -53,4 +53,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.8.3"
__version__ = "0.8.4"
@@ -56,6 +56,7 @@ BACKUP_TABLES = [
"observation_history",
"mental_models",
"mental_model_history",
"knowledge_pages",
"directives",
"async_operations",
"webhooks",
@@ -0,0 +1,52 @@
"""Add managed flag to knowledge_pages.
The knowledge base is managed by clients (CRUD over folders/pages). ``managed``
lets a client tag a node as system-owned vs. hand-authored; it carries no
server-side behaviour.
Revision ID: a5b6c7d8e9f0
Revises: a9b8c7d6e5f4
Create Date: 2026-06-26
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a5b6c7d8e9f0"
down_revision: str | Sequence[str] | None = "a9b8c7d6e5f4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"ALTER TABLE {schema}knowledge_pages ADD COLUMN IF NOT EXISTS managed BOOLEAN NOT NULL DEFAULT false")
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"ALTER TABLE {schema}knowledge_pages DROP COLUMN IF EXISTS managed")
def _oracle_upgrade() -> None:
op.execute("ALTER TABLE knowledge_pages ADD (managed NUMBER(1) DEFAULT 0 NOT NULL)")
def _oracle_downgrade() -> None:
op.execute("ALTER TABLE knowledge_pages DROP COLUMN managed")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -0,0 +1,110 @@
"""Add knowledge_pages table (knowledge-base hierarchy).
The knowledge base organizes synthesized mental models into a navigable tree of
**folders** and **pages**. A page references the mental model that holds its
content (``mental_model_id``); a folder is a pure container (``mental_model_id``
NULL). Hierarchy is a single self-referential ``parent_id`` so folders can nest
arbitrarily. Content stays in ``mental_models`` — this table is metadata + tree
structure only.
Revision ID: a9b8c7d6e5f4
Revises: b57a7c9e0d13
Create Date: 2026-06-25
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a9b8c7d6e5f4"
down_revision: str | Sequence[str] | None = "b57a7c9e0d13"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
# parent_id self-FK cascades so deleting a folder row removes its whole
# subtree of rows in one shot. The mental_model FK is composite (matches the
# mental_models (id, bank_id) PK) and cascades too, so deleting a page's
# mental model removes the page row — folders skip the FK because a NULL
# column in a composite FK is not enforced (MATCH SIMPLE).
op.execute(
f"""
CREATE TABLE IF NOT EXISTS {schema}knowledge_pages (
id VARCHAR(64) NOT NULL,
bank_id TEXT NOT NULL,
parent_id VARCHAR(64),
kind VARCHAR(16) NOT NULL,
name TEXT NOT NULL,
mental_model_id VARCHAR(64),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT pk_knowledge_pages PRIMARY KEY (id),
CONSTRAINT ck_knowledge_pages_kind CHECK (kind IN ('folder', 'page')),
CONSTRAINT fk_kp_bank FOREIGN KEY (bank_id)
REFERENCES {schema}banks(bank_id) ON DELETE CASCADE,
CONSTRAINT fk_kp_parent FOREIGN KEY (parent_id)
REFERENCES {schema}knowledge_pages(id) ON DELETE CASCADE,
CONSTRAINT fk_kp_mm FOREIGN KEY (mental_model_id, bank_id)
REFERENCES {schema}mental_models(id, bank_id) ON DELETE CASCADE
)
"""
)
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_kp_bank_parent ON {schema}knowledge_pages (bank_id, parent_id, sort_order)"
)
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}idx_kp_bank_parent")
op.execute(f"DROP TABLE IF EXISTS {schema}knowledge_pages")
def _oracle_upgrade() -> None:
op.execute(
"""
CREATE TABLE IF NOT EXISTS knowledge_pages (
id VARCHAR2(64) NOT NULL,
bank_id VARCHAR2(256) NOT NULL,
parent_id VARCHAR2(64),
kind VARCHAR2(16) NOT NULL,
name CLOB NOT NULL,
mental_model_id VARCHAR2(64),
sort_order NUMBER DEFAULT 0 NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT pk_knowledge_pages PRIMARY KEY (id),
CONSTRAINT ck_knowledge_pages_kind CHECK (kind IN ('folder', 'page')),
CONSTRAINT fk_kp_bank FOREIGN KEY (bank_id)
REFERENCES banks(bank_id) ON DELETE CASCADE,
CONSTRAINT fk_kp_parent FOREIGN KEY (parent_id)
REFERENCES knowledge_pages(id) ON DELETE CASCADE,
CONSTRAINT fk_kp_mm FOREIGN KEY (mental_model_id, bank_id)
REFERENCES mental_models(id, bank_id) ON DELETE CASCADE
)
"""
)
op.execute("CREATE INDEX idx_kp_bank_parent ON knowledge_pages (bank_id, parent_id, sort_order)")
def _oracle_downgrade() -> None:
op.execute("DROP TABLE knowledge_pages CASCADE CONSTRAINTS")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -0,0 +1,61 @@
"""Add bank_stats_cache table for distributed get_bank_stats caching
Revision ID: b57a7c9e0d13
Revises: c3f7a1b9d2e4
Create Date: 2026-07-01
get_bank_stats aggregates over memory_links / unit_entities — a multi-second scan
on banks with millions of rows. The result was cached per-process (in-memory), so
every API worker recomputed it once per TTL and the first caller after expiry
stalled. This table backs a shared, cross-process TTL cache: one worker's compute
is written here and served to all the others.
PostgreSQL only. Oracle keeps the in-process cache (the runtime picks the backing
store by dialect), so the Oracle upgrade slot is intentionally absent.
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "b57a7c9e0d13"
down_revision: str | Sequence[str] | None = "c3f7a1b9d2e4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
# One row per bank: payload is the full get_bank_stats result, computed_at
# drives logical TTL expiry. Rows are overwritten in place (ON CONFLICT), so
# the table never grows beyond the number of banks and needs no purge job.
op.execute(
f"""
CREATE TABLE IF NOT EXISTS {schema}bank_stats_cache (
bank_id TEXT PRIMARY KEY,
payload JSONB NOT NULL,
computed_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
)
def _pg_downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"DROP TABLE IF EXISTS {schema}bank_stats_cache")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade) # oracle slot intentionally absent → no-op
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,71 @@
"""Unique page name per folder in knowledge_pages.
The folder curator can fire concurrently (folder-create trigger + the
post-consolidation sweep), and an in-process lock can't serialize runs that
execute in different threads/loops. A partial unique index on
(bank_id, parent, lower(name)) for pages makes duplicate-named pages in the same
folder impossible at the DB level — the second concurrent insert fails and the
curator treats it as "already exists".
PostgreSQL only: the Oracle ``name`` column is a CLOB and cannot back a
functional unique index; Oracle relies on the in-process serialization instead.
Revision ID: c3d4e5f6a7b8
Revises: a5b6c7d8e9f0
Create Date: 2026-06-26
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "c3d4e5f6a7b8"
down_revision: str | Sequence[str] | None = "a5b6c7d8e9f0"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
# First drop any pre-existing duplicate pages (created by the racy curator
# before this guard existed), keeping the earliest row of each duplicate set,
# so the unique index can be built. Their backing mental models are left in
# place (harmless orphans).
op.execute(
f"""
DELETE FROM {schema}knowledge_pages a
USING {schema}knowledge_pages b
WHERE a.kind = 'page' AND b.kind = 'page'
AND a.bank_id = b.bank_id
AND COALESCE(a.parent_id, '') = COALESCE(b.parent_id, '')
AND lower(a.name) = lower(b.name)
AND a.ctid > b.ctid
"""
)
# COALESCE(parent_id, '') so root-level pages (NULL parent) are also unique by
# name — NULLs would otherwise compare distinct and allow duplicates.
op.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_kp_folder_pagename "
f"ON {schema}knowledge_pages (bank_id, COALESCE(parent_id, ''), lower(name)) "
"WHERE kind = 'page'"
)
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}uq_kp_folder_pagename")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade) # oracle slot intentionally absent (CLOB name)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,144 @@
"""Backfill search_vector for native-backend observations.
Observations created or updated by the consolidator landed with a NULL
``search_vector`` under the ``native`` text-search backend: the
single-row INSERT/UPDATE paths in ``consolidator.py`` never populated the
tsvector (only the batch raw-fact path in ``ops_postgresql.insert_facts_batch``
did). Those observations were therefore invisible to the BM25 retrieval arm
until they were re-written by a later consolidation pass. The writer is fixed
in the same change set (all four consolidator sites now call
``to_tsvector($lang, COALESCE(text, ''))``); this migration repairs the
historical residue so existing observations become BM25-searchable without a
re-ingest.
Scope mirrors the writer fix exactly:
* Only the ``native`` backend is touched. The gate is the column *type*:
under ``native`` ``search_vector`` is a regular (non-generated) tsvector
column; under ``vchord`` it is a ``bm25vector`` and under
``pg_textsearch`` / ``pgroonga`` / ``pg_search`` it is a dummy ``text``
column. ``_is_regular_tsvector`` is true only for ``native``, so every
other backend is a no-op.
* The tsvector is built from the observation's own ``text`` only — matching
the consolidator INSERT/UPDATE paths (entity / source / temporal signals
are intentionally excluded; the other retrieval arms cover those).
* Only ``fact_type = 'observation'`` rows with a NULL ``search_vector`` are
rewritten. Raw facts already carry a populated tsvector, and the
``IS NULL`` predicate makes the migration idempotent and re-runnable.
The configured ``HINDSIGHT_API_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE`` is used
so backfilled rows are lexically identical to newly-created observations. The
value is validated as a PG identifier (mirroring
``HindsightConfig.validate``) before being embedded as a SQL literal.
This is a single UPDATE per schema: it locks the targeted observation rows for
its duration. It is one-time and only touches unpopulated rows, so subsequent
online writes (which now carry the tsvector via the writer fix) are unaffected.
Oracle slot is intentionally absent: the consolidator INSERT/UPDATE paths that
this repairs are PostgreSQL-specific (``ops_postgresql``), and the native
tsvector ``search_vector`` column only exists on PostgreSQL. There is no Oracle
residue to repair.
Revision ID: c3f7a1b9d2e4
Revises: f4d1c2b3a5e6
Create Date: 2026-06-29
"""
import os
import re
from collections.abc import Sequence
from alembic import context, op
from sqlalchemy import Connection, text
from hindsight_api.alembic._dialect import run_for_dialect
from hindsight_api.config import (
DEFAULT_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE,
ENV_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE,
)
revision: str = "c3f7a1b9d2e4"
down_revision: str | Sequence[str] | None = "f4d1c2b3a5e6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# Matches HindsightConfig.validate(): a tsvector regconfig name embedded as a
# SQL literal must be a bare PG identifier.
_PG_IDENTIFIER = re.compile(r"[a-zA-Z_][a-zA-Z0-9_]*")
def _schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _schema_name() -> str:
return (context.config.get_main_option("target_schema") or "public").strip('"')
def _native_language() -> str:
"""Configured native tsvector language, validated as a PG identifier."""
lang = os.getenv(
ENV_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE,
DEFAULT_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE,
)
if not _PG_IDENTIFIER.fullmatch(lang):
return DEFAULT_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE
return lang
def _is_regular_tsvector(conn: Connection, schema: str, table: str) -> bool:
"""True iff ``schema.table.search_vector`` is a non-generated tsvector column.
This is the ``native`` backend signature. ``vchord`` (bm25vector) and
``pg_textsearch`` / ``pgroonga`` / ``pg_search`` (dummy text column) all
fail this check, so the backfill is a no-op for them.
"""
row = conn.execute(
text(
"""
SELECT is_generated, udt_name
FROM information_schema.columns
WHERE table_schema = :schema
AND table_name = :table
AND column_name = 'search_vector'
"""
),
{"schema": schema, "table": table},
).fetchone()
if not row:
return False
is_generated, udt_name = row[0], row[1]
return udt_name == "tsvector" and is_generated != "ALWAYS"
def _pg_upgrade() -> None:
conn = op.get_bind()
schema_name = _schema_name()
if not _is_regular_tsvector(conn, schema_name, "memory_units"):
# Non-native backend (or column absent) — nothing to backfill.
return
schema_prefix = _schema_prefix()
lang = _native_language()
op.execute(
f"""
UPDATE {schema_prefix}memory_units
SET search_vector = to_tsvector('{lang}'::regconfig, COALESCE(text, ''))
WHERE fact_type = 'observation' AND search_vector IS NULL
"""
)
def _pg_downgrade() -> None:
# No-op: backfilled rows are indistinguishable from observations that were
# populated by the post-fix writer, and reverting either to NULL would
# re-break BM25 retrieval. The column simply stays populated.
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
+501 -17
View File
@@ -18,6 +18,7 @@ from typing import Any, Literal, TypeVar
from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Query, Request, UploadFile
from fastapi.middleware.gzip import GZipMiddleware
from hindsight_api.api import okf
from hindsight_api.api.disconnect import ClientDisconnectCancellationMiddleware, get_scope_cancellation_token
from hindsight_api.cancellation import OperationCancelledError
from hindsight_api.engine.audit import (
@@ -27,7 +28,7 @@ from hindsight_api.engine.audit import (
AuditLogStatsResponse,
)
from hindsight_api.engine.llm_trace import LLMRequestListResponse, LLMRequestStatsResponse
from hindsight_api.extensions import AuthenticationError
from hindsight_api.extensions import AuthenticationError, PrecheckOperation
def _parse_metadata(metadata: Any) -> dict[str, Any]:
@@ -2110,6 +2111,150 @@ class MentalModelListResponse(BaseModel):
items: list[MentalModelResponse]
# =========================================================================
# KNOWLEDGE BASE (folders + pages over mental models, projected to OKF)
# =========================================================================
class KnowledgeNode(BaseModel):
"""A node in the knowledge-base tree — a folder or a page.
Pages carry ``description``/``tags`` from their backing mental model. The
knowledge base is client-managed (CRUD); ``managed`` lets a client tag a node
as system-owned vs. hand-authored.
"""
id: str
kind: Literal["folder", "page"]
name: str
parent_id: str | None = None
mental_model_id: str | None = Field(default=None, description="Backing mental model id (pages only).")
managed: bool = Field(default=False, description="Client-set flag: true = system-owned, false = hand-authored.")
description: str | None = Field(default=None, description="Page source query (OKF `description`).")
tags: list[str] = FieldWithDefault(list)
timestamp: str | None = Field(default=None, description="Last refresh (page) or last update (folder).")
children: list["KnowledgeNode"] = FieldWithDefault(list)
class KnowledgeTreeResponse(BaseModel):
"""The knowledge base as a nested folder/page tree."""
roots: list[KnowledgeNode]
class CreateFolderRequest(BaseModel):
"""Create a folder under an optional parent folder."""
name: str
parent_id: str | None = None
class CreatePageRequest(BaseModel):
"""Create a page (a mental model + tree node) under an optional parent folder."""
name: str
source_query: str
parent_id: str | None = None
tags: list[str] | None = None
max_tokens: int | None = None
trigger: MentalModelTrigger | None = None
class UpdateNodeRequest(BaseModel):
"""Rename and/or move a node. Each field applies only when present."""
name: str | None = None
parent_id: str | None = None
class CreateKnowledgePageResponse(BaseModel):
"""Result of creating a page: the node id, its mental model, and the refresh op."""
page_id: str
mental_model_id: str
operation_id: str | None = None
class KnowledgePageResponse(BaseModel):
"""A knowledge page rendered as an OKF document."""
id: str
name: str
type: str = Field(description="OKF document type — from a `type:<x>` tag, else 'knowledge-page'.")
description: str | None = Field(default=None, description="The source query that rebuilds the page.")
tags: list[str] = FieldWithDefault(list)
timestamp: str | None = Field(default=None, description="Last refresh time (falls back to creation).")
body: str | None = Field(default=None, description="The page's synthesized markdown body.")
markdown: str = Field(description="The full OKF document: YAML frontmatter + markdown body.")
class KnowledgePageGraphResponse(BaseModel):
"""Constellation graph of knowledge pages linked by shared tags."""
nodes: list[dict[str, Any]]
edges: list[dict[str, Any]]
total_pages: int
total_edges: int
class KnowledgePageBundleFile(BaseModel):
"""One file in a portable OKF bundle."""
path: str
content: str
class KnowledgePageBundleResponse(BaseModel):
"""A portable OKF bundle — a flat set of markdown files (index + pages + logs)."""
files: list[KnowledgePageBundleFile]
def _knowledge_node_model(node: dict[str, Any]) -> KnowledgeNode:
"""Project an engine node dict into a (childless) KnowledgeNode."""
is_page = node.get("kind") == "page"
return KnowledgeNode(
id=node["id"],
kind=node["kind"],
name=node["name"],
parent_id=node.get("parent_id"),
mental_model_id=node.get("mental_model_id"),
managed=bool(node.get("managed")),
description=node.get("source_query") if is_page else None,
tags=list(node.get("tags") or []) if is_page else [],
timestamp=(node.get("last_refreshed_at") if is_page else node.get("updated_at")),
)
def _build_knowledge_tree(nodes: list[dict[str, Any]]) -> list[KnowledgeNode]:
"""Assemble the flat node list into a nested tree of roots."""
models = {n["id"]: _knowledge_node_model(n) for n in nodes}
roots: list[KnowledgeNode] = []
for node in nodes:
model = models[node["id"]]
parent_id = node.get("parent_id")
if parent_id and parent_id in models:
models[parent_id].children.append(model)
else:
roots.append(model)
return roots
def _knowledge_page_response(node: dict[str, Any]) -> KnowledgePageResponse:
"""Project a page node (with merged mental-model content) into an OKF document."""
page = okf.page_type(node.get("tags"))
return KnowledgePageResponse(
id=node["id"],
name=node["name"],
type=page.type,
description=node.get("source_query"),
tags=page.display_tags,
timestamp=node.get("last_refreshed_at") or node.get("created_at"),
body=node.get("content"),
markdown=okf.render_document(node),
)
class CreateMentalModelRequest(BaseModel):
"""Request model for creating a mental model."""
@@ -3380,7 +3525,7 @@ def _register_routes(app: FastAPI):
api_key = authorization.strip()
return RequestContext(api_key=api_key)
def precheck_for(operation: str):
def precheck_for(operation: PrecheckOperation):
"""
Build a FastAPI dependency that runs ``OperationValidator.precheck``.
@@ -3631,7 +3776,7 @@ def _register_routes(app: FastAPI):
async def _require_dry_run_enabled() -> None:
"""Feature-flag gate for dry-run extraction.
Declared as a dependency BEFORE ``precheck_for("dry_run_extract")`` so a
Declared as a dependency BEFORE ``precheck_for(PrecheckOperation.DRY_RUN_EXTRACT)`` so a
disabled route returns 404 regardless of tenant/billing state FastAPI
resolves path-operation dependencies in signature order, so this runs
first and preserves the original "disabled → 404" contract.
@@ -3661,7 +3806,7 @@ def _register_routes(app: FastAPI):
body: DryRunExtractRequest,
request_context: RequestContext = Depends(get_request_context),
_enabled: None = Depends(_require_dry_run_enabled),
_precheck: None = Depends(precheck_for("dry_run_extract")),
_precheck: None = Depends(precheck_for(PrecheckOperation.DRY_RUN_EXTRACT)),
):
try:
override_fields = (
@@ -3829,7 +3974,7 @@ def _register_routes(app: FastAPI):
request: RecallRequest,
http_request: Request,
request_context: RequestContext = Depends(get_request_context),
_precheck: None = Depends(precheck_for("recall")),
_precheck: None = Depends(precheck_for(PrecheckOperation.RECALL)),
):
"""Run a recall and return results with trace."""
import time
@@ -4033,7 +4178,7 @@ def _register_routes(app: FastAPI):
request: ReflectRequest,
http_request: Request,
request_context: RequestContext = Depends(get_request_context),
_precheck: None = Depends(precheck_for("reflect")),
_precheck: None = Depends(precheck_for(PrecheckOperation.REFLECT)),
):
metrics = get_metrics_collector()
@@ -4191,11 +4336,17 @@ def _register_routes(app: FastAPI):
)
async def api_stats(
bank_id: str,
refresh: bool = Query(
default=False,
description="Force a fresh recompute, bypassing the cached value (and refreshing the cache).",
),
request_context: RequestContext = Depends(get_request_context),
):
"""Get statistics about memory nodes and links for a memory bank."""
try:
stats = await app.state.memory.get_bank_stats(bank_id, request_context=request_context)
stats = await app.state.memory.get_bank_stats(
bank_id, request_context=request_context, force_refresh=refresh
)
nodes_by_type = stats["node_counts"]
links_by_type = stats["link_counts"]
links_by_fact_type = stats["link_counts_by_fact_type"]
@@ -4580,7 +4731,7 @@ def _register_routes(app: FastAPI):
bank_id: str,
body: CreateMentalModelRequest,
request_context: RequestContext = Depends(get_request_context),
_precheck: None = Depends(precheck_for("mental_model_create")),
_precheck: None = Depends(precheck_for(PrecheckOperation.MENTAL_MODEL_CREATE)),
):
"""Create a mental model (async - returns operation_id)."""
try:
@@ -4629,7 +4780,7 @@ def _register_routes(app: FastAPI):
bank_id: str,
mental_model_id: str,
request_context: RequestContext = Depends(get_request_context),
_precheck: None = Depends(precheck_for("mental_model_refresh")),
_precheck: None = Depends(precheck_for(PrecheckOperation.MENTAL_MODEL_REFRESH)),
):
"""Refresh a mental model by re-running its source query (async)."""
try:
@@ -4775,6 +4926,333 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/mental-models/{mental_model_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
# =========================================================================
# KNOWLEDGE BASE ENDPOINTS (folders + pages, Open Knowledge Format)
# =========================================================================
# A hierarchy of folders and pages over mental models. Pages project to OKF
# documents (markdown body + YAML frontmatter); see api/okf.py. The static
# sub-paths (/tree, /folders, /pages, /graph, /export) are declared before
# the /pages/{id} and /nodes/{id} path-parameter routes so they win.
@app.get(
"/v1/default/banks/{bank_id}/knowledge-base/tree",
response_model=KnowledgeTreeResponse,
summary="Get the knowledge-base tree",
description="Return the knowledge base as a nested tree of folders and pages.",
operation_id="get_knowledge_base_tree",
tags=["Knowledge Base"],
)
async def api_knowledge_base_tree(
bank_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Return the folder/page tree for a bank."""
try:
nodes = await app.state.memory.list_knowledge_nodes(bank_id=bank_id, request_context=request_context)
return KnowledgeTreeResponse(roots=_build_knowledge_tree(nodes))
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in GET /v1/default/banks/{bank_id}/knowledge-base/tree: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/knowledge-base/folders",
response_model=KnowledgeNode,
status_code=201,
summary="Create a knowledge-base folder",
description="Create a folder, optionally nested under a parent folder.",
operation_id="create_knowledge_folder",
tags=["Knowledge Base"],
)
async def api_create_knowledge_folder(
bank_id: str,
body: CreateFolderRequest,
request_context: RequestContext = Depends(get_request_context),
):
"""Create a folder node."""
try:
node = await app.state.memory.create_knowledge_folder(
bank_id=bank_id,
name=body.name,
parent_id=body.parent_id,
request_context=request_context,
)
return _knowledge_node_model(node)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in POST /v1/default/banks/{bank_id}/knowledge-base/folders: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/knowledge-base/pages",
response_model=CreateKnowledgePageResponse,
status_code=201,
summary="Create a knowledge-base page",
description="Create a page (a mental model + tree node). Content is generated asynchronously; "
"use the returned operation_id to track completion.",
operation_id="create_knowledge_page",
tags=["Knowledge Base"],
)
async def api_create_knowledge_page(
bank_id: str,
body: CreatePageRequest,
request_context: RequestContext = Depends(get_request_context),
):
"""Create a page node (async content generation)."""
try:
node = await app.state.memory.create_knowledge_page(
bank_id=bank_id,
name=body.name,
source_query=body.source_query,
content="Generating content...",
parent_id=body.parent_id,
tags=body.tags if body.tags else None,
max_tokens=body.max_tokens,
trigger=body.trigger.model_dump() if body.trigger else None,
request_context=request_context,
)
if node is None:
raise HTTPException(status_code=409, detail=f"A page named '{body.name}' already exists in this folder")
result = await app.state.memory.submit_async_refresh_mental_model(
bank_id=bank_id,
mental_model_id=node["mental_model_id"],
request_context=request_context,
)
return CreateKnowledgePageResponse(
page_id=node["id"],
mental_model_id=node["mental_model_id"],
operation_id=result["operation_id"],
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in POST /v1/default/banks/{bank_id}/knowledge-base/pages: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/knowledge-base/graph",
response_model=KnowledgePageGraphResponse,
summary="Knowledge-base constellation graph",
description="Return pages as nodes linked by shared tags, for the constellation view.",
operation_id="get_knowledge_base_graph",
tags=["Knowledge Base"],
)
async def api_knowledge_base_graph(
bank_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Return the shared-tag constellation graph for a bank's pages."""
try:
nodes = await app.state.memory.list_knowledge_nodes(bank_id=bank_id, request_context=request_context)
pages = [n for n in nodes if n.get("kind") == "page"]
# Cluster the constellation by parent folder (the knowledge base's own
# structure) rather than by the retired type: tag.
folder_names = {n["id"]: n["name"] for n in nodes if n.get("kind") == "folder"}
graph = okf.knowledge_graph(pages, cluster_for=lambda p: folder_names.get(p.get("parent_id"), "Ungrouped"))
return KnowledgePageGraphResponse(
nodes=graph.nodes,
edges=graph.edges,
total_pages=len(graph.nodes),
total_edges=len(graph.edges),
)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in GET /v1/default/banks/{bank_id}/knowledge-base/graph: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/knowledge-base/export",
response_model=KnowledgePageBundleResponse,
summary="Export the knowledge base as an OKF bundle",
description="Return a portable OKF bundle: a nested index.md, one <id>.md per page, and history logs.",
operation_id="export_knowledge_base",
tags=["Knowledge Base"],
)
async def api_export_knowledge_base(
bank_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Export a bank's knowledge base as a flat OKF markdown bundle."""
try:
nodes = await app.state.memory.list_knowledge_nodes(bank_id=bank_id, request_context=request_context)
files = [KnowledgePageBundleFile(path=okf.INDEX_FILENAME, content=okf.render_index(nodes))]
for node in nodes:
if node.get("kind") != "page":
continue
page = await app.state.memory.get_knowledge_page(
bank_id=bank_id, page_id=node["id"], request_context=request_context
)
if page is None:
continue
files.append(
KnowledgePageBundleFile(path=okf.page_filename(node["id"]), content=okf.render_document(page))
)
if node.get("mental_model_id"):
history = (
await app.state.memory.get_mental_model_history(
bank_id=bank_id,
mental_model_id=node["mental_model_id"],
request_context=request_context,
)
or []
)
if history:
files.append(
KnowledgePageBundleFile(
path=okf.log_filename(node["id"]), content=okf.render_log(page, history)
)
)
return KnowledgePageBundleResponse(files=files)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in GET /v1/default/banks/{bank_id}/knowledge-base/export: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/knowledge-base/pages/{page_id}",
response_model=KnowledgePageResponse,
summary="Get a knowledge-base page",
description="Return a single page as an OKF document (frontmatter + markdown body).",
operation_id="get_knowledge_page",
tags=["Knowledge Base"],
)
async def api_get_knowledge_page(
bank_id: str,
page_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Get a single page as an OKF document."""
try:
node = await app.state.memory.get_knowledge_page(
bank_id=bank_id, page_id=page_id, request_context=request_context
)
if node is None:
raise HTTPException(status_code=404, detail=f"Knowledge page '{page_id}' not found")
return _knowledge_page_response(node)
except (AuthenticationError, HTTPException):
raise
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in GET /v1/default/banks/{bank_id}/knowledge-base/pages/{page_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.patch(
"/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}",
response_model=KnowledgeNode,
summary="Rename or move a knowledge-base node",
description="Rename a node (set `name`) and/or move it under another folder (set `parent_id`, "
"null for the root).",
operation_id="update_knowledge_node",
tags=["Knowledge Base"],
)
async def api_update_knowledge_node(
bank_id: str,
node_id: str,
body: UpdateNodeRequest,
request_context: RequestContext = Depends(get_request_context),
):
"""Rename and/or move a node."""
try:
updated: dict[str, Any] | None = None
did_change = False
if body.name is not None:
did_change = True
updated = await app.state.memory.rename_knowledge_node(
bank_id=bank_id, node_id=node_id, name=body.name, request_context=request_context
)
# parent_id is applied only when present in the body, so passing null
# moves the node to the root (distinct from "not provided").
if "parent_id" in body.model_fields_set:
did_change = True
updated = await app.state.memory.move_knowledge_node(
bank_id=bank_id, node_id=node_id, new_parent_id=body.parent_id, request_context=request_context
)
if not did_change:
raise HTTPException(status_code=400, detail="Provide name and/or parent_id to update")
if updated is None:
raise HTTPException(status_code=404, detail=f"Knowledge node '{node_id}' not found")
return _knowledge_node_model(updated)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except (AuthenticationError, HTTPException):
raise
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.delete(
"/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}",
summary="Delete a knowledge-base node",
description="Delete a folder or page and its whole subtree (pages' mental models are removed too).",
operation_id="delete_knowledge_node",
tags=["Knowledge Base"],
)
async def api_delete_knowledge_node(
bank_id: str,
node_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Delete a node and its subtree."""
try:
deleted = await app.state.memory.delete_knowledge_node(
bank_id=bank_id, node_id=node_id, request_context=request_context
)
if not deleted:
raise HTTPException(status_code=404, detail=f"Knowledge node '{node_id}' not found")
return {"status": "deleted"}
except (AuthenticationError, HTTPException):
raise
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
# =========================================================================
# DIRECTIVES ENDPOINTS
# =========================================================================
@@ -6215,9 +6693,11 @@ def _register_routes(app: FastAPI):
# Authenticate and set schema context for multi-tenant DB queries
await app.state.memory._authenticate_tenant(request_context)
if app.state.memory._operation_validator:
from hindsight_api.extensions import BankReadContext
from hindsight_api.extensions import BankReadContext, BankReadOperation
ctx = BankReadContext(bank_id=bank_id, operation="get_bank_config", request_context=request_context)
ctx = BankReadContext(
bank_id=bank_id, operation=BankReadOperation.GET_BANK_CONFIG, request_context=request_context
)
await app.state.memory._validate_operation(
app.state.memory._operation_validator.validate_bank_read(ctx)
)
@@ -6263,9 +6743,11 @@ def _register_routes(app: FastAPI):
# Authenticate and set schema context for multi-tenant DB queries
await app.state.memory._authenticate_tenant(request_context)
if app.state.memory._operation_validator:
from hindsight_api.extensions import BankWriteContext
from hindsight_api.extensions import BankWriteContext, BankWriteOperation
ctx = BankWriteContext(bank_id=bank_id, operation="update_bank_config", request_context=request_context)
ctx = BankWriteContext(
bank_id=bank_id, operation=BankWriteOperation.UPDATE_BANK_CONFIG, request_context=request_context
)
await app.state.memory._validate_operation(
app.state.memory._operation_validator.validate_bank_write(ctx)
)
@@ -6322,9 +6804,11 @@ def _register_routes(app: FastAPI):
# Authenticate and set schema context for multi-tenant DB queries
await app.state.memory._authenticate_tenant(request_context)
if app.state.memory._operation_validator:
from hindsight_api.extensions import BankWriteContext
from hindsight_api.extensions import BankWriteContext, BankWriteOperation
ctx = BankWriteContext(bank_id=bank_id, operation="reset_bank_config", request_context=request_context)
ctx = BankWriteContext(
bank_id=bank_id, operation=BankWriteOperation.RESET_BANK_CONFIG, request_context=request_context
)
await app.state.memory._validate_operation(
app.state.memory._operation_validator.validate_bank_write(ctx)
)
@@ -6695,7 +7179,7 @@ def _register_routes(app: FastAPI):
bank_id: str,
request: RetainRequest,
request_context: RequestContext = Depends(get_request_context),
_precheck: None = Depends(precheck_for("retain")),
_precheck: None = Depends(precheck_for(PrecheckOperation.RETAIN)),
):
"""Retain memories with optional async processing."""
metrics = get_metrics_collector()
@@ -6878,7 +7362,7 @@ def _register_routes(app: FastAPI):
files: list[UploadFile] = File(..., description="Files to upload and convert"),
request: str = Form(..., description="JSON string with FileRetainRequest model"),
request_context: RequestContext = Depends(get_request_context),
_precheck: None = Depends(precheck_for("files_retain")),
_precheck: None = Depends(precheck_for(PrecheckOperation.FILES_RETAIN)),
):
"""Upload and convert files to memories."""
from hindsight_api.config import get_config
+263
View File
@@ -0,0 +1,263 @@
"""Open Knowledge Format (OKF) projection for knowledge pages.
Knowledge pages are a *read-only* OKF view over the existing mental models: each
mental model is projected into an OKF document — a markdown body with YAML
frontmatter (``type`` required; ``title``/``description``/``tags``/``timestamp``
optional) — and pages are linked into a constellation graph via shared tags.
See the Open Knowledge Format spec:
https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf
This module is intentionally pure: every function transforms the mental-model
dicts returned by ``MemoryEngine.list_mental_models`` / ``get_mental_model`` and
never touches the database. That keeps the OKF contract unit-testable without a
DB or LLM and lets the HTTP layer stay a thin wrapper.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
# OKF requires exactly one frontmatter field — ``type``. We default to this when
# a page does not declare one via a ``type:<x>`` tag.
DEFAULT_PAGE_TYPE = "knowledge-page"
# A page declares its OKF ``type`` through a tag of the form ``type:runbook``.
# This keeps the projection schema-free (no new mental_models column): the type
# is lifted from the existing tags array.
TYPE_TAG_PREFIX = "type:"
INDEX_FILENAME = "index.md"
# Deterministic, colour-blind-friendly palette. Type → colour is stable across
# requests so the constellation keeps the same colours between reloads.
_PALETTE = (
"#0074d9", # blue
"#2ecc40", # green
"#b10dc9", # purple
"#ff851b", # orange
"#39cccc", # teal
"#f012be", # magenta
"#3d9970", # olive
"#ff4136", # red
)
_EDGE_COLOR = "#9aa5b1"
@dataclass(frozen=True)
class PageType:
"""A page's OKF ``type`` and the tags that remain after the type tag is split off."""
type: str
display_tags: list[str]
@dataclass(frozen=True)
class KnowledgeGraph:
"""Cytoscape-style node/edge graph of knowledge pages linked by shared tags."""
nodes: list[dict[str, Any]] = field(default_factory=list)
edges: list[dict[str, Any]] = field(default_factory=list)
def _color_for(key: str) -> str:
"""Stable colour for a string key (FNV-ish hash into the fixed palette)."""
h = 0
for ch in key:
h = (h * 31 + ord(ch)) & 0xFFFFFFFF
return _PALETTE[h % len(_PALETTE)]
def _scalar(value: Any) -> str:
"""Emit a YAML-safe double-quoted scalar.
We always double-quote so arbitrary page names / source queries can't be
misread as YAML special forms (``true``, ``2026-01-01``, ``- x``, etc.).
"""
text = str(value)
escaped = text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "")
return f'"{escaped}"'
def page_type(tags: list[str] | None) -> PageType:
"""Split an OKF ``type`` out of the tag list.
The first ``type:<x>`` tag wins; all ``type:`` tags are removed from the
returned ``display_tags`` so they don't pollute the constellation's
shared-tag edges. Falls back to :data:`DEFAULT_PAGE_TYPE`.
"""
resolved = DEFAULT_PAGE_TYPE
display: list[str] = []
for tag in tags or []:
if tag.startswith(TYPE_TAG_PREFIX):
suffix = tag[len(TYPE_TAG_PREFIX) :].strip()
if suffix and resolved == DEFAULT_PAGE_TYPE:
resolved = suffix
continue
display.append(tag)
return PageType(type=resolved, display_tags=display)
def _timestamp(mm: dict[str, Any]) -> str | None:
return mm.get("last_refreshed_at") or mm.get("created_at")
def frontmatter(mm: dict[str, Any]) -> dict[str, Any]:
"""Build the ordered OKF frontmatter mapping for a mental model.
``None``/empty values are dropped by :func:`render_frontmatter`.
"""
pt = page_type(mm.get("tags"))
return {
"id": mm.get("id"),
"type": pt.type,
"title": mm.get("name"),
"description": mm.get("source_query"),
"tags": pt.display_tags,
"timestamp": _timestamp(mm),
}
def render_frontmatter(fm: dict[str, Any]) -> str:
"""Render a frontmatter mapping into a ``---`` fenced YAML block."""
lines = ["---"]
for key, value in fm.items():
if value is None:
continue
if isinstance(value, list):
if not value:
continue
lines.append(f"{key}:")
lines.extend(f" - {_scalar(item)}" for item in value)
else:
lines.append(f"{key}: {_scalar(value)}")
lines.append("---")
return "\n".join(lines)
def render_document(mm: dict[str, Any]) -> str:
"""Render a full OKF document: frontmatter block + markdown body."""
body = (mm.get("content") or "").strip()
return f"{render_frontmatter(frontmatter(mm))}\n\n{body}\n" if body else f"{render_frontmatter(frontmatter(mm))}\n"
def page_filename(page_id: str) -> str:
"""OKF bundle filename for a page id."""
return f"{page_id}.md"
def log_filename(page_id: str) -> str:
"""OKF reserved per-page history filename."""
return f"{page_id}.log.md"
def render_index(nodes: list[dict[str, Any]]) -> str:
"""Render the reserved ``index.md`` — nested OKF navigation over the tree.
``nodes`` is the flat folder/page list (each with ``id``, ``kind``, ``name``,
``parent_id``); folders nest their children, pages link to their ``.md``.
"""
fm = render_frontmatter({"type": "index", "title": "Knowledge base"})
lines = [fm, "", "# Knowledge base", ""]
children: dict[Any, list[dict[str, Any]]] = {}
for node in nodes:
children.setdefault(node.get("parent_id"), []).append(node)
def walk(parent: Any, depth: int) -> None:
ordered = sorted(children.get(parent, []), key=lambda n: (n.get("sort_order", 0), n.get("name") or ""))
for node in ordered:
indent = " " * depth
if node.get("kind") == "folder":
lines.append(f"{indent}- **{node['name']}/**")
walk(node["id"], depth + 1)
else:
description = node.get("source_query") or node.get("description")
link = f"{indent}- [{node['name']}](./{page_filename(node['id'])})"
lines.append(f"{link}{description}" if description else link)
walk(None, 0)
if len(lines) == 4:
lines.append("_No knowledge pages yet._")
return "\n".join(lines) + "\n"
def render_log(mm: dict[str, Any], history: list[dict[str, Any]]) -> str:
"""Render the reserved per-page ``log.md`` from refresh history.
Each history entry is ``{previous_content, previous_reflect_response,
changed_at}`` (newest first), capturing the content *before* a refresh.
"""
name = mm.get("name") or mm.get("id")
fm = render_frontmatter({"type": "log", "title": f"{name} — history"})
lines = [fm, "", f"# {name} — history", ""]
if not history:
lines.append("_No refresh history._")
return "\n".join(lines) + "\n"
for entry in history:
changed_at = entry.get("changed_at") or "unknown"
previous = (entry.get("previous_content") or "").strip()
lines.append(f"## {changed_at}")
lines.append("")
lines.append(previous if previous else "_(empty)_")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def knowledge_graph(
pages: list[dict[str, Any]],
cluster_for: "Callable[[dict[str, Any]], str] | None" = None,
) -> KnowledgeGraph:
"""Derive the constellation graph: pages as nodes, shared tags as edges.
Two pages are linked when they share at least one (non-``type:``) tag; the
edge weight is the number of shared tags. Each node's cluster (``type`` field
+ colour) comes from ``cluster_for(page)`` — the knowledge base groups by
parent folder; the default groups by OKF ``type``.
"""
nodes: list[dict[str, Any]] = []
tag_sets: list[tuple[str, frozenset[str]]] = []
for mm in pages:
page_id = mm["id"]
pt = page_type(mm.get("tags"))
cluster = cluster_for(mm) if cluster_for else pt.type
tag_sets.append((page_id, frozenset(pt.display_tags)))
nodes.append(
{
"data": {
"id": page_id,
"label": mm.get("name") or page_id,
"type": cluster,
"tagCount": len(pt.display_tags),
"color": _color_for(cluster),
}
}
)
edges: list[dict[str, Any]] = []
for i in range(len(tag_sets)):
source_id, source_tags = tag_sets[i]
if not source_tags:
continue
for j in range(i + 1, len(tag_sets)):
target_id, target_tags = tag_sets[j]
shared = source_tags & target_tags
if not shared:
continue
edges.append(
{
"data": {
"id": f"{source_id}--{target_id}",
"source": source_id,
"target": target_id,
"sharedTags": sorted(shared),
"weight": len(shared),
"color": _EDGE_COLOR,
}
}
)
return KnowledgeGraph(nodes=nodes, edges=edges)
+79 -3
View File
@@ -148,6 +148,19 @@ ENV_LLM_DEFAULT_HEADERS = "HINDSIGHT_API_LLM_DEFAULT_HEADERS"
ENV_LLM_STRICT_SCHEMA = "HINDSIGHT_API_LLM_STRICT_SCHEMA"
ENV_LLM_SEND_BANK_AS_USER = "HINDSIGHT_API_LLM_SEND_BANK_AS_USER"
# Per-operation sampling temperature. Each internal LLM call uses a temperature
# tuned for its task (deterministic extraction vs. creative reflection). These
# expose those as overridable knobs. Resolution per operation:
# per-operation env -> global env (ENV_LLM_TEMPERATURE) -> built-in default.
# A value of "none"/"default"/"" (or "off") omits the temperature parameter
# entirely, for models that reject explicit temperatures (e.g. Azure GPT-5.5,
# which only accepts the default value) -- see issue #2459.
ENV_LLM_TEMPERATURE = "HINDSIGHT_API_LLM_TEMPERATURE"
ENV_LLM_TEMPERATURE_VERIFICATION = "HINDSIGHT_API_LLM_TEMPERATURE_VERIFICATION"
ENV_LLM_TEMPERATURE_RETAIN = "HINDSIGHT_API_LLM_TEMPERATURE_RETAIN"
ENV_LLM_TEMPERATURE_REFLECT = "HINDSIGHT_API_LLM_TEMPERATURE_REFLECT"
ENV_LLM_TEMPERATURE_CONSOLIDATION = "HINDSIGHT_API_LLM_TEMPERATURE_CONSOLIDATION"
# Multi-LLM strategy. Extra LLMs are configured by index alongside the unindexed
# primary (e.g. HINDSIGHT_API_LLM_1_PROVIDER, HINDSIGHT_API_LLM_2_PROVIDER, ...),
# and HINDSIGHT_API_LLM_STRATEGY (JSON) selects how to route across them — see
@@ -166,6 +179,12 @@ ENV_CONSOLIDATION_LLM_STRATEGY = "HINDSIGHT_API_CONSOLIDATION_LLM_STRATEGY"
# disambiguates from the embeddings/reranker LITELLM_* settings.
ENV_LLM_LITELLMROUTER_CONFIG = "HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG"
# Per-operation temperature defaults (preserve historical hardcoded values).
DEFAULT_LLM_TEMPERATURE_VERIFICATION = 0.0 # connection check
DEFAULT_LLM_TEMPERATURE_RETAIN = 0.1 # fact extraction
DEFAULT_LLM_TEMPERATURE_REFLECT = 0.9 # reflect "thinking"
DEFAULT_LLM_TEMPERATURE_CONSOLIDATION = 0.0 # mental-model delta / dedup
# Defaults for service tiers
DEFAULT_LLM_GROQ_SERVICE_TIER = "auto" # "on_demand", "flex", or "auto"
DEFAULT_LLM_OPENAI_SERVICE_TIER = None # None (default) or "flex" (50% cheaper)
@@ -189,6 +208,46 @@ def parse_gemini_service_tier(value: str | None) -> str | None:
return tier
# Sentinel strings that, as a temperature value, mean "omit the temperature
# parameter entirely" rather than a numeric setting.
_TEMPERATURE_OMIT_VALUES = frozenset({"", "none", "default", "off", "unset"})
def _parse_temperature(raw: str) -> float | None:
"""Parse a raw temperature env value into a float, or None to omit it.
Returns None for the omit sentinels (so the temperature parameter is dropped
from the LLM call); otherwise parses a float and validates the 0.0-2.0 range.
"""
if raw.strip().lower() in _TEMPERATURE_OMIT_VALUES:
return None
try:
value = float(raw)
except ValueError as e:
raise ValueError(
f"Invalid LLM temperature {raw!r}: must be a number in [0.0, 2.0] "
f"or one of {sorted(_TEMPERATURE_OMIT_VALUES)} to omit it."
) from e
if not 0.0 <= value <= 2.0:
raise ValueError(f"Invalid LLM temperature {value}: must be in [0.0, 2.0].")
return value
def _resolve_operation_temperature(operation_env: str, default: float) -> float | None:
"""Resolve a per-operation temperature: per-op env -> global env -> default.
The omit sentinels resolve to None at any layer, so a single
``HINDSIGHT_API_LLM_TEMPERATURE=none`` drops temperature from every operation
that has no explicit per-operation override.
"""
raw = os.getenv(operation_env)
if raw is None:
raw = os.getenv(ENV_LLM_TEMPERATURE)
if raw is None:
return default
return _parse_temperature(raw)
# Per-operation LLM configuration (optional, falls back to global LLM config)
ENV_RETAIN_LLM_PROVIDER = "HINDSIGHT_API_RETAIN_LLM_PROVIDER"
ENV_RETAIN_LLM_API_KEY = "HINDSIGHT_API_RETAIN_LLM_API_KEY"
@@ -514,7 +573,6 @@ ENV_LLAMACPP_EXTRA_ARGS = "HINDSIGHT_API_LLAMACPP_EXTRA_ARGS"
# Optimization flags
ENV_SKIP_LLM_VERIFICATION = "HINDSIGHT_API_SKIP_LLM_VERIFICATION"
ENV_LAZY_RERANKER = "HINDSIGHT_API_LAZY_RERANKER"
# Database migrations
ENV_RUN_MIGRATIONS_ON_STARTUP = "HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP"
@@ -1520,6 +1578,14 @@ class HindsightConfig:
# overrides a `user` the caller already set.
llm_send_bank_as_user: bool
# Per-operation sampling temperature. None means the temperature parameter is
# omitted from the call (for models that reject explicit temperatures). See
# ENV_LLM_TEMPERATURE and _resolve_operation_temperature.
llm_temperature_verification: float | None
llm_temperature_retain: float | None
llm_temperature_reflect: float | None
llm_temperature_consolidation: float | None
# LiteLLM Router chain (provider-specific; consumed by the "litellmrouter" provider).
# List of deployment dicts evaluated in order with fallback on transient errors.
# Each entry: {"provider": str, "model": str, "api_key": str | None, "base_url": str | None}.
@@ -1819,7 +1885,6 @@ class HindsightConfig:
# Optimization flags
skip_llm_verification: bool
lazy_reranker: bool
# Database migrations
run_migrations_on_startup: bool
@@ -2254,6 +2319,18 @@ class HindsightConfig:
llm_strict_schema=os.getenv(ENV_LLM_STRICT_SCHEMA, str(DEFAULT_LLM_STRICT_SCHEMA)).lower() in ("true", "1"),
llm_send_bank_as_user=os.getenv(ENV_LLM_SEND_BANK_AS_USER, str(DEFAULT_LLM_SEND_BANK_AS_USER)).lower()
in ("true", "1"),
llm_temperature_verification=_resolve_operation_temperature(
ENV_LLM_TEMPERATURE_VERIFICATION, DEFAULT_LLM_TEMPERATURE_VERIFICATION
),
llm_temperature_retain=_resolve_operation_temperature(
ENV_LLM_TEMPERATURE_RETAIN, DEFAULT_LLM_TEMPERATURE_RETAIN
),
llm_temperature_reflect=_resolve_operation_temperature(
ENV_LLM_TEMPERATURE_REFLECT, DEFAULT_LLM_TEMPERATURE_REFLECT
),
llm_temperature_consolidation=_resolve_operation_temperature(
ENV_LLM_TEMPERATURE_CONSOLIDATION, DEFAULT_LLM_TEMPERATURE_CONSOLIDATION
),
llm_litellmrouter_config=_parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG),
# Vertex AI
llm_vertexai_project_id=os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID) or DEFAULT_LLM_VERTEXAI_PROJECT_ID,
@@ -2651,7 +2728,6 @@ class HindsightConfig:
),
# Optimization flags
skip_llm_verification=os.getenv(ENV_SKIP_LLM_VERIFICATION, "false").lower() == "true",
lazy_reranker=os.getenv(ENV_LAZY_RERANKER, "false").lower() == "true",
# Retain settings
retain_max_completion_tokens=int(
os.getenv(ENV_RETAIN_MAX_COMPLETION_TOKENS, str(DEFAULT_RETAIN_MAX_COMPLETION_TOKENS))
@@ -13,9 +13,18 @@ in-flight task so that N concurrent callers produce one query rather than N.
from __future__ import annotations
import asyncio
import json
import logging
import time
from collections import OrderedDict
from typing import Any, Awaitable, Callable
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from .db_utils import acquire_with_retry
if TYPE_CHECKING:
from .db.base import DatabaseBackend
logger = logging.getLogger(__name__)
class BankStatsCache:
@@ -66,17 +75,28 @@ class BankStatsCache:
schema: str,
bank_id: str,
loader: Callable[[], Awaitable[dict[str, Any]]],
*,
force_refresh: bool = False,
) -> dict[str, Any]:
"""Return cached stats for `(schema, bank_id)` or call `loader()`.
Concurrent misses on the same key are coalesced onto a single
in-flight loader.
in-flight loader. When ``force_refresh`` is set the cached value is
ignored: the loader runs and its result replaces the cached entry.
"""
if not self.enabled:
return await loader()
key = (schema, bank_id)
if force_refresh:
value = await loader()
async with self._lock:
self._store_unlocked(key, value)
# Supersede any loader that was in flight for this key.
self._in_flight.pop(key, None)
return value
async with self._lock:
cached = self._get_fresh_unlocked(key)
if cached is not None:
@@ -132,3 +152,103 @@ class BankStatsCache:
async with self._lock:
self._entries.clear()
self._in_flight.clear()
class DistributedBankStatsCache:
"""Table-backed (cross-process) TTL cache for `get_bank_stats`.
Same ``get_or_load`` / ``invalidate`` / ``clear`` contract as
:class:`BankStatsCache`, but the store is the per-schema ``bank_stats_cache``
table instead of a per-process dict — so one worker's computation is shared
with every other worker, and no caller recomputes while a fresh row exists.
On a hit, a call is a single primary-key ``SELECT`` (sub-millisecond); only a
miss runs the (expensive) ``loader`` and writes the row back. Concurrent
misses are *not* coalesced across processes (that would need a lock): they
each compute and ``UPSERT``, last write wins — all results are correct, at the
cost of a brief redundant compute at expiry.
Every DB touch is best-effort: if the cache table is unreachable or missing
(e.g. a schema mid-migration), the call degrades to computing without caching
rather than failing ``get_bank_stats``. PostgreSQL only — the engine keeps the
in-process :class:`BankStatsCache` for Oracle.
"""
def __init__(self, *, backend: "DatabaseBackend", ttl_seconds: float) -> None:
self._backend = backend
self._ttl = float(ttl_seconds)
@property
def enabled(self) -> bool:
return self._ttl > 0
@staticmethod
def _qualified(schema: str) -> str:
return f'"{schema}".bank_stats_cache' if schema else "bank_stats_cache"
async def get_or_load(
self,
schema: str,
bank_id: str,
loader: Callable[[], Awaitable[dict[str, Any]]],
*,
force_refresh: bool = False,
) -> dict[str, Any]:
if not self.enabled:
return await loader()
table = self._qualified(schema)
# 1. Fresh row? Single PK lookup; ``payload::text`` sidesteps any
# jsonb->object codec so we always decode the same way. Skipped when
# the caller forces a refresh — then we recompute and overwrite below.
if not force_refresh:
try:
async with acquire_with_retry(self._backend) as conn:
row = await conn.fetchrow(
f"SELECT payload::text AS payload FROM {table} "
f"WHERE bank_id = $1 AND computed_at > now() - make_interval(secs => $2::double precision)",
bank_id,
self._ttl,
)
if row is not None:
return json.loads(row["payload"])
except Exception as exc: # noqa: BLE001 — cache read must never break the endpoint
logger.debug("bank_stats_cache read failed for %s.%s (%s); computing uncached", schema, bank_id, exc)
return await loader()
# 2. Miss — compute, then write the row back (best-effort).
value = await loader()
try:
async with acquire_with_retry(self._backend) as conn:
await conn.execute(
f"INSERT INTO {table} (bank_id, payload, computed_at) VALUES ($1, $2::jsonb, now()) "
f"ON CONFLICT (bank_id) DO UPDATE SET payload = EXCLUDED.payload, computed_at = now()",
bank_id,
json.dumps(value),
)
except Exception as exc: # noqa: BLE001 — a failed write just means no caching this round
logger.warning("bank_stats_cache write failed for %s.%s (%s)", schema, bank_id, exc)
return value
async def invalidate(self, schema: str, bank_id: str) -> None:
"""Drop the cached row so the next read recomputes."""
if not self.enabled:
return
try:
async with acquire_with_retry(self._backend) as conn:
await conn.execute(f"DELETE FROM {self._qualified(schema)} WHERE bank_id = $1", bank_id)
except Exception as exc: # noqa: BLE001 — invalidation must never break the write path
logger.debug("bank_stats_cache invalidate failed for %s.%s (%s)", schema, bank_id, exc)
async def clear(self) -> None:
"""Drop all cached rows in the current schema (best-effort)."""
if not self.enabled:
return
from .memory_engine import get_current_schema
try:
async with acquire_with_retry(self._backend) as conn:
await conn.execute(f"DELETE FROM {self._qualified(get_current_schema())}")
except Exception as exc: # noqa: BLE001
logger.debug("bank_stats_cache clear failed (%s)", exc)
@@ -98,7 +98,7 @@ _DEDUP_TOP_K = 5
class _DedupDecision(BaseModel):
"""Focused 1-by-1 verdict for whether a new observation duplicates an existing one."""
action: Literal["merge", "keep"]
action: Literal["merge", "keep"] = "keep"
text: str = "" # the synthesized merged observation (when action == "merge")
reason: str = ""
@@ -224,13 +224,18 @@ async def _dedup_reconcile_create(
# Fold the new source facts into the twin and persist the merged text. We keep the twin's
# existing embedding: the merged text is >= threshold similar, so the stored vector stays
# representative and we avoid a re-embed + a dialect-specific vector UPDATE.
search_vector_clause = (
f",\n search_vector = to_tsvector('{config.text_search_extension_native_language}'::regconfig, COALESCE($1, ''))"
if config.text_search_extension == "native"
else ""
)
await conn.execute(
f"""
UPDATE {fq_table("memory_units")}
SET text = $1,
source_memory_ids = (SELECT array_agg(DISTINCT e) FROM unnest(source_memory_ids || $2::uuid[]) e),
proof_count = (SELECT count(DISTINCT e) FROM unnest(source_memory_ids || $2::uuid[]) e),
updated_at = now()
updated_at = now(){search_vector_clause}
WHERE id = $3::uuid
""",
outcome.merged_text,
@@ -279,6 +284,11 @@ async def _dedup_reconcile_update(
# the create path) then delete the now-redundant updated row. The all_strict/any tag match
# guarantees twin and updated share scope, so dropping the updated row's tags loses no
# visibility. Temporal fields follow the surviving twin (minimal scope; matches create).
search_vector_clause = (
f",\n search_vector = to_tsvector('{config.text_search_extension_native_language}'::regconfig, COALESCE($1, ''))"
if config.text_search_extension == "native"
else ""
)
await conn.execute(
f"""
UPDATE {fq_table("memory_units")} t
@@ -289,7 +299,7 @@ async def _dedup_reconcile_update(
proof_count = (
SELECT count(DISTINCT e) FROM unnest(t.source_memory_ids || u.source_memory_ids) e
),
updated_at = now()
updated_at = now(){search_vector_clause}
FROM {fq_table("memory_units")} u
WHERE t.id = $2::uuid AND u.id = $3::uuid
""",
@@ -1845,6 +1855,12 @@ async def _execute_update_action(
config = get_config()
search_vector_clause = (
f",\n search_vector = to_tsvector('{config.text_search_extension_native_language}'::regconfig, COALESCE($1, ''))"
if config.text_search_extension == "native"
else ""
)
t0 = time.time()
await conn.execute(
f"""
@@ -1857,7 +1873,7 @@ async def _execute_update_action(
updated_at = now(),
occurred_start = LEAST(occurred_start, COALESCE($6, occurred_start)),
occurred_end = GREATEST(occurred_end, COALESCE($7, occurred_end)),
mentioned_at = GREATEST(mentioned_at, COALESCE($8, mentioned_at))
mentioned_at = GREATEST(mentioned_at, COALESCE($8, mentioned_at)){search_vector_clause}
WHERE id = $5
""",
new_text,
@@ -2333,16 +2349,20 @@ async def _create_observation_directly(
tokenize($3, 'llmlingua2')::bm25_catalog.bm25vector)
RETURNING id
"""
else: # native, pg_textsearch, pgroonga, or pg_search
# pg_textsearch / pgroonga / pg_search: indexes operate on base text
# columns directly, so the dummy search_vector column is left NULL.
# Native: the migration p4q5r6s7t8u9 dropped the GENERATED expression on
# search_vector to allow per-deployment language configuration; the
# batch insert path in ops_postgresql.insert_facts_batch now populates
# it via to_tsvector($lang, ...). This single-observation INSERT does
# not, so observations under the native backend currently land with
# NULL search_vector and are not BM25-searchable until reflected/
# re-ingested. Tracking a separate fix for that gap.
elif config.text_search_extension == "native":
# Native: search_vector is populated with to_tsvector() using the
# configured native language dictionary, matching the batch insert
# path in ops_postgresql.insert_facts_batch.
query = f"""
INSERT INTO {fq_table("memory_units")} (
id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids,
tags, event_date, occurred_start, occurred_end, mentioned_at, search_vector
)
VALUES ($1, $2, $3, 'observation', $4::vector, 1, $5, $6, $7, $8, $9, $10,
to_tsvector('{config.text_search_extension_native_language}'::regconfig, COALESCE($3, '')))
RETURNING id
"""
else: # pg_textsearch, pgroonga, pg_search: indexes operate on base text columns directly
query = f"""
INSERT INTO {fq_table("memory_units")} (
id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids,
@@ -449,6 +449,7 @@ class MemoryEngineInterface(ABC):
bank_id: str,
*,
request_context: "RequestContext",
force_refresh: bool = False,
) -> dict[str, Any]:
"""
Get statistics about memory nodes and links for a bank.
@@ -456,6 +457,8 @@ class MemoryEngineInterface(ABC):
Args:
bank_id: The memory bank ID.
request_context: Request context for authentication.
force_refresh: Bypass the cached value and recompute (also refreshes
the cache for subsequent callers).
Returns:
Dict with node_counts, link_counts, link_counts_by_fact_type
@@ -253,6 +253,7 @@ def create_llm_provider(
prompt_cache_enabled: bool = False,
litellmrouter_config: dict[str, Any] | None = None,
gemini_service_tier: str | None = None,
timeout: float | None = None,
) -> Any: # Returns LLMInterface
"""
Factory function to create the appropriate LLM provider implementation.
@@ -272,12 +273,20 @@ def create_llm_provider(
VertexAI and LiteLLM providers (each merges them in its own parameter
space). Keys must use each provider's native names (e.g. ``max_tokens``
for OpenAI/Anthropic vs ``max_output_tokens`` for Gemini).
default_headers: Custom headers passed as ``default_headers`` to provider SDK clients
(used by operators routing through proxies / request-tracing middleware). Currently
wired into the Anthropic provider; other providers may opt in as needed.
default_headers: Custom headers passed to provider SDK clients (used by operators
routing through proxies / request-tracing middleware). Wired into the Anthropic
provider (SDK ``default_headers``) and the LiteLLM-backed providers — ``litellm``,
``litellmrouter`` and ``bedrock`` — as the LiteLLM ``extra_headers`` completion
kwarg; other providers may opt in as needed.
vertexai_project_id: Vertex AI project ID (for VertexAI provider).
vertexai_region: Vertex AI region (for VertexAI provider).
vertexai_credentials: Vertex AI credentials object (for VertexAI provider).
timeout: Per-request LLM timeout in seconds (resolved by the caller from the
per-operation/global config). Threaded into the providers that honour a
configurable request timeout (LiteLLM, LiteLLM Router, OpenAI-compatible,
Nous). ``None`` lets each provider fall back to its own default
(``HINDSIGHT_API_LLM_TIMEOUT`` / ``DEFAULT_LLM_TIMEOUT`` for those four;
Anthropic and Gemini keep their provider-specific defaults).
Returns:
LLMInterface implementation for the specified provider.
@@ -375,6 +384,8 @@ def create_llm_provider(
model=model,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
default_headers=default_headers,
timeout=timeout,
)
elif provider_lower == "litellmrouter":
@@ -393,6 +404,8 @@ def create_llm_provider(
config=litellmrouter_config,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
default_headers=default_headers,
timeout=timeout,
)
elif provider_lower == "bedrock":
@@ -405,7 +418,9 @@ def create_llm_provider(
model=bedrock_model,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
default_headers=default_headers,
bedrock_service_tier=bedrock_service_tier,
timeout=timeout,
)
elif provider_lower == "llamacpp":
@@ -452,6 +467,7 @@ def create_llm_provider(
model=model,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
timeout=timeout,
)
elif provider_lower in (
@@ -478,6 +494,7 @@ def create_llm_provider(
groq_service_tier=groq_service_tier,
openai_service_tier=openai_service_tier,
extra_body=extra_body,
timeout=timeout,
)
else:
@@ -510,6 +527,10 @@ class LLMProvider:
vertexai_project_id: str | None = None,
vertexai_region: str | None = None,
vertexai_service_account_key: str | None = None,
timeout: float | None = None,
max_retries: int | None = None,
initial_backoff: float | None = None,
max_backoff: float | None = None,
):
"""
Initialize LLM provider.
@@ -538,6 +559,17 @@ class LLMProvider:
``"us-central1"`` when ``None``).
vertexai_service_account_key: Path to a Vertex AI service-account key file for
``provider="vertexai"`` (uses ADC when ``None``).
timeout: Per-request LLM timeout in seconds. Resolved by the caller from the
per-operation/global config (``retain_llm_timeout`` falling back to
``llm_timeout``, etc.). ``None`` lets each provider apply its own default.
max_retries: Default retry-attempt budget for ``call`` / ``call_with_tools``
when the per-call argument is omitted. Resolved by the caller from the
per-operation/global config (``reflect_llm_max_retries`` falling back to
``llm_max_retries``, etc.). ``None`` keeps each method's own fallback.
initial_backoff: Default initial retry backoff (seconds), same resolution as
``max_retries``. ``None`` keeps each method's own fallback.
max_backoff: Default maximum retry backoff (seconds), same resolution as
``max_retries``. ``None`` keeps each method's own fallback.
This constructor uses every argument as passed and does not read global
``HindsightConfig``: resolving the server-level default for a ``None`` argument is the
@@ -551,6 +583,15 @@ class LLMProvider:
self.base_url = base_url
self.model = model
self.reasoning_effort = reasoning_effort
# Per-request timeout (seconds). Used verbatim — the caller resolves the
# per-operation/global fallback. ``None`` defers to the provider default.
self.timeout = timeout
# Default retry policy for call()/call_with_tools(). The caller resolves the
# per-operation/global fallback; ``None`` keeps each method's own fallback so
# providers built without a resolved config (from_env, tests) are unchanged.
self.max_retries = max_retries
self.initial_backoff = initial_backoff
self.max_backoff = max_backoff
self.litellmrouter_config = litellmrouter_config
# Service tiers from hierarchical config (not env vars)
self.groq_service_tier = groq_service_tier
@@ -701,6 +742,7 @@ class LLMProvider:
gemini_safety_settings=self.gemini_safety_settings,
prompt_cache_enabled=self.prompt_cache_enabled,
litellmrouter_config=router_config,
timeout=self.timeout,
)
# Backward compatibility: Keep mock provider properties
@@ -757,9 +799,9 @@ class LLMProvider:
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "memory",
max_retries: int = 10,
initial_backoff: float = 1.0,
max_backoff: float = 60.0,
max_retries: int | None = None,
initial_backoff: float | None = None,
max_backoff: float | None = None,
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
@@ -774,9 +816,12 @@ class LLMProvider:
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature (0.0-2.0).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
max_retries: Maximum retry attempts. ``None`` uses the provider's configured
default (per-operation/global ``llm_max_retries``), else 10.
initial_backoff: Initial backoff time in seconds. ``None`` uses the provider's
configured default (``llm_initial_backoff``), else 1.0.
max_backoff: Maximum backoff time in seconds. ``None`` uses the provider's
configured default (``llm_max_backoff``), else 60.0.
skip_validation: Return raw JSON without Pydantic validation.
strict_schema: Per-call override requesting grammar-enforced (json_schema strict)
structured output instead of the soft json_object path. The server-level
@@ -801,6 +846,20 @@ class LLMProvider:
structured = "+structured" if response_format is not None else ""
set_stage(f"llm.{self.provider}.{scope}{structured}")
# Resolve the retry policy: explicit per-call arg wins, else the provider's
# configured per-operation/global default, else this method's own fallback.
max_retries = (
max_retries if max_retries is not None else (self.max_retries if self.max_retries is not None else 10)
)
initial_backoff = (
initial_backoff
if initial_backoff is not None
else (self.initial_backoff if self.initial_backoff is not None else 1.0)
)
max_backoff = (
max_backoff if max_backoff is not None else (self.max_backoff if self.max_backoff is not None else 60.0)
)
# Resolve strict-schema once, here, rather than in each provider: the
# per-call argument OR the server-level HINDSIGHT_API_LLM_STRICT_SCHEMA
# flag. Providers with a json_schema response_format (OpenAI-compatible,
@@ -903,9 +962,9 @@ class LLMProvider:
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
max_retries: int | None = None,
initial_backoff: float | None = None,
max_backoff: float | None = None,
tool_choice: str | dict[str, Any] = "auto",
cached_prefix: str | None = None,
) -> "LLMToolCallResult":
@@ -918,9 +977,12 @@ class LLMProvider:
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature (0.0-2.0).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
max_retries: Maximum retry attempts. ``None`` uses the provider's configured
default (per-operation/global ``llm_max_retries``), else 5.
initial_backoff: Initial backoff time in seconds. ``None`` uses the provider's
configured default (``llm_initial_backoff``), else 1.0.
max_backoff: Maximum backoff time in seconds. ``None`` uses the provider's
configured default (``llm_max_backoff``), else 30.0.
tool_choice: How to choose tools - "auto", "none", "required", or {"type": "function", "function": {"name": "..."}}
Returns:
@@ -930,6 +992,20 @@ class LLMProvider:
set_stage(f"llm.{self.provider}.{scope}+tools")
# Resolve the retry policy: explicit per-call arg wins, else the provider's
# configured per-operation/global default, else this method's own fallback.
max_retries = (
max_retries if max_retries is not None else (self.max_retries if self.max_retries is not None else 5)
)
initial_backoff = (
initial_backoff
if initial_backoff is not None
else (self.initial_backoff if self.initial_backoff is not None else 1.0)
)
max_backoff = (
max_backoff if max_backoff is not None else (self.max_backoff if self.max_backoff is not None else 30.0)
)
# Failures forwarded to the GenAI recorder; successes recorded by providers.
from ..tracing import get_span_recorder
from .llm_trace import (
@@ -1168,9 +1244,12 @@ class LLMProvider:
# does so without building the full HindsightConfig, keeping from_env() a
# lightweight env-only loader (see test_llm_provider_from_env_keeps_lightweight_loader).
from ..config import (
DEFAULT_LLM_GROQ_SERVICE_TIER,
DEFAULT_LLM_OPENAI_SERVICE_TIER,
DEFAULT_LLM_PROMPT_CACHE_ENABLED,
DEFAULT_LLM_PROVIDER,
DEFAULT_LLM_REASONING_EFFORT,
DEFAULT_LLM_TIMEOUT,
ENV_LLM_API_KEY,
ENV_LLM_BASE_URL,
ENV_LLM_BEDROCK_SERVICE_TIER,
@@ -1178,11 +1257,14 @@ class LLMProvider:
ENV_LLM_EXTRA_BODY,
ENV_LLM_GEMINI_SAFETY_SETTINGS,
ENV_LLM_GEMINI_SERVICE_TIER,
ENV_LLM_GROQ_SERVICE_TIER,
ENV_LLM_LITELLMROUTER_CONFIG,
ENV_LLM_MODEL,
ENV_LLM_OPENAI_SERVICE_TIER,
ENV_LLM_PROMPT_CACHE_ENABLED,
ENV_LLM_PROVIDER,
ENV_LLM_REASONING_EFFORT,
ENV_LLM_TIMEOUT,
ENV_LLM_VERTEXAI_PROJECT_ID,
ENV_LLM_VERTEXAI_REGION,
ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY,
@@ -1222,6 +1304,8 @@ class LLMProvider:
reasoning_effort=os.getenv(ENV_LLM_REASONING_EFFORT, DEFAULT_LLM_REASONING_EFFORT),
extra_body=extra_body,
default_headers=default_headers,
groq_service_tier=os.getenv(ENV_LLM_GROQ_SERVICE_TIER, DEFAULT_LLM_GROQ_SERVICE_TIER),
openai_service_tier=os.getenv(ENV_LLM_OPENAI_SERVICE_TIER, DEFAULT_LLM_OPENAI_SERVICE_TIER),
bedrock_service_tier=os.getenv(ENV_LLM_BEDROCK_SERVICE_TIER) or None,
gemini_service_tier=(
parse_gemini_service_tier(os.getenv(ENV_LLM_GEMINI_SERVICE_TIER))
@@ -1234,6 +1318,7 @@ class LLMProvider:
vertexai_project_id=os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID) or None,
vertexai_region=os.getenv(ENV_LLM_VERTEXAI_REGION) or None,
vertexai_service_account_key=os.getenv(ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY) or None,
timeout=float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT))),
)
File diff suppressed because it is too large Load Diff
@@ -5,13 +5,35 @@ import logging
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from hindsight_api.config import DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT
from .base import FileParser
if TYPE_CHECKING:
from markitdown import StreamInfo
logger = logging.getLogger(__name__)
# Extensions whose markitdown converters decode the raw bytes as text. markitdown
# samples only the first chunk for charset detection, so a UTF-8 file with a long
# ASCII-only prefix is mis-detected as ASCII; the JSON/ipynb converter then crashes
# decoding the first multibyte byte. Passing an explicit UTF-8 hint when the bytes
# are valid UTF-8 sidesteps the faulty detection without affecting other encodings.
_TEXT_EXTENSIONS = {
".json",
".jsonl",
".ipynb",
".txt",
".text",
".md",
".markdown",
".csv",
".html",
".htm",
}
@dataclass(frozen=True)
class MarkitdownOcrOptions:
@@ -134,8 +156,9 @@ class MarkitdownParser(FileParser):
tmp_path = tmp.name
try:
# Parse using markitdown
result = self._markitdown.convert(tmp_path)
# Parse using markitdown, passing an explicit charset hint for text
# files to avoid markitdown's sample-based (and crash-prone) detection.
result = self._markitdown.convert(tmp_path, stream_info=self._utf8_stream_info(file_data, filename))
if not result or not result.text_content:
raise RuntimeError(f"No content extracted from '{filename}'")
@@ -153,6 +176,23 @@ class MarkitdownParser(FileParser):
except Exception:
pass
@staticmethod
def _utf8_stream_info(file_data: bytes, filename: str) -> "StreamInfo | None":
"""Return a UTF-8 charset hint for text files that decode cleanly as UTF-8.
Returns None for binary files or non-UTF-8 text so markitdown falls back
to its own detection.
"""
if Path(filename).suffix.lower() not in _TEXT_EXTENSIONS:
return None
try:
file_data.decode("utf-8")
except UnicodeDecodeError:
return None
from markitdown import StreamInfo
return StreamInfo(charset="utf-8")
@staticmethod
def _is_image_file(filename: str) -> bool:
"""Return whether the file type needs OCR to extract useful text."""
@@ -119,12 +119,14 @@ class ClaudeCodeLLM(LLMInterface):
Raises:
RuntimeError: If the connection test fails.
"""
from ...config import get_config
try:
test_messages = [{"role": "user", "content": "test"}]
await self.call(
messages=test_messages,
max_completion_tokens=10,
temperature=0.0,
temperature=get_config().llm_temperature_verification,
scope="verification",
max_retries=0,
)
@@ -71,6 +71,7 @@ class LiteLLMLLM(LLMInterface):
timeout: float | None = None,
extra_body: dict[str, Any] | None = None,
bedrock_service_tier: str | None = None,
default_headers: dict[str, Any] | None = None,
**kwargs: Any,
):
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
@@ -84,6 +85,13 @@ class LiteLLMLLM(LLMInterface):
# drops any the target model rejects (litellm.drop_params=True below).
# Sourced from llm_extra_body (env: HINDSIGHT_API_LLM_EXTRA_BODY).
self._extra_body: dict[str, Any] = extra_body or {}
# Operator-configured default headers forwarded to litellm.acompletion as
# ``extra_headers`` (used by deployments routing through proxies / request-
# tracing middleware). Mirrors the Anthropic provider's default_headers
# wiring. Sourced from llm_default_headers (env: HINDSIGHT_API_LLM_DEFAULT_HEADERS).
# Copied so a caller-owned dict can't be mutated through us, and a fresh
# copy is handed to each call below to avoid cross-request contamination.
self._default_headers: dict[str, Any] = dict(default_headers or {})
self.bedrock_service_tier = bedrock_service_tier
try:
@@ -100,12 +108,14 @@ class LiteLLMLLM(LLMInterface):
raise RuntimeError("LiteLLM SDK not installed. Run: uv add litellm or pip install litellm") from e
async def verify_connection(self) -> None:
from ...config import get_config
try:
test_messages = [{"role": "user", "content": "test"}]
await self.call(
messages=test_messages,
max_completion_tokens=50,
temperature=0.0,
temperature=get_config().llm_temperature_verification,
scope="verification",
max_retries=0,
)
@@ -144,6 +154,13 @@ class LiteLLMLLM(LLMInterface):
for key, value in self._extra_body.items():
kwargs.setdefault(key, value)
# Forward operator-configured default headers as ``extra_headers`` so they
# reach the provider behind LiteLLM (proxies / request-tracing middleware).
# ``setdefault`` keeps any explicit per-call ``extra_headers`` authoritative;
# a per-call copy prevents LiteLLM/downstream from mutating the stored dict.
if self._default_headers:
kwargs.setdefault("extra_headers", dict(self._default_headers))
# Bedrock service tier: flex (50% cheaper), priority, or reserved
if self.model.startswith("bedrock/") and self.bedrock_service_tier is not None:
kwargs["service_tier"] = self.bedrock_service_tier
@@ -408,6 +425,14 @@ class LiteLLMLLM(LLMInterface):
self._acompletion(**call_kwargs),
timeout=self.timeout,
)
# Stash usage before the tool-call argument parse below, which
# can raise json.JSONDecodeError locally even though the provider
# already billed for these tokens; without this the error trace
# records 0/0 tokens (#2387). Mirrors call() and the anthropic/
# gemini call_with_tools paths so the litellm tool path (and the
# LiteLLMRouterLLM subclass that inherits this method) completes
# the #2396 usage-on-error coverage.
stash_response_usage(_usage_from_litellm_response(response))
message = response.choices[0].message
content = message.content
@@ -146,16 +146,28 @@ class LiteLLMRouterLLM(LiteLLMLLM):
kwargs["max_completion_tokens"] = self._cap_max_completion_tokens(max_completion_tokens)
if temperature is not None:
kwargs["temperature"] = temperature
# Forward operator-configured default headers as ``extra_headers`` so they
# reach the provider behind the Router (proxies / request-tracing middleware).
# This override deliberately omits api_key/base_url/extra_body (those live in
# the per-deployment Router config), but headers are a cross-cutting operator
# concern, so we inject them here too — mirroring the base provider.
# ``setdefault`` keeps any explicit per-call ``extra_headers`` authoritative;
# a per-call copy prevents LiteLLM/downstream from mutating the stored dict.
if self._default_headers:
kwargs.setdefault("extra_headers", dict(self._default_headers))
return kwargs
async def verify_connection(self) -> None:
from hindsight_api.engine.llm_interface import OutputTooLongError
from ...config import get_config
try:
await self.call(
messages=[{"role": "user", "content": "test"}],
max_completion_tokens=50,
temperature=0.0,
temperature=get_config().llm_temperature_verification,
scope="verification",
max_retries=0,
)
@@ -101,7 +101,7 @@ class MockLLM(LLMInterface):
messages: List of message dicts with 'role' and 'content'.
response_format: Optional Pydantic model for structured output.
max_completion_tokens: Not used in mock.
temperature: Not used in mock.
temperature: Recorded on the call record for test assertions.
scope: Scope identifier for tracking.
max_retries: Not used in mock.
initial_backoff: Not used in mock.
@@ -123,6 +123,9 @@ class MockLLM(LLMInterface):
if response_format and hasattr(response_format, "__name__")
else str(response_format),
"scope": scope,
# Record the temperature so tests can assert per-operation temperature
# wiring (None means the parameter was omitted from the call).
"temperature": temperature,
}
self._mock_calls.append(call_record)
logger.debug(f"Mock LLM call recorded: scope={scope}, model={self.model}")
@@ -208,7 +211,7 @@ class MockLLM(LLMInterface):
messages: List of message dicts. Can include tool results with role='tool'.
tools: List of tool definitions in OpenAI format.
max_completion_tokens: Not used in mock.
temperature: Not used in mock.
temperature: Recorded on the call record for test assertions.
scope: Scope identifier for tracking.
max_retries: Not used in mock.
initial_backoff: Not used in mock.
@@ -225,6 +228,9 @@ class MockLLM(LLMInterface):
"messages": messages,
"tools": [t.get("function", {}).get("name") for t in tools],
"scope": scope,
# Record the temperature so tests can assert per-operation temperature
# wiring (None means the parameter was omitted from the call).
"temperature": temperature,
}
self._mock_calls.append(call_record)
@@ -90,12 +90,87 @@ _LEAKED_JSON_SUFFIX = re.compile(
r'\s*```(?:json)?\s*\{[^}]*(?:"(?:observation_ids|memory_ids|mental_model_ids)"|\})\s*```\s*$',
re.DOTALL | re.IGNORECASE,
)
_LEAKED_JSON_OBJECT = re.compile(
r'\s*\{[^{]*"(?:observation_ids|memory_ids|mental_model_ids|answer)"[^}]*\}\s*$', re.DOTALL
)
_TRAILING_IDS_PATTERN = re.compile(
r"\s*(?:observation_ids|memory_ids|mental_model_ids)\s*[=:]\s*\[.*?\]\s*$", re.DOTALL | re.IGNORECASE
)
_JSON_CODE_FENCE_PATTERN = re.compile(r"^\s*```(?:json)?\s*(\{.*\})\s*```\s*$", re.DOTALL | re.IGNORECASE)
_DONE_ARGUMENT_KEYS = frozenset(
{
"answer",
"directive_compliance",
"memory_ids",
"mental_model_ids",
"observation_ids",
"model_ids",
}
)
_DONE_ARGUMENT_MARKER_KEYS = _DONE_ARGUMENT_KEYS - {"answer"}
_LEAKED_JSON_ID_KEYS = frozenset({"memory_ids", "mental_model_ids", "observation_ids", "model_ids"})
def _unwrap_leaked_done_arguments(text: str) -> str | None:
"""Return the answer when a done tool call was rendered as JSON text.
Some providers leak the done tool's argument object instead of surfacing it
as a native tool call, e.g. {"answer": "...", "memory_ids": [...]}. Only
unwrap objects that match the done argument shape so normal JSON answers
stay intact.
"""
candidate = text.strip()
if not candidate:
return None
fenced = _JSON_CODE_FENCE_PATTERN.match(candidate)
if fenced:
candidate = fenced.group(1).strip()
try:
payload = json.loads(candidate)
except json.JSONDecodeError:
return None
if not isinstance(payload, dict):
return None
answer = payload.get("answer")
if not isinstance(answer, str) or not answer.strip():
return None
keys = set(payload)
if not keys.intersection(_DONE_ARGUMENT_MARKER_KEYS):
return None
if not keys.issubset(_DONE_ARGUMENT_KEYS):
return None
for key in ("memory_ids", "mental_model_ids", "observation_ids", "model_ids"):
value = payload.get(key)
if value is not None and not isinstance(value, list):
return None
return answer.strip()
def _strip_trailing_id_json_object(text: str) -> str:
stripped = text.rstrip()
if not stripped.endswith("}"):
return text.strip()
start = stripped.rfind("{")
if start < 0:
return text.strip()
try:
payload = json.loads(stripped[start:])
except json.JSONDecodeError:
return text.strip()
if not isinstance(payload, dict) or not payload:
return text.strip()
keys = set(payload)
if not keys.issubset(_LEAKED_JSON_ID_KEYS):
return text.strip()
return stripped[:start].strip()
def _clean_answer_text(text: str) -> str:
@@ -104,6 +179,10 @@ def _clean_answer_text(text: str) -> str:
Some LLMs output the done() call as text instead of a proper tool call.
This strips out patterns like: done({"answer": "...", ...})
"""
unwrapped = _unwrap_leaked_done_arguments(text)
if unwrapped is not None:
return unwrapped
# Remove done() call pattern from the end of the text
cleaned = _DONE_CALL_PATTERN.sub("", text).strip()
return cleaned if cleaned else text
@@ -122,13 +201,17 @@ def _clean_done_answer(text: str) -> str:
if not text:
return text
unwrapped = _unwrap_leaked_done_arguments(text)
if unwrapped is not None:
return unwrapped
cleaned = text
# Remove leaked JSON in code blocks at the end
cleaned = _LEAKED_JSON_SUFFIX.sub("", cleaned).strip()
# Remove leaked raw JSON objects at the end
cleaned = _LEAKED_JSON_OBJECT.sub("", cleaned).strip()
cleaned = _strip_trailing_id_json_object(cleaned)
# Remove trailing ID patterns
cleaned = _TRAILING_IDS_PATTERN.sub("", cleaned).strip()
@@ -243,7 +243,7 @@ class MemoryFact(BaseModel):
id: str = Field(description="Unique identifier for the memory fact")
text: str = Field(description="The actual text content of the memory")
fact_type: str = Field(description="Type of fact: 'world', 'experience', 'opinion', or 'observation'")
fact_type: str = Field(description="Type of fact: 'world', 'experience', or 'observation'")
entities: list[str] | None = Field(None, description="Entity names mentioned in this fact")
context: str | None = Field(None, description="Additional context for the memory")
occurred_start: str | None = Field(None, description="ISO format date when the event started occurring")
@@ -193,7 +193,7 @@ class ExtractedFact(BaseModel):
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' = objective/external facts. 'assistant' = first-person actions, experiences, or observations by the speaker."
description="'world' = objective/external facts, including user preferences, rules, corrections, and constraints even when stated during a conversation. 'assistant' = actions, experiences, or observations the assistant/agent actually performed."
)
entities: list[Entity] | None = Field(default=None, description="People, places, concepts")
causal_relations: list[FactCausalRelation] | None = Field(
@@ -296,7 +296,7 @@ class ExtractedFactVerbose(BaseModel):
)
fact_type: Literal["world", "assistant"] = Field(
description="'world' = objective/external facts about other people, events, general knowledge. 'assistant' = first-person actions, experiences, or observations by the speaker (e.g., 'I changed X', 'I discovered Y')."
description="'world' = objective/external facts about the user, other people, events, general knowledge, preferences, rules, corrections, or constraints. 'assistant' = actions, experiences, or observations the assistant/agent actually performed (e.g., 'I changed X', 'I discovered Y')."
)
entities: list[Entity] | None = Field(
@@ -346,7 +346,7 @@ class ExtractedFactNoCausal(BaseModel):
occurred_start: str | None = Field(default=None, description="WHEN the event happened (ISO timestamp).")
occurred_end: str | None = Field(default=None, description="WHEN the event ended (ISO timestamp).")
fact_type: Literal["world", "assistant"] = Field(
description="'world' = about the user/others. 'assistant' = experience with assistant."
description="'world' = about the user/others, including user preferences, rules, corrections, and constraints. 'assistant' = actions or experiences the assistant/agent actually performed."
)
entities: list[Entity] | None = Field(
default=None,
@@ -663,8 +663,8 @@ fact_kind:
- "conversation": Ongoing state, preference, trait (no dates)
fact_type:
- "world": About other people, external events, general knowledge, objective facts
- "assistant": First-person actions, experiences, or observations by the speaker/author (e.g., "I changed X", "I discovered Y", "I debugged Z"). Also includes interactions with the user (requests, recommendations). If the narrator describes something they did, tried, learned, or decided — use "assistant".
- "world": Objective/external facts, including the user's preferences, rules, corrections, constraints, plans, traits, or context. These stay "world" even when the user states them during an assistant interaction (e.g., "User prefers browser_navigate over web_search", "User corrected the project deadline").
- "assistant": Actions, experiences, or observations the assistant/agent actually performed (e.g., "I changed X", "I discovered Y", "I debugged Z"). Use this for the assistant/agent doing, trying, learning, deciding, recommending, or responding — not merely for user facts mentioned in conversation.
══════════════════════════════════════════════════════════════════════════
TEMPORAL HANDLING
@@ -766,7 +766,7 @@ RULES:
- 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."""
- fact_type: use "world" for user preferences, rules, corrections, constraints, traits, and other objective facts, even when stated during an assistant interaction. Use "assistant" only for actions or experiences the assistant/agent actually performed."""
VERBATIM_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
retain_mission_section="{retain_mission_section}",
@@ -867,8 +867,8 @@ For CONVERSATIONS (fact_kind="conversation"):
FACT TYPE
══════════════════════════════════════════════════════════════════════════
- **world**: User's life, other people, events (would exist without this conversation)
- **assistant**: Interactions with assistant (requests, recommendations, help)
- **world**: User's life, preferences, rules, corrections, constraints, other people, and events (facts that would exist without this conversation)
- **assistant**: Actions or experiences the assistant/agent actually performed while helping the user (requests, recommendations, help)
⚠️ CRITICAL for assistant facts: ALWAYS capture the user's request/question in the fact!
Include: what the user asked, what problem they wanted solved, what context they provided
@@ -1203,9 +1203,17 @@ def _build_request_body(llm_config, config, prompt: str, user_message: str, resp
request_body = {
"model": llm_config.model,
"messages": [{"role": "system", "content": prompt}, {"role": "user", "content": user_message}],
"temperature": 0.1,
}
# Honour the configured retain temperature. ``None`` omits the parameter
# entirely (for models like Azure GPT-5.5 that reject explicit temperatures),
# mirroring LLMProvider.call, which drops temperature when it is None. The
# batch path builds the request body directly instead of going through
# LLMProvider.call (#2469 only de-hardcoded the streaming path), so it must
# apply the same rule here.
if config.llm_temperature_retain is not None:
request_body["temperature"] = config.llm_temperature_retain
# Add max_completion_tokens if configured
if config.retain_max_completion_tokens:
request_body["max_completion_tokens"] = config.retain_max_completion_tokens
@@ -1314,7 +1322,7 @@ async def _extract_facts_from_chunk(
messages=[{"role": "system", "content": prompt}, {"role": "user", "content": user_message}],
response_format=response_schema,
scope="retain_extract_facts",
temperature=0.1,
temperature=config.llm_temperature_retain,
max_completion_tokens=config.retain_max_completion_tokens,
max_retries=llm_max_retries,
initial_backoff=initial_backoff,
@@ -1814,7 +1822,14 @@ async def extract_facts_from_text(
total_usage = total_usage + chunk_usage
if failed_chunks:
failed_summary = ", ".join(f"chunk {idx}: {type(err).__name__}" for idx, err in failed_chunks[:5])
# Include the exception message — not just the type — so operators
# can tell a structured-JSON parse failure apart from a rate limit
# apart from a network 5xx, all of which can surface as the same
# exception types. The error_message we propagate to the
# async_operations row is the only inspection surface a worker-side
# failure leaves behind, and a bare "chunk 0: RuntimeError" is not
# actionable.
failed_summary = ", ".join(f"chunk {idx}: {type(err).__name__}: {err}" for idx, err in failed_chunks[:5])
quota_errors = [err for _, err in failed_chunks if isinstance(err, ProviderRateLimitResetError)]
if quota_errors and len(quota_errors) == len(failed_chunks):
retry_at = max(err.retry_at for err in quota_errors)
@@ -5,6 +5,7 @@ Think operation utilities for formulating answers based on agent and world facts
import logging
from datetime import datetime
from ...config import get_config
from ..response_models import DispositionTraits, MemoryFact
logger = logging.getLogger(__name__)
@@ -251,7 +252,7 @@ async def reflect(
answer_text = await llm_config.call(
messages=[{"role": "system", "content": system_message}, {"role": "user", "content": prompt}],
scope="memory_think",
temperature=0.9,
temperature=get_config().llm_temperature_reflect,
max_completion_tokens=1000,
)
@@ -39,7 +39,9 @@ from hindsight_api.extensions.operation_validator import (
BankListContext,
BankListResult,
BankReadContext,
BankReadOperation,
BankWriteContext,
BankWriteOperation,
# Consolidation operation
ConsolidateContext,
ConsolidateResult,
@@ -54,6 +56,7 @@ from hindsight_api.extensions.operation_validator import (
OperationValidationError,
OperationValidatorExtension,
PrecheckContext,
PrecheckOperation,
RecallContext,
RecallResult,
ReflectContext,
@@ -87,6 +90,7 @@ __all__ = [
"OperationValidationError",
"OperationValidatorExtension",
"PrecheckContext",
"PrecheckOperation",
"RecallContext",
"RecallResult",
"ReflectContext",
@@ -98,7 +102,9 @@ __all__ = [
"BankListContext",
"BankListResult",
"BankReadContext",
"BankReadOperation",
"BankWriteContext",
"BankWriteOperation",
# Operation Validator - Consolidation
"ConsolidateContext",
"ConsolidateResult",
@@ -3,6 +3,7 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from enum import StrEnum
from typing import TYPE_CHECKING
from hindsight_api.extensions.base import Extension
@@ -82,6 +83,18 @@ class ValidationResult:
# =============================================================================
class PrecheckOperation(StrEnum):
"""Route operation names passed to the pre-body-parse precheck hook."""
DRY_RUN_EXTRACT = "dry_run_extract"
FILES_RETAIN = "files_retain"
MENTAL_MODEL_CREATE = "mental_model_create"
MENTAL_MODEL_REFRESH = "mental_model_refresh"
RECALL = "recall"
REFLECT = "reflect"
RETAIN = "retain"
@dataclass
class PrecheckContext:
"""Context for a pre-body-parse precheck on an operation.
@@ -91,9 +104,7 @@ class PrecheckContext:
therefore intentionally carries only the cheap, already-resolved
pieces of request state:
- ``operation``: a short string identifying the route, e.g. ``"retain"``,
``"recall"``, ``"reflect"``, ``"files_retain"``, ``"mental_model_create"``,
``"mental_model_refresh"``.
- ``operation``: a short string-compatible enum identifying the route.
- ``bank_id``: parsed from the URL path.
- ``request_context``: the authenticated :class:`RequestContext` (tenant
already resolved by the tenant extension).
@@ -108,7 +119,7 @@ class PrecheckContext:
the source of truth for the precise per-call cost / quota arithmetic.
"""
operation: str
operation: PrecheckOperation
bank_id: str
request_context: "RequestContext"
content_length: int | None = None
@@ -303,12 +314,77 @@ class ConsolidateResult:
# =============================================================================
class BankReadOperation(StrEnum):
"""Bank-scoped read operation names passed to validate_bank_read."""
GET_BANK_CONFIG = "get_bank_config"
GET_BANK_PROFILE = "get_bank_profile"
GET_BANK_STATS = "get_bank_stats"
GET_CHUNK = "get_chunk"
GET_DIRECTIVE = "get_directive"
GET_DOCUMENT = "get_document"
GET_ENTITY = "get_entity"
GET_ENTITY_GRAPH = "get_entity_graph"
GET_ENTITY_STATE = "get_entity_state"
GET_GRAPH_DATA = "get_graph_data"
GET_MEMORIES_TIMESERIES = "get_memories_timeseries"
GET_MEMORY_UNIT = "get_memory_unit"
GET_OBSERVATION_HISTORY = "get_observation_history"
GET_OPERATION_STATUS = "get_operation_status"
LIST_DIRECTIVES = "list_directives"
LIST_DOCUMENT_CHUNKS = "list_document_chunks"
LIST_DOCUMENTS = "list_documents"
LIST_ENTITIES = "list_entities"
LIST_MEMORY_UNITS = "list_memory_units"
LIST_MENTAL_MODEL_TAGS = "list_mental_model_tags"
LIST_MENTAL_MODELS = "list_mental_models"
LIST_OBSERVATION_SCOPES = "list_observation_scopes"
LIST_OPERATIONS = "list_operations"
LIST_TAGS = "list_tags"
LIST_WEBHOOK_DELIVERIES = "list_webhook_deliveries"
LIST_WEBHOOKS = "list_webhooks"
class BankWriteOperation(StrEnum):
"""Bank-scoped write operation names passed to validate_bank_write."""
CANCEL_OPERATION = "cancel_operation"
CLEAR_MENTAL_MODEL = "clear_mental_model"
CLEAR_OBSERVATIONS = "clear_observations"
CLEAR_OBSERVATIONS_FOR_MEMORY = "clear_observations_for_memory"
CREATE_DIRECTIVE = "create_directive"
CREATE_MENTAL_MODEL = "create_mental_model"
CREATE_WEBHOOK = "create_webhook"
DELETE_BANK = "delete_bank"
DELETE_DIRECTIVE = "delete_directive"
DELETE_DOCUMENT = "delete_document"
DELETE_MENTAL_MODEL = "delete_mental_model"
DELETE_WEBHOOK = "delete_webhook"
MERGE_BANK_MISSION = "merge_bank_mission"
REPROCESS_DOCUMENT = "reprocess_document"
RESET_BANK_CONFIG = "reset_bank_config"
RETRY_FAILED_CONSOLIDATION = "retry_failed_consolidation"
RETRY_OPERATION = "retry_operation"
RUN_CONSOLIDATION = "run_consolidation"
SET_BANK_MISSION = "set_bank_mission"
SUBMIT_ASYNC_CONSOLIDATION = "submit_async_consolidation"
SUBMIT_ASYNC_GRAPH_MAINTENANCE = "submit_async_graph_maintenance"
UPDATE_BANK = "update_bank"
UPDATE_BANK_CONFIG = "update_bank_config"
UPDATE_BANK_DISPOSITION = "update_bank_disposition"
UPDATE_DIRECTIVE = "update_directive"
UPDATE_DOCUMENT = "update_document"
UPDATE_MEMORY_UNIT = "update_memory_unit"
UPDATE_MENTAL_MODEL = "update_mental_model"
UPDATE_WEBHOOK = "update_webhook"
@dataclass
class BankReadContext:
"""Context for a bank read operation validation (pre-operation)."""
bank_id: str
operation: str # "get_bank_profile", "get_bank_stats"
operation: BankReadOperation
request_context: "RequestContext"
@@ -317,7 +393,7 @@ class BankWriteContext:
"""Context for a bank write operation validation (pre-operation)."""
bank_id: str
operation: str # "delete_bank", "update_bank", "update_bank_disposition", "set_bank_mission", "merge_bank_mission", "clear_observations", "clear_observations_for_memory"
operation: BankWriteOperation
request_context: "RequestContext"
+83 -77
View File
@@ -32,6 +32,7 @@ from sqlalchemy.pool import NullPool
from ._pg_search import normalize_pg_search_tokenizer, pg_search_bm25_columns
from ._vector_index import (
bootstrap_extension,
configured_vector_extension,
detect_vector_extension,
index_type_keyword,
index_using_clause,
@@ -60,6 +61,86 @@ def _detect_vector_extension(conn, vector_extension: str = "pgvector") -> str:
return detect_vector_extension(conn, vector_extension)
def _ensure_pgvector_extension_in_public(conn: Connection) -> None:
"""Ensure pgvector is installed before pgvector-backed migrations run."""
logger.debug("Checking pgvector extension availability...")
# First, check if extension already exists
ext_check = conn.execute(
text(
"SELECT extname, nspname FROM pg_extension e "
"JOIN pg_namespace n ON e.extnamespace = n.oid "
"WHERE extname = 'vector'"
)
).fetchone()
if ext_check:
# Extension exists - check if in correct schema
ext_schema = ext_check[1]
if ext_schema == "public":
logger.info("pgvector extension found in public schema - ready to use")
else:
# Extension in wrong schema - try to fix if we have permissions
logger.warning(
f"pgvector extension found in schema '{ext_schema}' instead of 'public'. Attempting to relocate..."
)
try:
conn.execute(text("DROP EXTENSION vector CASCADE"))
conn.execute(text("SET search_path TO public"))
conn.execute(text("CREATE EXTENSION vector"))
conn.commit()
logger.info("pgvector extension relocated to public schema")
except Exception as e:
# Failed to relocate - log but don't fail if extension exists somewhere
logger.warning(
f"Could not relocate pgvector extension to public schema: {e}. "
f"Continuing with extension in '{ext_schema}' schema."
)
conn.rollback()
else:
# Extension doesn't exist - try to install
logger.info("pgvector extension not found, attempting to install...")
try:
conn.execute(text("SET search_path TO public"))
conn.execute(text("CREATE EXTENSION vector"))
conn.commit()
logger.info("pgvector extension installed in public schema")
except Exception as e:
# Installation failed - this is only fatal if extension truly doesn't exist
# Check one more time in case another process installed it
conn.rollback()
ext_recheck = conn.execute(
text(
"SELECT nspname FROM pg_extension e "
"JOIN pg_namespace n ON e.extnamespace = n.oid "
"WHERE extname = 'vector'"
)
).fetchone()
if ext_recheck:
logger.warning(
f"Could not install pgvector extension (permission denied?), "
f"but extension exists in '{ext_recheck[0]}' schema. Continuing..."
)
else:
# Extension truly doesn't exist and we can't install it
logger.error(
f"pgvector extension is not installed and cannot be installed: {e}. "
f"Please ensure pgvector is installed by a database administrator. "
f"See: https://github.com/pgvector/pgvector#installation"
)
raise RuntimeError(
"pgvector extension is required but not installed. Please install it with: CREATE EXTENSION vector;"
) from e
def _bootstrap_vector_extension_for_migrations(conn: Connection, vector_extension: str) -> None:
"""Bootstrap the configured vector backend before schema migrations run."""
if vector_extension == "pgvector":
_ensure_pgvector_extension_in_public(conn)
bootstrap_extension(conn, vector_extension)
def _drop_per_bank_vector_indexes(conn: Connection, schema_name: str) -> None:
"""Drop per-bank partial memory_units vector indexes after global ScaNN is ready."""
rows = conn.execute(
@@ -275,83 +356,8 @@ def run_migrations(
logger.debug("Migration advisory lock acquired")
try:
# Ensure pgvector extension is installed globally BEFORE schema migrations
# This is critical: the extension must exist database-wide before any schema
# migrations run, otherwise custom schemas won't have access to vector types
logger.debug("Checking pgvector extension availability...")
# First, check if extension already exists
ext_check = conn.execute(
text(
"SELECT extname, nspname FROM pg_extension e "
"JOIN pg_namespace n ON e.extnamespace = n.oid "
"WHERE extname = 'vector'"
)
).fetchone()
if ext_check:
# Extension exists - check if in correct schema
ext_schema = ext_check[1]
if ext_schema == "public":
logger.info("pgvector extension found in public schema - ready to use")
else:
# Extension in wrong schema - try to fix if we have permissions
logger.warning(
f"pgvector extension found in schema '{ext_schema}' instead of 'public'. "
f"Attempting to relocate..."
)
try:
conn.execute(text("DROP EXTENSION vector CASCADE"))
conn.execute(text("SET search_path TO public"))
conn.execute(text("CREATE EXTENSION vector"))
conn.commit()
logger.info("pgvector extension relocated to public schema")
except Exception as e:
# Failed to relocate - log but don't fail if extension exists somewhere
logger.warning(
f"Could not relocate pgvector extension to public schema: {e}. "
f"Continuing with extension in '{ext_schema}' schema."
)
conn.rollback()
else:
# Extension doesn't exist - try to install
logger.info("pgvector extension not found, attempting to install...")
try:
conn.execute(text("SET search_path TO public"))
conn.execute(text("CREATE EXTENSION vector"))
conn.commit()
logger.info("pgvector extension installed in public schema")
except Exception as e:
# Installation failed - this is only fatal if extension truly doesn't exist
# Check one more time in case another process installed it
conn.rollback()
ext_recheck = conn.execute(
text(
"SELECT nspname FROM pg_extension e "
"JOIN pg_namespace n ON e.extnamespace = n.oid "
"WHERE extname = 'vector'"
)
).fetchone()
if ext_recheck:
logger.warning(
f"Could not install pgvector extension (permission denied?), "
f"but extension exists in '{ext_recheck[0]}' schema. Continuing..."
)
else:
# Extension truly doesn't exist and we can't install it
logger.error(
f"pgvector extension is not installed and cannot be installed: {e}. "
f"Please ensure pgvector is installed by a database administrator. "
f"See: https://github.com/pgvector/pgvector#installation"
)
raise RuntimeError(
"pgvector extension is required but not installed. "
"Please install it with: CREATE EXTENSION vector;"
) from e
vector_extension = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
bootstrap_extension(conn, vector_extension)
vector_extension = configured_vector_extension()
_bootstrap_vector_extension_for_migrations(conn, vector_extension)
# Commit any pending transaction on the advisory-lock connection
# before running migrations. Some code paths above (e.g., the
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api-slim"
version = "0.8.3"
version = "0.8.4"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
+23
View File
@@ -54,6 +54,29 @@ async def _teardown_memory_engine(mem: MemoryEngine) -> None:
unregister_span_recorder(mem._llm_recorder)
@pytest.fixture(autouse=True)
def _cleanup_leaked_span_recorders():
"""Fail-safe for the process-global LLM-trace recorder registry (#2229).
``MemoryEngine.__init__`` registers its recorder in the shared registry, and
only ``close()`` removes it. Tests that construct an engine directly (without
``_teardown_memory_engine``/``close()``) leak an *enabled* recorder; a later
test's LLM calls then get recorded into the shared DB, flaking
``test_llm_trace::test_disabled_writes_no_rows`` (it observes rows for its
bank even though its own recorder is disabled). ``_teardown_memory_engine``
guards the fixtures; this guards everything else by dropping any recorder a
test added to the registry.
"""
from hindsight_api.tracing import get_span_recorder
recorders = get_span_recorder()._recorders
before = {id(r) for r in recorders}
yield
for recorder in list(recorders):
if id(recorder) not in before:
recorders.remove(recorder)
# Default pg0 instance configuration for tests
DEFAULT_PG0_INSTANCE_NAME = "hindsight-test"
DEFAULT_PG0_PORT = int(os.environ.get("HINDSIGHT_TEST_PG_PORT", "5556"))
@@ -88,7 +88,10 @@ async def test_backup_tables_covers_entire_schema(backup_test_schema):
await conn.close()
# alembic_version is migration bookkeeping, not data — never backed up.
schema_tables = {r["table_name"] for r in rows} - {"alembic_version"}
# bank_stats_cache is a derived TTL cache of get_bank_stats results: it has no
# FK to banks (so the restore cascade never touches it) and repopulates itself
# on demand, so it is deliberately not backed up — a restore starts it cold.
schema_tables = {r["table_name"] for r in rows} - {"alembic_version", "bank_stats_cache"}
backup_tables = set(BACKUP_TABLES)
missing = schema_tables - backup_tables
@@ -0,0 +1,148 @@
"""Tests for the table-backed (cross-process) get_bank_stats cache.
On PostgreSQL the engine backs `get_bank_stats` with the `bank_stats_cache`
table (`DistributedBankStatsCache`) instead of a per-process dict, so one
worker's computation is shared with every other worker. These tests verify:
* the PG engine actually selects the distributed cache,
* a computed result is written to the table and served from it on the next call,
* invalidation deletes the row so the next call recomputes, and
* an unreachable cache table degrades to computing without caching rather than
failing the endpoint.
"""
import uuid
import pytest
from hindsight_api import RequestContext
from hindsight_api.engine.bank_stats_cache import DistributedBankStatsCache
from hindsight_api.engine.memory_engine import MemoryEngine, get_current_schema
_PINNED_TTL_SECONDS = 300.0
async def _insert_memory(conn, bank_id: str, text: str, fact_type: str = "experience") -> uuid.UUID:
mem_id = uuid.uuid4()
await conn.execute(
"""
INSERT INTO memory_units (id, bank_id, text, fact_type, event_date, created_at, updated_at, consolidated_at)
VALUES ($1, $2, $3, $4, NOW(), NOW(), NOW(), NOW())
""",
mem_id,
bank_id,
text,
fact_type,
)
return mem_id
async def _ensure_bank(memory: MemoryEngine, bank_id: str, request_context: RequestContext) -> None:
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
def _pin_distributed_cache(memory: MemoryEngine) -> DistributedBankStatsCache:
cache = DistributedBankStatsCache(backend=memory._backend, ttl_seconds=_PINNED_TTL_SECONDS)
memory._bank_stats_cache = cache
return cache
class TestDistributedBankStatsCache:
@pytest.mark.asyncio
async def test_pg_engine_selects_distributed_cache(self, memory: MemoryEngine):
if memory._database_backend_type != "postgresql":
pytest.skip("distributed cache is PostgreSQL-only")
assert isinstance(memory._bank_stats_cache, DistributedBankStatsCache)
@pytest.mark.asyncio
async def test_result_is_written_and_served_from_table(self, memory: MemoryEngine, request_context: RequestContext):
if memory._database_backend_type != "postgresql":
pytest.skip("distributed cache is PostgreSQL-only")
bank_id = f"test-dist-stats-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
pool = await memory._get_pool()
async with pool.acquire() as conn:
await _insert_memory(conn, bank_id, "Alice loves hiking.")
_pin_distributed_cache(memory)
try:
first = await memory.get_bank_stats(bank_id, request_context=request_context)
assert first["node_counts"].get("experience") == 1
# The computed result was persisted to the shared table.
async with pool.acquire() as conn:
rows = await conn.fetchval("SELECT count(*) FROM bank_stats_cache WHERE bank_id = $1", bank_id)
assert rows == 1
# Mutate the underlying data WITHOUT going through an invalidating
# engine method — the long-TTL cache must serve the stale row.
async with pool.acquire() as conn:
await _insert_memory(conn, bank_id, "Bob enjoys cycling.")
served = await memory.get_bank_stats(bank_id, request_context=request_context)
assert served["node_counts"].get("experience") == 1 # still cached
# Invalidating drops the row → next call recomputes the true count.
await memory._bank_stats_cache.invalidate(get_current_schema(), bank_id)
async with pool.acquire() as conn:
rows = await conn.fetchval("SELECT count(*) FROM bank_stats_cache WHERE bank_id = $1", bank_id)
assert rows == 0
fresh = await memory.get_bank_stats(bank_id, request_context=request_context)
assert fresh["node_counts"].get("experience") == 2
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_force_refresh_bypasses_and_updates_cache(
self, memory: MemoryEngine, request_context: RequestContext
):
if memory._database_backend_type != "postgresql":
pytest.skip("distributed cache is PostgreSQL-only")
bank_id = f"test-dist-stats-fresh-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
pool = await memory._get_pool()
async with pool.acquire() as conn:
await _insert_memory(conn, bank_id, "Alice loves hiking.")
_pin_distributed_cache(memory)
try:
# Warm the cache, then mutate the data without invalidation.
assert (await memory.get_bank_stats(bank_id, request_context=request_context))["node_counts"][
"experience"
] == 1
async with pool.acquire() as conn:
await _insert_memory(conn, bank_id, "Bob enjoys cycling.")
# A normal read is served the stale cached count...
stale = await memory.get_bank_stats(bank_id, request_context=request_context)
assert stale["node_counts"]["experience"] == 1
# ...but force_refresh recomputes the true count.
fresh = await memory.get_bank_stats(bank_id, request_context=request_context, force_refresh=True)
assert fresh["node_counts"]["experience"] == 2
# The forced result also refreshed the cache for the next caller.
served = await memory.get_bank_stats(bank_id, request_context=request_context)
assert served["node_counts"]["experience"] == 2
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_degrades_when_cache_table_unreachable(self, memory: MemoryEngine, request_context: RequestContext):
if memory._database_backend_type != "postgresql":
pytest.skip("distributed cache is PostgreSQL-only")
bank_id = f"test-dist-stats-degrade-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
pool = await memory._get_pool()
async with pool.acquire() as conn:
await _insert_memory(conn, bank_id, "Alice loves hiking.")
# Point the cache at a table that does not exist: reads and writes fail,
# so it must fall back to computing the real result (no real table touched).
cache = _pin_distributed_cache(memory)
cache._qualified = lambda schema: '"public".bank_stats_cache_does_not_exist' # type: ignore[method-assign]
try:
stats = await memory.get_bank_stats(bank_id, request_context=request_context)
assert stats["node_counts"].get("experience") == 1
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@@ -3619,3 +3619,39 @@ def test_consolidation_prompt_split_is_cacheable_and_complete():
)
assert "OBSERVATION LIMIT REACHED" in capped
assert "OBSERVATION LIMIT REACHED" not in sys_prompt
@pytest.mark.asyncio
async def test_create_observation_populates_search_vector_native(memory, request_context):
"""Observations created via consolidation must have search_vector populated
when text_search_extension == 'native', so BM25 retrieval finds them."""
from hindsight_api.config import get_config
config = get_config()
if config.text_search_extension != "native":
pytest.skip("Only applies to native text search backend")
bank_id = f"test-search-vector-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
await memory.retain_async(
bank_id=bank_id,
content="Django uses middleware for request processing.",
request_context=request_context,
)
async with memory._pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT search_vector
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
LIMIT 1
""",
bank_id,
)
assert row is not None, "Consolidation should have created an observation"
assert row["search_vector"] is not None, "search_vector must be populated for BM25 retrieval under native backend"
await memory.delete_bank(bank_id, request_context=request_context)
@@ -84,7 +84,13 @@ def _ctx(threshold: float = 0.97):
conn=conn,
memory_engine=types.SimpleNamespace(embeddings=object()),
bank_id="bank1",
config=types.SimpleNamespace(consolidation_dedup_threshold=threshold),
# The merge path builds a search_vector UPDATE clause from the text-search
# config, so these must be present (production defaults: native/english).
config=types.SimpleNamespace(
consolidation_dedup_threshold=threshold,
text_search_extension="native",
text_search_extension_native_language="english",
),
dedup_llm_config=llm,
create_text="YouTube content in Uzbek is very rich.",
create_source_ids=[uuid.uuid4()],
@@ -126,6 +132,16 @@ async def test_dedup_llm_keep_does_not_merge() -> None:
conn.execute.assert_not_called() # kept distinct → no merge
async def test_dedup_llm_missing_action_defaults_to_keep() -> None:
kwargs, conn, llm = _ctx()
llm.call.return_value = _DedupDecision(reason="underfilled structured response")
with _patch_embed(), _patch_probe([_obs("Uzbek content on YouTube is described as very rich.", 0.98)]):
result = await _dedup_reconcile_create(**kwargs)
assert result is None
llm.call.assert_awaited_once()
conn.execute.assert_not_called() # missing action is a conservative no-merge
async def test_dedup_llm_merge_folds_into_twin() -> None:
kwargs, conn, llm = _ctx()
kwargs["create_source_ids"] = [uuid.uuid4(), uuid.uuid4()]
@@ -169,7 +185,13 @@ def _update_ctx(threshold: float = 0.97):
conn=conn,
memory_engine=types.SimpleNamespace(embeddings=object()),
bank_id="bank1",
config=types.SimpleNamespace(consolidation_dedup_threshold=threshold),
# The merge path builds a search_vector UPDATE clause from the text-search
# config, so these must be present (production defaults: native/english).
config=types.SimpleNamespace(
consolidation_dedup_threshold=threshold,
text_search_extension="native",
text_search_extension_native_language="english",
),
dedup_llm_config=llm,
updated_id=_UPDATED_ID,
updated_text="Uzbek content on YouTube is very rich and growing.",
+46 -12
View File
@@ -9,11 +9,19 @@ from fastapi.testclient import TestClient
from hindsight_api.extensions import (
ApiKeyTenantExtension,
AuthenticationError,
BankReadContext,
BankReadOperation,
BankWriteContext,
BankWriteOperation,
# Consolidation operation
ConsolidateContext,
ConsolidateResult,
Extension,
HttpExtension,
OperationValidationError,
OperationValidatorExtension,
PrecheckContext,
PrecheckOperation,
RecallContext,
RecallResult,
ReflectContext,
@@ -21,13 +29,8 @@ from hindsight_api.extensions import (
RequestContext,
RetainContext,
RetainResult,
TenantContext,
TenantExtension,
ValidationResult,
load_extension,
# Consolidation operation
ConsolidateContext,
ConsolidateResult,
)
@@ -68,6 +71,36 @@ class TestExtensionLoader:
await ext.on_shutdown()
assert ext.stopped
def test_operation_enums_remain_string_compatible(self):
"""Operation enums centralize names without breaking string comparisons."""
request_context = RequestContext(tenant_id="tenant-1")
precheck_ctx = PrecheckContext(
bank_id="bank-1",
operation=PrecheckOperation.RETAIN,
request_context=request_context,
)
read_ctx = BankReadContext(
bank_id="bank-1",
operation=BankReadOperation.GET_BANK_STATS,
request_context=request_context,
)
write_ctx = BankWriteContext(
bank_id="bank-1",
operation=BankWriteOperation.UPDATE_BANK_CONFIG,
request_context=request_context,
)
assert precheck_ctx.operation is PrecheckOperation.RETAIN
assert read_ctx.operation is BankReadOperation.GET_BANK_STATS
assert write_ctx.operation is BankWriteOperation.UPDATE_BANK_CONFIG
assert precheck_ctx.operation == "retain"
assert read_ctx.operation == "get_bank_stats"
assert write_ctx.operation == "update_bank_config"
assert isinstance(precheck_ctx.operation, str)
assert isinstance(read_ctx.operation, str)
assert isinstance(write_ctx.operation, str)
class LifecycleTestExtension(Extension):
"""Test extension for config and lifecycle tests."""
@@ -356,6 +389,7 @@ class TestOperationHooksParameters:
async def test_recall_pre_hook_receives_all_parameters(self, memory_with_tracking_validator):
"""Pre-recall hook receives all user-provided parameters."""
from datetime import datetime, timezone
from hindsight_api.engine.memory_engine import Budget
memory, validator = memory_with_tracking_validator
@@ -882,7 +916,7 @@ class TestPrecheckDefault:
validator = RecordingPrecheckValidator(reject=False)
# Bypass our override by calling the base implementation directly.
ctx = PrecheckContext(
operation="retain",
operation=PrecheckOperation.RETAIN,
bank_id="bank-x",
request_context=RequestContext(),
)
@@ -914,7 +948,7 @@ class TestPrecheckHttpWiring:
from fastapi import Depends, FastAPI, HTTPException, Request
from pydantic import BaseModel, model_validator
from hindsight_api.extensions import PrecheckContext
from hindsight_api.extensions import PrecheckContext, PrecheckOperation
from hindsight_api.models import RequestContext
body_parses: list[str] = []
@@ -949,7 +983,7 @@ class TestPrecheckHttpWiring:
async def _request_context() -> RequestContext:
return RequestContext()
def _precheck_for(operation: str):
def _precheck_for(operation: PrecheckOperation):
async def _dep(
bank_id: str,
request: Request,
@@ -985,7 +1019,7 @@ class TestPrecheckHttpWiring:
async def retain(
bank_id: str,
body: _RetainBody,
_: None = Depends(_precheck_for("retain")),
_: None = Depends(_precheck_for(PrecheckOperation.RETAIN)),
):
return {"ok": True, "bank_id": bank_id, "n": len(body.items)}
@@ -993,7 +1027,7 @@ class TestPrecheckHttpWiring:
async def recall(
bank_id: str,
body: _RecallBody,
_: None = Depends(_precheck_for("recall")),
_: None = Depends(_precheck_for(PrecheckOperation.RECALL)),
):
return {"ok": True}
@@ -1001,7 +1035,7 @@ class TestPrecheckHttpWiring:
async def reflect(
bank_id: str,
body: _ReflectBody,
_: None = Depends(_precheck_for("reflect")),
_: None = Depends(_precheck_for(PrecheckOperation.REFLECT)),
):
return {"ok": True}
@@ -1168,7 +1202,7 @@ class TestPrecheckHttpWiring:
content_length = parsed
ctx = PrecheckContext(
operation="retain",
operation=PrecheckOperation.RETAIN,
bank_id="bank-x",
request_context=RequestContext(),
content_length=content_length,
@@ -27,6 +27,13 @@ def llm_config():
api_key=config.retain_llm_api_key or config.llm_api_key,
model=config.retain_llm_model or config.llm_model,
base_url=config.retain_llm_base_url or config.llm_base_url,
# LLMConfig uses these as-passed and no longer reads them from global config,
# so the caller must forward the Vertex AI settings (mirrors MemoryEngine's
# own LLMConfig construction). Without this, provider=vertexai raises
# "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID is required" even when it is set.
vertexai_project_id=config.llm_vertexai_project_id,
vertexai_region=config.llm_vertexai_region,
vertexai_service_account_key=config.llm_vertexai_service_account_key,
)
@@ -0,0 +1,41 @@
from unittest.mock import MagicMock
from hindsight_api.engine.retain.fact_extraction import (
ExtractedFact,
ExtractedFactNoCausal,
ExtractedFactVerbose,
_build_extraction_prompt_and_schema,
)
def _baseline_config() -> MagicMock:
config = MagicMock()
config.entity_labels = None
config.entities_allow_free_form = True
config.retain_extraction_mode = "concise"
config.retain_extract_causal_links = False
config.retain_mission = None
config.retain_custom_instructions = None
config.llm_output_language = None
return config
def test_concise_prompt_keeps_user_preferences_rules_and_corrections_world():
prompt, _ = _build_extraction_prompt_and_schema(_baseline_config())
assert '"world": Objective/external facts' in prompt
assert "user's preferences, rules, corrections, constraints" in prompt
assert 'These stay "world" even when the user states them during an assistant interaction' in prompt
assert "Use this for the assistant/agent doing" in prompt
assert "not merely for user facts mentioned in conversation" in prompt
def test_fact_type_schema_descriptions_distinguish_user_facts_from_agent_actions():
for model in (ExtractedFact, ExtractedFactVerbose, ExtractedFactNoCausal):
description = model.model_fields["fact_type"].description
assert description is not None
assert "preferences" in description
assert "rules" in description
assert "corrections" in description
assert "assistant/agent actually performed" in description
@@ -30,6 +30,7 @@ def _make_config(llm_max_retries: int = 3, retain_llm_max_retries: int | None =
cfg.retain_extraction_mode = "concise"
cfg.retain_extract_causal_links = False
cfg.retain_mission = None
cfg.llm_temperature_retain = 0.1
return cfg
@@ -216,3 +217,43 @@ async def test_none_event_date_with_valid_facts_no_crash():
assert len(facts) == 1
assert "Alice visited Paris" in facts[0].fact
def _make_batch_temp_config(temperature):
"""Minimal config for _build_request_body temperature tests."""
from hindsight_api.config import HindsightConfig
cfg = MagicMock(spec=HindsightConfig)
cfg.llm_temperature_retain = temperature
cfg.retain_max_completion_tokens = None
cfg.llm_strict_schema = False
return cfg
def _make_batch_llm_config():
"""Minimal LLMProvider mock for _build_request_body (non-openai skips service_tier)."""
from hindsight_api.engine.llm_wrapper import LLMProvider
llm = MagicMock(spec=LLMProvider)
llm.model = "gpt-test"
llm.provider = "mock"
return llm
def test_build_request_body_forwards_configured_temperature():
"""Batch retain path must send the configured retain temperature."""
from hindsight_api.engine.retain.fact_extraction import _build_request_body
body = _build_request_body(_make_batch_llm_config(), _make_batch_temp_config(0.7), "sys", "user", dict)
assert body["temperature"] == 0.7
def test_build_request_body_omits_temperature_when_none():
"""HINDSIGHT_API_LLM_TEMPERATURE=none must drop temperature from the batch
request body too (Azure GPT-5.5 rejects explicit temperatures). Follow-up to
#2469, which only de-hardcoded the streaming path and left the batch
_build_request_body hardcoding temperature=0.1."""
from hindsight_api.engine.retain.fact_extraction import _build_request_body
body = _build_request_body(_make_batch_llm_config(), _make_batch_temp_config(None), "sys", "user", dict)
assert "temperature" not in body
@@ -165,6 +165,12 @@ async def test_full_api_workflow(api_client, test_bank_id):
assert "total_nodes" in stats
assert stats["total_nodes"] > 0
# ?refresh=true forces a fresh recompute, bypassing the cache; same shape.
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats?refresh=true")
assert response.status_code == 200
fresh_stats = response.json()
assert fresh_stats["total_nodes"] == stats["total_nodes"]
# Verify bank list returns stats (fact_count, last_document_at)
response = await api_client.get("/v1/default/banks")
assert response.status_code == 200
@@ -0,0 +1,241 @@
"""HTTP + engine integration tests for the knowledge base (folders + pages).
Pages are seeded directly via the engine (deterministic content, no LLM) so the
tree, OKF projection, move/rename, and cascade-delete behaviour can be asserted
without consolidation.
"""
import urllib.parse
import uuid
import pytest_asyncio
from hindsight_api.engine.memory_engine import MemoryEngine
def _enc(bank_id: str) -> str:
return urllib.parse.quote(bank_id, safe="")
class _Seed:
"""Holds the ids created by the seed fixture for assertions."""
def __init__(self, **ids):
self.__dict__.update(ids)
@pytest_asyncio.fixture
async def kb_bank(memory: MemoryEngine, request_context):
"""A bank with folders, nested folders, and pages."""
bank_id = f"test-kb-{uuid.uuid4().hex[:8]}"
runbooks = await memory.create_knowledge_folder(bank_id, "Runbooks", request_context=request_context)
policies = await memory.create_knowledge_folder(bank_id, "Policies", request_context=request_context)
sub = await memory.create_knowledge_folder(
bank_id, "Sub", parent_id=runbooks["id"], request_context=request_context
)
orders = await memory.create_knowledge_page(
bank_id,
"Orders",
"What are the order facts?",
"# Orders\n\nOne row per order.",
parent_id=runbooks["id"],
tags=["type:runbook", "sales", "revenue"],
request_context=request_context,
)
billing = await memory.create_knowledge_page(
bank_id,
"Billing",
"What is the billing policy?",
"# Billing\n\nNet-30.",
parent_id=policies["id"],
tags=["type:policy", "revenue"],
request_context=request_context,
)
loose = await memory.create_knowledge_page(
bank_id,
"Loose",
"A root page.",
"# Loose\n\nNo folder, no tags.",
tags=[],
request_context=request_context,
)
yield (
bank_id,
_Seed(
runbooks=runbooks["id"],
policies=policies["id"],
sub=sub["id"],
orders=orders["id"],
billing=billing["id"],
loose=loose["id"],
orders_mm=orders["mental_model_id"],
),
)
await memory.delete_bank(bank_id, request_context=request_context)
class TestTree:
async def test_nested_tree(self, api_client, kb_bank):
bank_id, ids = kb_bank
resp = await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/tree")
assert resp.status_code == 200, resp.text
roots = {r["name"]: r for r in resp.json()["roots"]}
assert set(roots) == {"Runbooks", "Policies", "Loose"}
runbooks = roots["Runbooks"]
assert runbooks["kind"] == "folder"
child_names = {c["name"] for c in runbooks["children"]}
assert child_names == {"Sub", "Orders"}
orders = next(c for c in runbooks["children"] if c["name"] == "Orders")
assert orders["kind"] == "page"
# Human-created pages are pinned (not curator-managed).
assert orders["managed"] is False
assert "sales" in orders["tags"]
assert roots["Loose"]["kind"] == "page"
class TestPageDefaults:
"""A knowledge page is a living document by default: observation-only, delta,
auto-refreshing, with a larger token budget than a plain mental model."""
async def test_default_trigger_and_max_tokens(self, memory: MemoryEngine, request_context):
bank_id = f"test-kb-def-{uuid.uuid4().hex[:8]}"
page = await memory.create_knowledge_page(
bank_id, "P", "What is P?", "seed", request_context=request_context
)
mm = await memory.get_mental_model(bank_id, page["mental_model_id"], request_context=request_context)
assert mm["trigger"] == {
"mode": "delta",
"fact_types": ["observation"],
"exclude_mental_models": True,
"refresh_after_consolidation": True,
}
assert mm["max_tokens"] == 4096
await memory.delete_bank(bank_id, request_context=request_context)
async def test_client_trigger_and_max_tokens_override_defaults(self, memory: MemoryEngine, request_context):
bank_id = f"test-kb-ovr-{uuid.uuid4().hex[:8]}"
page = await memory.create_knowledge_page(
bank_id,
"P",
"What is P?",
"seed",
trigger={"mode": "full", "refresh_after_consolidation": False},
max_tokens=1024,
request_context=request_context,
)
mm = await memory.get_mental_model(bank_id, page["mental_model_id"], request_context=request_context)
assert mm["trigger"]["mode"] == "full"
assert mm["trigger"].get("refresh_after_consolidation") is False
assert mm["max_tokens"] == 1024
await memory.delete_bank(bank_id, request_context=request_context)
class TestGetPage:
async def test_okf_document(self, api_client, kb_bank):
bank_id, ids = kb_bank
resp = await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/pages/{ids.orders}")
assert resp.status_code == 200, resp.text
page = resp.json()
assert page["type"] == "runbook"
assert page["body"].startswith("# Orders")
assert page["markdown"].startswith("---\n")
assert 'type: "runbook"' in page["markdown"]
async def test_missing_page_404(self, api_client, kb_bank):
bank_id, ids = kb_bank
resp = await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/pages/nope")
assert resp.status_code == 404
class TestCreate:
async def test_create_folder(self, api_client, kb_bank):
bank_id, ids = kb_bank
resp = await api_client.post(
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/folders",
json={"name": "Guides", "parent_id": None},
)
assert resp.status_code == 201, resp.text
assert resp.json()["kind"] == "folder"
assert resp.json()["name"] == "Guides"
async def test_create_folder_bad_parent(self, api_client, kb_bank):
bank_id, ids = kb_bank
# parent that is a page, not a folder → 400
resp = await api_client.post(
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/folders",
json={"name": "Nope", "parent_id": ids.orders},
)
assert resp.status_code == 400
class TestGraphAndExport:
async def test_graph_shared_tag_edge(self, api_client, kb_bank):
bank_id, ids = kb_bank
resp = await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/graph")
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["total_pages"] == 3
# orders & billing share "revenue"; loose has no tags
assert data["total_edges"] == 1
edge = data["edges"][0]["data"]
assert {edge["source"], edge["target"]} == {ids.orders, ids.billing}
assert edge["sharedTags"] == ["revenue"]
async def test_export_bundle_nested_index(self, api_client, kb_bank):
bank_id, ids = kb_bank
resp = await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/export")
assert resp.status_code == 200, resp.text
files = {f["path"]: f["content"] for f in resp.json()["files"]}
assert "index.md" in files
assert f"{ids.orders}.md" in files
# index reflects the folder hierarchy
assert "**Runbooks/**" in files["index.md"]
assert "One row per order." in files[f"{ids.orders}.md"]
class TestMoveRenameDelete:
async def test_rename(self, api_client, kb_bank):
bank_id, ids = kb_bank
resp = await api_client.patch(
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/nodes/{ids.policies}",
json={"name": "Compliance"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["name"] == "Compliance"
async def test_move_into_folder(self, api_client, kb_bank):
bank_id, ids = kb_bank
# move the Loose root page under Policies
resp = await api_client.patch(
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/nodes/{ids.loose}",
json={"parent_id": ids.policies},
)
assert resp.status_code == 200, resp.text
assert resp.json()["parent_id"] == ids.policies
async def test_move_cycle_rejected(self, api_client, kb_bank):
bank_id, ids = kb_bank
# moving Runbooks under its own descendant Sub must fail
resp = await api_client.patch(
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/nodes/{ids.runbooks}",
json={"parent_id": ids.sub},
)
assert resp.status_code == 400
async def test_delete_folder_cascades(self, api_client, kb_bank, memory, request_context):
bank_id, ids = kb_bank
# deleting Runbooks removes Sub + Orders (and Orders' mental model)
resp = await api_client.delete(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/nodes/{ids.runbooks}")
assert resp.status_code == 200, resp.text
tree = (await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/tree")).json()
root_names = {r["name"] for r in tree["roots"]}
assert "Runbooks" not in root_names
# the backing mental model is gone too
mm = await memory.get_mental_model(bank_id, ids.orders_mm, request_context=request_context)
assert mm is None
+107 -1
View File
@@ -22,6 +22,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
EXTRA_BODY = {"temperature": 0.2, "top_p": 0.9}
DEFAULT_HEADERS = {"X-Component-Id": "hindsight", "X-Trace": "abc"}
# ─── config / env parsing ─────────────────────────────────────────────────────
@@ -348,7 +349,7 @@ async def test_gemini_cached_parse_retry_keeps_cached_native_schema():
# ─── LiteLLM ──────────────────────────────────────────────────────────────────
def _make_litellm_provider(extra_body=None):
def _make_litellm_provider(extra_body=None, default_headers=None):
pytest.importorskip("litellm")
from hindsight_api.engine.providers.litellm_llm import LiteLLMLLM
@@ -358,6 +359,7 @@ def _make_litellm_provider(extra_body=None):
base_url="",
model="gpt-4o",
extra_body=extra_body,
default_headers=default_headers,
)
@@ -419,3 +421,107 @@ def test_litellm_router_forwards_extra_body():
extra_body=EXTRA_BODY,
)
assert provider._extra_body == EXTRA_BODY
def test_litellm_stores_default_headers():
provider = _make_litellm_provider(default_headers=DEFAULT_HEADERS)
assert provider._default_headers == DEFAULT_HEADERS
def test_litellm_empty_default_headers_defaults_to_dict():
provider = _make_litellm_provider(default_headers=None)
assert provider._default_headers == {}
@pytest.mark.asyncio
async def test_litellm_call_passes_default_headers_as_extra_headers():
"""``call()`` forwards default_headers to acompletion via ``extra_headers``."""
provider = _make_litellm_provider(default_headers=DEFAULT_HEADERS)
provider._acompletion = AsyncMock(return_value=_fake_litellm_response())
with patch("hindsight_api.engine.providers.litellm_llm.get_metrics_collector"):
await provider.call(messages=[{"role": "user", "content": "hi"}], scope="test", max_retries=0)
assert provider._acompletion.call_args.kwargs.get("extra_headers") == DEFAULT_HEADERS
@pytest.mark.asyncio
async def test_litellm_no_default_headers_omits_extra_headers():
"""``call()`` does not pass ``extra_headers`` when none are configured."""
provider = _make_litellm_provider(default_headers=None)
provider._acompletion = AsyncMock(return_value=_fake_litellm_response())
with patch("hindsight_api.engine.providers.litellm_llm.get_metrics_collector"):
await provider.call(messages=[{"role": "user", "content": "hi"}], scope="test", max_retries=0)
assert "extra_headers" not in provider._acompletion.call_args.kwargs
@pytest.mark.asyncio
async def test_litellm_default_headers_passed_as_fresh_copy():
"""Each call gets its own ``extra_headers`` copy so downstream mutation can't
contaminate the stored headers or other requests."""
provider = _make_litellm_provider(default_headers=DEFAULT_HEADERS)
provider._acompletion = AsyncMock(return_value=_fake_litellm_response())
with patch("hindsight_api.engine.providers.litellm_llm.get_metrics_collector"):
await provider.call(messages=[{"role": "user", "content": "hi"}], scope="test", max_retries=0)
passed = provider._acompletion.call_args.kwargs["extra_headers"]
assert passed == DEFAULT_HEADERS
assert passed is not provider._default_headers
passed["X-Injected"] = "1"
assert "X-Injected" not in provider._default_headers
def test_litellm_default_headers_copied_from_caller_dict():
"""A caller-owned dict cannot be mutated through the provider."""
caller_dict = {"X-Component-Id": "hindsight"}
provider = _make_litellm_provider(default_headers=caller_dict)
caller_dict["X-Mutated"] = "1"
assert "X-Mutated" not in provider._default_headers
def _make_litellm_router_provider(default_headers=None):
pytest.importorskip("litellm")
from hindsight_api.engine.providers.litellm_router_llm import LiteLLMRouterLLM
config = {"model_list": [{"model_name": "default", "litellm_params": {"model": "gpt-4o", "api_key": "x"}}]}
return LiteLLMRouterLLM(
provider="litellmrouter",
api_key="",
base_url="",
model="default",
config=config,
default_headers=default_headers,
)
def test_litellm_router_stores_default_headers():
provider = _make_litellm_router_provider(default_headers=DEFAULT_HEADERS)
assert provider._default_headers == DEFAULT_HEADERS
@pytest.mark.asyncio
async def test_litellm_router_call_passes_default_headers_as_extra_headers():
"""The Router's ``_build_common_kwargs`` override must also forward default_headers
as ``extra_headers`` storage alone doesn't reach the provider behind the Router."""
provider = _make_litellm_router_provider(default_headers=DEFAULT_HEADERS)
provider._acompletion = AsyncMock(return_value=_fake_litellm_response())
with patch("hindsight_api.engine.providers.litellm_llm.get_metrics_collector"):
await provider.call(messages=[{"role": "user", "content": "hi"}], scope="test", max_retries=0)
assert provider._acompletion.call_args.kwargs.get("extra_headers") == DEFAULT_HEADERS
@pytest.mark.asyncio
async def test_litellm_router_no_default_headers_omits_extra_headers():
"""The Router omits ``extra_headers`` entirely when none are configured."""
provider = _make_litellm_router_provider(default_headers=None)
provider._acompletion = AsyncMock(return_value=_fake_litellm_response())
with patch("hindsight_api.engine.providers.litellm_llm.get_metrics_collector"):
await provider.call(messages=[{"role": "user", "content": "hi"}], scope="test", max_retries=0)
assert "extra_headers" not in provider._acompletion.call_args.kwargs
@@ -45,11 +45,22 @@ def _get_api_key() -> str:
def _make_llm() -> LLMProvider:
# LLMProvider uses provider-specific settings as-passed (it does not resolve
# them from global config), so forward the ones whose providers require them:
# Vertex AI needs project/region, and litellmrouter needs its router config.
# Without these, provider=vertexai/litellmrouter raise at construction.
from hindsight_api.config import get_config
config = get_config()
return LLMProvider(
provider=_PROVIDER,
api_key=_get_api_key(),
base_url=os.environ.get("HINDSIGHT_API_LLM_BASE_URL", ""),
model=_MODEL,
vertexai_project_id=config.llm_vertexai_project_id,
vertexai_region=config.llm_vertexai_region,
vertexai_service_account_key=config.llm_vertexai_service_account_key,
litellmrouter_config=config.llm_litellmrouter_config,
)
@@ -194,6 +194,7 @@ def _make_router_provider(config: dict[str, Any], mock_router: Any) -> LiteLLMRo
provider.model = "unused"
provider.reasoning_effort = "low"
provider.timeout = 300.0
provider._default_headers = {}
provider.config = config
provider._litellm = fake_litellm
provider._router = mock_router
@@ -0,0 +1,73 @@
"""Tests for per-operation LLM temperature configuration from environment variables.
Covers the resolution order (per-operation env -> global env -> built-in default)
and the "omit" sentinels that drop the temperature parameter for models that reject
explicit temperatures (e.g. Azure gpt-5.5 -- see issue #2459).
"""
import pytest
from hindsight_api.config import HindsightConfig, _parse_temperature
_OP_FIELDS = {
"HINDSIGHT_API_LLM_TEMPERATURE_VERIFICATION": ("llm_temperature_verification", 0.0),
"HINDSIGHT_API_LLM_TEMPERATURE_RETAIN": ("llm_temperature_retain", 0.1),
"HINDSIGHT_API_LLM_TEMPERATURE_REFLECT": ("llm_temperature_reflect", 0.9),
"HINDSIGHT_API_LLM_TEMPERATURE_CONSOLIDATION": ("llm_temperature_consolidation", 0.0),
}
def _clear_temperature_env(monkeypatch) -> None:
monkeypatch.delenv("HINDSIGHT_API_LLM_TEMPERATURE", raising=False)
for env_name in _OP_FIELDS:
monkeypatch.delenv(env_name, raising=False)
def test_defaults_preserve_historical_values(monkeypatch):
_clear_temperature_env(monkeypatch)
config = HindsightConfig.from_env()
for _, (field, default) in _OP_FIELDS.items():
assert getattr(config, field) == default
def test_global_override_applies_to_all_operations(monkeypatch):
_clear_temperature_env(monkeypatch)
monkeypatch.setenv("HINDSIGHT_API_LLM_TEMPERATURE", "0.2")
config = HindsightConfig.from_env()
for _, (field, _default) in _OP_FIELDS.items():
assert getattr(config, field) == 0.2
def test_global_none_omits_temperature_everywhere(monkeypatch):
_clear_temperature_env(monkeypatch)
monkeypatch.setenv("HINDSIGHT_API_LLM_TEMPERATURE", "none")
config = HindsightConfig.from_env()
for _, (field, _default) in _OP_FIELDS.items():
assert getattr(config, field) is None
def test_per_operation_override_beats_global(monkeypatch):
_clear_temperature_env(monkeypatch)
monkeypatch.setenv("HINDSIGHT_API_LLM_TEMPERATURE", "none")
monkeypatch.setenv("HINDSIGHT_API_LLM_TEMPERATURE_RETAIN", "0.5")
config = HindsightConfig.from_env()
assert config.llm_temperature_retain == 0.5
# Other operations still follow the global "none" (omit).
assert config.llm_temperature_reflect is None
@pytest.mark.parametrize("sentinel", ["none", "NONE", "default", "off", "unset", "", " "])
def test_omit_sentinels(sentinel):
assert _parse_temperature(sentinel) is None
def test_parse_temperature_rejects_out_of_range():
with pytest.raises(ValueError):
_parse_temperature("2.5")
with pytest.raises(ValueError):
_parse_temperature("-0.1")
def test_parse_temperature_rejects_non_numeric():
with pytest.raises(ValueError):
_parse_temperature("warm")
@@ -0,0 +1,99 @@
"""End-to-end checks that per-operation temperature reaches the LLM call.
These drive the real pipeline with the mock LLM provider (which records the
``temperature`` it receives) and assert that each operation forwards the
configured value -- including ``None``, which omits the parameter for models
that reject explicit temperatures (issue #2459).
Config resolution itself is unit-tested in ``test_llm_temperature_env.py``;
here we verify the value is actually threaded through to ``provider.call()``.
"""
from datetime import datetime, timezone
import pytest
from hindsight_api.config import clear_config_cache
from hindsight_api.engine.search import think_utils
def _calls_for_scope(memory, scope: str) -> list[dict]:
"""Collect mock call records for a scope across the engine's LLM configs.
retain/reflect/consolidation each wrap a distinct provider instance, so a
given scope only lands on one of them; gather from all and filter.
"""
seen_impls: dict[int, object] = {}
for config in (
memory._llm_config,
memory._retain_llm_config,
memory._reflect_llm_config,
memory._consolidation_llm_config,
):
impl = config._provider_impl
seen_impls[id(impl)] = impl
calls: list[dict] = []
for impl in seen_impls.values():
calls.extend(impl.get_mock_calls())
return [c for c in calls if c.get("scope") == scope]
@pytest.mark.asyncio
async def test_retain_forwards_configured_temperature(memory, request_context):
"""Retain's fact extraction must call the LLM with the retain temperature (0.1 default)."""
bank_id = f"test_temp_retain_{datetime.now(timezone.utc).timestamp()}"
try:
await memory.retain_async(
bank_id=bank_id,
content="Alice is a senior engineer at TechCorp. She works on distributed systems.",
context="team overview",
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
request_context=request_context,
)
extract_calls = _calls_for_scope(memory, "retain_extract_facts")
assert extract_calls, "retain should have made a fact-extraction LLM call"
assert all(c["temperature"] == 0.1 for c in extract_calls)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_reflect_think_forwards_configured_temperature(memory):
"""The reflect 'thinking' path must call the LLM with the reflect temperature (0.9 default)."""
reflect_config = memory._reflect_llm_config
reflect_config._provider_impl.clear_mock_calls()
await think_utils.reflect(
llm_config=reflect_config,
query="What does Alice work on?",
world_facts=["Alice works on distributed systems."],
)
think_calls = [c for c in reflect_config._provider_impl.get_mock_calls() if c["scope"] == "memory_think"]
assert think_calls, "reflect should have made a memory_think LLM call"
assert all(c["temperature"] == 0.9 for c in think_calls)
@pytest.mark.asyncio
async def test_global_none_omits_temperature_on_real_call(memory, monkeypatch):
"""HINDSIGHT_API_LLM_TEMPERATURE=none must omit (None) the temperature on a live call."""
monkeypatch.setenv("HINDSIGHT_API_LLM_TEMPERATURE", "none")
clear_config_cache()
try:
reflect_config = memory._reflect_llm_config
reflect_config._provider_impl.clear_mock_calls()
await think_utils.reflect(
llm_config=reflect_config,
query="What does Alice work on?",
world_facts=["Alice works on distributed systems."],
)
think_calls = [c for c in reflect_config._provider_impl.get_mock_calls() if c["scope"] == "memory_think"]
assert think_calls, "reflect should have made a memory_think LLM call"
assert all(c["temperature"] is None for c in think_calls), "temperature should be omitted"
finally:
# Restore the cached config so later tests see default temperatures.
clear_config_cache()
@@ -0,0 +1,179 @@
"""Plumbing tests for the per-operation / global LLM request defaults (issue #2452).
These assert the *wiring* that a resolved timeout / retry policy actually reaches
the provider that uses it not just that the env var parses into config (covered by
test_config_validation.py).
The values are threaded
``config -> MemoryEngine per-op resolve -> LLMProvider -> (create_llm_provider /
call())``. Before the fix the per-operation ``*_llm_timeout`` / ``*_llm_max_retries`` /
``*_llm_initial_backoff`` / ``*_llm_max_backoff`` fields (and even the global ``llm_*``)
were resolved into ``HindsightConfig`` but never reached the provider, so a configured
``HINDSIGHT_API_RETAIN_LLM_TIMEOUT`` silently used the global default
(``LiteLLM call exceeded timeout=120.0s``) and the per-op retry knobs were inert.
"""
import pytest
from hindsight_api.config import DEFAULT_LLM_TIMEOUT
from hindsight_api.engine.llm_wrapper import LLMConfig
def _mock_llm(**kwargs) -> LLMConfig:
return LLMConfig(provider="mock", api_key="", base_url="", model="m", **kwargs)
def _spy_provider_call(monkeypatch, llm: LLMConfig) -> dict:
"""Replace the provider impl's call() with a kwargs-capturing stub."""
captured: dict = {}
async def fake_call(**kwargs):
captured.update(kwargs)
return "ok"
monkeypatch.setattr(llm._provider_impl, "call", fake_call)
return captured
def test_litellm_provider_impl_receives_timeout():
"""LLMConfig -> create_llm_provider -> LiteLLMLLM carries the resolved timeout."""
llm = LLMConfig(provider="litellm", api_key="k", base_url="", model="gpt-4o-mini", timeout=300.0)
assert llm.timeout == 300.0
assert llm._provider_impl.timeout == 300.0
def test_openai_compatible_provider_impl_receives_timeout():
"""The OpenAI-compatible path (openai/groq/ollama/...) carries the timeout too."""
llm = LLMConfig(provider="openai", api_key="k", base_url="", model="gpt-4o-mini", timeout=250.0)
assert llm._provider_impl.timeout == 250.0
def test_timeout_none_falls_back_to_provider_default():
"""No timeout passed -> provider falls back to its env/DEFAULT_LLM_TIMEOUT default.
Guards against a regression where threading the value would override the
long-standing default for callers that never configured a timeout.
"""
llm = LLMConfig(provider="litellm", api_key="k", base_url="", model="gpt-4o-mini")
assert llm._provider_impl.timeout == DEFAULT_LLM_TIMEOUT
@pytest.fixture
def _clean_timeout_env(monkeypatch):
"""Mock provider + verification off, with all timeout env vars cleared."""
from hindsight_api.config import clear_config_cache
monkeypatch.setenv("HINDSIGHT_API_SKIP_LLM_VERIFICATION", "true")
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
monkeypatch.setenv("HINDSIGHT_API_LLM_MODEL", "default-model")
for op in ("LLM", "RETAIN_LLM", "REFLECT_LLM", "CONSOLIDATION_LLM"):
for knob in ("TIMEOUT", "MAX_RETRIES", "INITIAL_BACKOFF", "MAX_BACKOFF"):
monkeypatch.delenv(f"HINDSIGHT_API_{op}_{knob}", raising=False)
clear_config_cache()
yield
clear_config_cache()
async def test_call_uses_instance_retry_defaults(monkeypatch):
"""call() falls back to the provider's configured retry policy when no per-call
arg is given this is what makes a per-op ``*_llm_max_retries`` take effect."""
llm = _mock_llm(max_retries=7, initial_backoff=2.0, max_backoff=9.0)
captured = _spy_provider_call(monkeypatch, llm)
await llm.call(messages=[{"role": "user", "content": "hi"}], scope="x")
assert captured["max_retries"] == 7
assert captured["initial_backoff"] == 2.0
assert captured["max_backoff"] == 9.0
async def test_call_explicit_arg_overrides_instance_default(monkeypatch):
"""An explicit per-call value still wins over the configured default."""
llm = _mock_llm(max_retries=7)
captured = _spy_provider_call(monkeypatch, llm)
await llm.call(messages=[{"role": "user", "content": "hi"}], scope="x", max_retries=2)
assert captured["max_retries"] == 2
async def test_call_falls_back_to_method_default_when_unconfigured(monkeypatch):
"""No instance config and no per-call arg -> the method's own fallback (10),
so providers built outside MemoryEngine (from_env, tests) are unchanged."""
llm = _mock_llm()
captured = _spy_provider_call(monkeypatch, llm)
await llm.call(messages=[{"role": "user", "content": "hi"}], scope="x")
assert captured["max_retries"] == 10
assert captured["initial_backoff"] == 1.0
assert captured["max_backoff"] == 60.0
def test_memory_engine_threads_per_operation_timeout(monkeypatch, _clean_timeout_env):
"""Each per-operation override reaches its own LLM config; the rest fall back
to the global ``llm_timeout``."""
from hindsight_api import MemoryEngine
from hindsight_api.config import clear_config_cache
monkeypatch.setenv("HINDSIGHT_API_LLM_TIMEOUT", "100")
monkeypatch.setenv("HINDSIGHT_API_RETAIN_LLM_TIMEOUT", "300")
monkeypatch.setenv("HINDSIGHT_API_CONSOLIDATION_LLM_TIMEOUT", "450")
# reflect intentionally unset -> inherits the global 100
clear_config_cache()
engine = MemoryEngine(skip_llm_verification=True)
assert engine._llm_config.timeout == 100.0
assert engine._retain_llm_config.timeout == 300.0
assert engine._reflect_llm_config.timeout == 100.0
assert engine._consolidation_llm_config.timeout == 450.0
def test_memory_engine_threads_per_operation_retry_policy(monkeypatch, _clean_timeout_env):
"""Per-op retry/backoff overrides reach their own config; unset ops fall back
to the global ``llm_max_retries`` / ``llm_initial_backoff`` / ``llm_max_backoff``."""
from hindsight_api import MemoryEngine
from hindsight_api.config import clear_config_cache
monkeypatch.setenv("HINDSIGHT_API_LLM_MAX_RETRIES", "4")
monkeypatch.setenv("HINDSIGHT_API_LLM_INITIAL_BACKOFF", "0.5")
monkeypatch.setenv("HINDSIGHT_API_LLM_MAX_BACKOFF", "20")
monkeypatch.setenv("HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES", "2")
monkeypatch.setenv("HINDSIGHT_API_CONSOLIDATION_LLM_MAX_BACKOFF", "99")
clear_config_cache()
engine = MemoryEngine(skip_llm_verification=True)
# Global applies everywhere unless overridden.
assert engine._llm_config.max_retries == 4
assert engine._retain_llm_config.max_retries == 4
# reflect overrides only max_retries; backoff inherits global.
assert engine._reflect_llm_config.max_retries == 2
assert engine._reflect_llm_config.initial_backoff == 0.5
# consolidation overrides only max_backoff; retries inherit global.
assert engine._consolidation_llm_config.max_retries == 4
assert engine._consolidation_llm_config.max_backoff == 99.0
def test_memory_engine_per_op_defaults_to_global_default(_clean_timeout_env):
"""With nothing configured, every operation uses the documented global defaults."""
from hindsight_api import MemoryEngine
from hindsight_api.config import (
DEFAULT_LLM_INITIAL_BACKOFF,
DEFAULT_LLM_MAX_BACKOFF,
DEFAULT_LLM_MAX_RETRIES,
)
engine = MemoryEngine(skip_llm_verification=True)
for cfg in (
engine._llm_config,
engine._retain_llm_config,
engine._reflect_llm_config,
engine._consolidation_llm_config,
):
assert cfg.timeout == DEFAULT_LLM_TIMEOUT
assert cfg.max_retries == DEFAULT_LLM_MAX_RETRIES
assert cfg.initial_backoff == DEFAULT_LLM_INITIAL_BACKOFF
assert cfg.max_backoff == DEFAULT_LLM_MAX_BACKOFF
@@ -389,6 +389,53 @@ async def test_retain_extract_success_records_usage_once(registered_recorder):
assert r.cached_tokens == 20
# ── real provider: litellm tool-call arg-parse failure keeps usage (#2387) ────
def _litellm_tool_response_with_usage(arguments: str):
"""A successful LiteLLM (OpenAI-shaped) tool-call response carrying usage,
like ``call_with_tools`` sees right before it ``json.loads`` the tool
arguments."""
function = SimpleNamespace(name="extract", arguments=arguments)
tool_call = SimpleNamespace(id="call_1", function=function)
message = SimpleNamespace(content=None, tool_calls=[tool_call])
choice = SimpleNamespace(finish_reason="tool_calls", message=message)
usage = SimpleNamespace(
prompt_tokens=140,
completion_tokens=18,
total_tokens=158,
prompt_tokens_details=SimpleNamespace(cached_tokens=20),
)
return SimpleNamespace(error=None, usage=usage, choices=[choice])
@pytest.mark.asyncio
async def test_litellm_tool_call_arg_parse_failure_keeps_usage(registered_recorder):
"""The litellm tool path bills the provider response, then ``json.loads`` the
tool-call arguments locally; malformed args raise after billing, so the error
trace must keep the provider-reported tokens. Exercises the real
``LiteLLMLLM.call_with_tools`` stash that ``LiteLLMRouterLLM`` also inherits
(the wrapper-level tools test uses a provider that already stashes)."""
llm = LLMProvider(provider="litellm", api_key="test-key", base_url="https://example.test/v1", model="gpt-4o-mini")
# Valid response + usage, but the tool arguments are not valid JSON.
llm._provider_impl._acompletion = AsyncMock(return_value=_litellm_tool_response_with_usage("{not valid json"))
with pytest.raises(json.JSONDecodeError):
await llm.call_with_tools(
messages=[{"role": "user", "content": "x"}],
tools=[],
scope="tools",
max_retries=0,
)
assert len(registered_recorder.records) == 1
r = registered_recorder.records[0]
assert r.status == "error"
assert r.input_tokens == 140
assert r.output_tokens == 18
assert r.cached_tokens == 20
@pytest.mark.asyncio
async def test_configured_provider_binds_bank_context(registered_recorder):
llm = LLMProvider(provider="mock", api_key="", base_url="", model="mock")
@@ -0,0 +1,65 @@
"""Unit tests for the markitdown file parser."""
import json
import pytest
from hindsight_api.engine.parsers.markitdown import MarkitdownParser
@pytest.fixture
def parser() -> MarkitdownParser:
return MarkitdownParser()
def _utf8_json_with_ascii_prefix() -> bytes:
"""A JSON file whose first chunk is ASCII but contains multibyte UTF-8 later.
markitdown samples only the first chunk for charset detection, so this layout
used to be mis-detected as ASCII and crash the JSON/ipynb converter on the
first multibyte byte (0xc3).
"""
payload = {"messages": [{"role": "user", "text": "x" * 6400 + " café à la crème naïve über"}]}
raw = json.dumps(payload, ensure_ascii=False).encode("utf-8")
assert any(b > 127 for b in raw[6000:]), "fixture must have non-ASCII bytes past the sample window"
return raw
async def test_convert_utf8_json_transcript(parser: MarkitdownParser):
"""A UTF-8 JSON transcript with an ASCII prefix parses without a decode error."""
file_data = _utf8_json_with_ascii_prefix()
content = await parser.convert(file_data, "transcript.json")
assert "café" in content
assert "crème" in content
async def test_convert_plain_utf8_text(parser: MarkitdownParser):
"""A plain UTF-8 text file with non-ASCII content round-trips."""
file_data = ("über résumé\n" + "a" * 7000 + "\nfin: naïveté").encode("utf-8")
content = await parser.convert(file_data, "notes.txt")
assert "über" in content
assert "naïveté" in content
def test_utf8_stream_info_for_text_extension():
"""Text files that are valid UTF-8 get an explicit UTF-8 charset hint."""
info = MarkitdownParser._utf8_stream_info("über".encode("utf-8"), "a.json")
assert info is not None
assert info.charset == "utf-8"
def test_utf8_stream_info_skips_binary_extension():
"""Binary files are left to markitdown's own detection (no hint)."""
assert MarkitdownParser._utf8_stream_info(b"%PDF-1.4 ...", "a.pdf") is None
def test_utf8_stream_info_skips_non_utf8_text():
"""Non-UTF-8 text falls back to markitdown's detection (no hint)."""
latin1 = "café".encode("latin-1") # 0xe9, invalid as standalone UTF-8
assert MarkitdownParser._utf8_stream_info(latin1, "a.txt") is None
@@ -0,0 +1,155 @@
"""Regression for the consolidator search_vector gap (PR #2425).
Observations written by the consolidator under the ``native`` text-search
backend landed with a NULL ``search_vector`` and were invisible to BM25. The
writer is fixed to populate the tsvector; migration
``c3f7a1b9d2e4`` backfills the historical NULL observations.
This test seeds an observation with a NULL ``search_vector`` at the revision
just before the backfill, runs the migration to head, and asserts the row is
populated with a valid tsvector and that already-populated rows are left
untouched. Uses a dedicated pg0 instance (mirrors test_migration_backsweep) so
it controls exactly which migrations have run and never stamps the shared test
instance.
"""
import asyncio
import uuid
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, text
_SCRIPT_LOCATION = str(Path(__file__).parent.parent / "hindsight_api" / "alembic")
# Revision immediately before the backfill migration.
_PRE_BACKFILL_REVISION = "f4d1c2b3a5e6"
_BACKFILL_REVISION = "c3f7a1b9d2e4"
def _alembic_cfg(db_url: str) -> Config:
cfg = Config()
cfg.set_main_option("script_location", _SCRIPT_LOCATION)
cfg.set_main_option("sqlalchemy.url", db_url)
cfg.set_main_option("prepend_sys_path", ".")
cfg.set_main_option("path_separator", "os")
return cfg
@pytest.fixture(scope="module")
def pre_backfill_db_url():
"""pg0 instance brought to the revision just before the backfill so the
migration's UPDATE runs against seeded NULL-search_vector observations."""
from hindsight_api.pg0 import EmbeddedPostgres
pg0 = EmbeddedPostgres(name="hindsight-obs-sv-backfill-test", port=5568)
loop = asyncio.new_event_loop()
try:
url = loop.run_until_complete(pg0.ensure_running())
finally:
loop.close()
# pg0 data dirs persist across runs, so normalise: go to head, then down to
# just before the backfill.
command.upgrade(_alembic_cfg(url), "heads")
command.downgrade(_alembic_cfg(url), _PRE_BACKFILL_REVISION)
return url
def test_backfill_populates_null_observation_search_vector(pre_backfill_db_url):
db_url = pre_backfill_db_url
bank_id = f"obs-sv-{uuid.uuid4().hex[:12]}"
null_obs_id = uuid.uuid4()
populated_obs_id = uuid.uuid4()
world_id = uuid.uuid4()
engine = create_engine(db_url)
with engine.connect() as conn:
# Sanity: under the default native backend search_vector is a regular
# tsvector column; otherwise this test wouldn't exercise the gate.
udt = conn.execute(
text(
"""
SELECT udt_name FROM information_schema.columns
WHERE table_name = 'memory_units' AND column_name = 'search_vector'
"""
)
).scalar()
assert udt == "tsvector", f"expected native tsvector backend, got {udt!r}"
conn.execute(text("INSERT INTO banks (bank_id) VALUES (:b)"), {"b": bank_id})
# The bug shape: an observation with NULL search_vector.
conn.execute(
text(
"""
INSERT INTO memory_units (id, bank_id, text, fact_type, search_vector)
VALUES (:id, :b, 'Django uses middleware for request processing', 'observation', NULL)
"""
),
{"id": null_obs_id, "b": bank_id},
)
# An observation already populated — must be left byte-for-byte intact.
conn.execute(
text(
"""
INSERT INTO memory_units (id, bank_id, text, fact_type, search_vector)
VALUES (:id, :b, 'Already indexed observation', 'observation',
to_tsvector('english', 'Already indexed observation'))
"""
),
{"id": populated_obs_id, "b": bank_id},
)
# A non-observation row with NULL search_vector — must NOT be touched
# (the migration is scoped to fact_type = 'observation').
conn.execute(
text(
"""
INSERT INTO memory_units (id, bank_id, text, fact_type, search_vector)
VALUES (:id, :b, 'A world fact', 'world', NULL)
"""
),
{"id": world_id, "b": bank_id},
)
conn.commit()
# Run the backfill.
command.upgrade(_alembic_cfg(db_url), _BACKFILL_REVISION)
with engine.connect() as conn:
null_obs_sv, null_obs_match = conn.execute(
text(
"""
SELECT search_vector IS NOT NULL,
search_vector @@ plainto_tsquery('english', 'middleware')
FROM memory_units WHERE id = :id
"""
),
{"id": null_obs_id},
).fetchone()
assert null_obs_sv, "backfill must populate the NULL observation's search_vector"
assert null_obs_match, "backfilled tsvector must be BM25-searchable on its own text"
populated_match = conn.execute(
text("SELECT search_vector @@ plainto_tsquery('english', 'indexed') FROM memory_units WHERE id = :id"),
{"id": populated_obs_id},
).scalar()
assert populated_match, "pre-populated observation must remain searchable"
world_null = conn.execute(
text("SELECT search_vector IS NULL FROM memory_units WHERE id = :id"),
{"id": world_id},
).scalar()
assert world_null, "non-observation rows must be left untouched by the backfill"
# Idempotency: re-running touches nothing and stays at head.
command.upgrade(_alembic_cfg(db_url), "heads")
with engine.connect() as conn:
still_populated = conn.execute(
text("SELECT search_vector IS NOT NULL FROM memory_units WHERE id = :id"),
{"id": null_obs_id},
).scalar()
assert still_populated
@@ -1,5 +1,5 @@
"""
Startup/lazy model-init must fail fast instead of hanging forever.
Startup model-init must fail fast instead of hanging forever.
Covers issue #1897: if a model load blocks (e.g. an offline HuggingFace
download or an unreachable provider), initialization is capped by a wall-clock
@@ -68,7 +68,7 @@ def test_default_model_init_timeout_is_300s():
@pytest.mark.asyncio
async def test_lazy_reranker_init_fails_fast_on_hang():
async def test_reranker_ensure_initialized_fails_fast_on_hang():
"""A stuck cross-encoder load raises RuntimeError within the timeout, not forever."""
reranker = CrossEncoderReranker(cross_encoder=_HangingCrossEncoder())
config = _make_config(model_init_timeout=0.1)
@@ -81,7 +81,7 @@ async def test_lazy_reranker_init_fails_fast_on_hang():
@pytest.mark.asyncio
async def test_lazy_reranker_init_succeeds_within_timeout():
async def test_reranker_ensure_initialized_succeeds_within_timeout():
"""A fast init completes normally and marks the reranker initialized."""
encoder = _FastCrossEncoder()
reranker = CrossEncoderReranker(cross_encoder=encoder)
@@ -70,7 +70,6 @@ async def test_global_default_dtype_restored_to_float32_after_init():
query_analyzer=_NoopQueryAnalyzer(),
run_migrations=False,
skip_llm_verification=True,
lazy_reranker=False, # load the cross-encoder eagerly, in the gather
task_backend=SyncTaskBackend(),
)
@@ -15,9 +15,13 @@ from hindsight_api.config import (
_parse_llm_strategy,
)
from hindsight_api.engine.llm_wrapper import LLMProvider
from hindsight_api.engine.memory_engine import _build_llm
from hindsight_api.engine.memory_engine import _build_llm, _LLMCallDefaults
from hindsight_api.engine.multi_llm import MultiLLMProvider
# No per-request overrides — exercises the chain-resolution logic without
# touching timeout/retry defaults (those have their own tests).
_NO_CALL_DEFAULTS = _LLMCallDefaults(timeout=None, max_retries=None, initial_backoff=None, max_backoff=None)
@pytest.fixture
def clean_llm_env(monkeypatch):
@@ -248,7 +252,7 @@ def _base_llm() -> LLMProvider:
def test_build_llm_no_chain_returns_plain_provider(clean_llm_env):
base = _base_llm()
result = _build_llm(base, _empty_config(), "")
result = _build_llm(base, _empty_config(), "", _NO_CALL_DEFAULTS)
assert result is base
assert not isinstance(result, MultiLLMProvider)
@@ -259,7 +263,7 @@ def test_build_llm_global_chain_wraps(clean_llm_env):
llm_strategy=LLMStrategyConfig(mode="failover"),
)
base = _base_llm()
result = _build_llm(base, config, "")
result = _build_llm(base, config, "", _NO_CALL_DEFAULTS)
assert isinstance(result, MultiLLMProvider)
assert result.members[0] is base # primary stays index 0
assert result.members[1].provider == "ollama"
@@ -272,7 +276,7 @@ def test_build_llm_per_op_inherits_global(clean_llm_env):
retain_llm_members=[],
retain_llm_strategy=None,
)
result = _build_llm(_base_llm(), config, "retain_")
result = _build_llm(_base_llm(), config, "retain_", _NO_CALL_DEFAULTS)
assert isinstance(result, MultiLLMProvider)
assert [m.provider for m in result.members[1:]] == ["ollama"] # inherited
@@ -284,7 +288,7 @@ def test_build_llm_per_op_overrides_global(clean_llm_env):
retain_llm_members=[_member("lmstudio")],
retain_llm_strategy=LLMStrategyConfig(mode="round-robin"),
)
result = _build_llm(_base_llm(), config, "retain_")
result = _build_llm(_base_llm(), config, "retain_", _NO_CALL_DEFAULTS)
assert isinstance(result, MultiLLMProvider)
assert [m.provider for m in result.members[1:]] == ["lmstudio"]
assert result._strategy.mode == "round-robin"
@@ -294,7 +298,7 @@ def test_build_llm_members_without_strategy_stays_plain(clean_llm_env):
# Members configured but no strategy → no wrapping (strategy is required).
config = _empty_config(llm_members=[_member("ollama")], llm_strategy=None)
base = _base_llm()
assert _build_llm(base, config, "") is base
assert _build_llm(base, config, "", _NO_CALL_DEFAULTS) is base
# ── vertexai member build path (_member_to_llm) ─────────────────────────────────
@@ -333,7 +337,7 @@ def test_member_to_llm_passes_vertexai_project_and_region(clean_llm_env, monkeyp
vertexai_project_id="member-proj",
vertexai_region="europe-west1",
)
provider = _member_to_llm(member, _empty_config())
provider = _member_to_llm(member, _empty_config(), _NO_CALL_DEFAULTS)
assert provider.provider == "vertexai"
# Project/region flowed all the way to the Vertex AI SDK client.
@@ -386,7 +390,7 @@ def test_member_to_llm_passes_vertexai_service_account_key(clean_llm_env, monkey
vertexai_project_id="member-proj",
vertexai_service_account_key="/keys/member-sa.json",
)
provider = _member_to_llm(member, _empty_config())
provider = _member_to_llm(member, _empty_config(), _NO_CALL_DEFAULTS)
assert provider.provider == "vertexai"
# The member's key path was loaded, and the credentials reached the SDK client.
@@ -432,7 +436,7 @@ def test_member_to_llm_passes_litellmrouter_config(clean_llm_env, monkeypatch):
gemini_service_tier=None,
litellmrouter_config=router_cfg,
)
provider = _member_to_llm(member, _empty_config())
provider = _member_to_llm(member, _empty_config(), _NO_CALL_DEFAULTS)
assert provider.provider == "litellmrouter"
# The member's own router config flowed to the LiteLLM router build.
+166
View File
@@ -0,0 +1,166 @@
"""Pure unit tests for the OKF (Open Knowledge Format) serializer.
These exercise hindsight_api/api/okf.py with plain dicts no DB, no LLM so
they pin the OKF contract (frontmatter projection, type-from-tag, shared-tag
graph) deterministically and fast.
"""
from hindsight_api.api import okf
def _mm(**overrides):
base = {
"id": "orders",
"name": "Orders",
"source_query": "What are the order facts?",
"content": "# Orders\n\nOne row per order.",
"tags": ["type:runbook", "sales", "revenue"],
"last_refreshed_at": "2026-01-02T00:00:00Z",
"created_at": "2026-01-01T00:00:00Z",
}
base.update(overrides)
return base
class TestPageType:
def test_lifts_type_from_tag_and_drops_it(self):
pt = okf.page_type(["type:runbook", "sales", "revenue"])
assert pt.type == "runbook"
assert pt.display_tags == ["sales", "revenue"]
def test_defaults_when_no_type_tag(self):
pt = okf.page_type(["sales"])
assert pt.type == okf.DEFAULT_PAGE_TYPE
assert pt.display_tags == ["sales"]
def test_handles_none_and_empty(self):
assert okf.page_type(None).type == okf.DEFAULT_PAGE_TYPE
assert okf.page_type(None).display_tags == []
def test_blank_type_suffix_falls_back(self):
pt = okf.page_type(["type:", "sales"])
assert pt.type == okf.DEFAULT_PAGE_TYPE
# the (blank) type tag is still stripped from display tags
assert pt.display_tags == ["sales"]
def test_first_type_tag_wins(self):
pt = okf.page_type(["type:runbook", "type:guide"])
assert pt.type == "runbook"
assert pt.display_tags == []
class TestFrontmatter:
def test_projects_expected_fields(self):
fm = okf.frontmatter(_mm())
assert fm["id"] == "orders"
assert fm["type"] == "runbook"
assert fm["title"] == "Orders"
assert fm["description"] == "What are the order facts?"
assert fm["tags"] == ["sales", "revenue"]
assert fm["timestamp"] == "2026-01-02T00:00:00Z"
def test_timestamp_falls_back_to_created_at(self):
fm = okf.frontmatter(_mm(last_refreshed_at=None))
assert fm["timestamp"] == "2026-01-01T00:00:00Z"
def test_render_omits_none_and_empty(self):
rendered = okf.render_frontmatter({"type": "x", "title": None, "tags": []})
assert "title" not in rendered
assert "tags" not in rendered
assert 'type: "x"' in rendered
def test_render_quotes_and_escapes(self):
# A name that looks like a YAML bool / contains a quote must stay a string.
rendered = okf.render_frontmatter({"title": 'true "x"'})
assert 'title: "true \\"x\\""' in rendered
class TestRenderDocument:
def test_includes_frontmatter_and_body(self):
doc = okf.render_document(_mm())
assert doc.startswith("---\n")
assert 'type: "runbook"' in doc
assert "One row per order." in doc
def test_empty_body(self):
doc = okf.render_document(_mm(content=""))
assert doc.count("---") == 2
assert doc.rstrip().endswith("---")
class TestKnowledgeGraph:
def test_edge_from_shared_tag(self):
pages = [
_mm(id="orders", tags=["type:runbook", "sales", "revenue"]),
_mm(id="customers", tags=["sales", "crm"]),
_mm(id="lonely", tags=[]),
]
graph = okf.knowledge_graph(pages)
assert len(graph.nodes) == 3
assert len(graph.edges) == 1
edge = graph.edges[0]["data"]
assert {edge["source"], edge["target"]} == {"orders", "customers"}
assert edge["sharedTags"] == ["sales"]
assert edge["weight"] == 1
def test_type_tag_does_not_create_edges(self):
# Two pages sharing only a type: tag must NOT be linked.
pages = [
_mm(id="a", tags=["type:runbook"]),
_mm(id="b", tags=["type:runbook"]),
]
graph = okf.knowledge_graph(pages)
assert graph.edges == []
def test_node_carries_type_and_color(self):
graph = okf.knowledge_graph([_mm(id="orders", tags=["type:runbook", "sales"])])
node = graph.nodes[0]["data"]
assert node["type"] == "runbook"
assert node["label"] == "Orders"
assert node["tagCount"] == 1
assert node["color"].startswith("#")
def test_weight_counts_shared_tags(self):
pages = [
_mm(id="a", tags=["sales", "revenue", "x"]),
_mm(id="b", tags=["sales", "revenue", "y"]),
]
graph = okf.knowledge_graph(pages)
assert graph.edges[0]["data"]["weight"] == 2
assert graph.edges[0]["data"]["sharedTags"] == ["revenue", "sales"]
class TestReservedFiles:
def test_index_links_each_page(self):
index = okf.render_index([_mm(id="orders", name="Orders", source_query="q?")])
assert "[Orders](./orders.md)" in index
assert "q?" in index
assert 'type: "index"' in index
def test_index_empty(self):
assert "No knowledge pages yet" in okf.render_index([])
def test_index_nests_folders(self):
nodes = [
{"id": "f1", "kind": "folder", "name": "Runbooks", "parent_id": None},
{"id": "p1", "kind": "page", "name": "Orders", "parent_id": "f1", "source_query": "q?"},
{"id": "p2", "kind": "page", "name": "Loose", "parent_id": None},
]
idx = okf.render_index(nodes)
assert "**Runbooks/**" in idx
# the page nested in the folder is indented and links to its file
assert " - [Orders](./p1.md) — q?" in idx
assert "- [Loose](./p2.md)" in idx
def test_log_renders_history_newest_first(self):
history = [
{"previous_content": "v2", "changed_at": "2026-01-02T00:00:00Z"},
{"previous_content": "v1", "changed_at": "2026-01-01T00:00:00Z"},
]
log = okf.render_log(_mm(), history)
assert 'type: "log"' in log
assert log.index("2026-01-02") < log.index("2026-01-01")
assert "v2" in log and "v1" in log
def test_log_empty(self):
assert "No refresh history" in okf.render_log(_mm(), [])
@@ -17,7 +17,6 @@ def setup_test_env():
# Save original environment values
env_vars_to_set = {
"HINDSIGHT_API_SKIP_LLM_VERIFICATION": "true",
"HINDSIGHT_API_LAZY_RERANKER": "true",
"HINDSIGHT_API_LLM_PROVIDER": "mock",
"HINDSIGHT_API_LLM_MODEL": "default-model",
"HINDSIGHT_API_RETAIN_LLM_PROVIDER": "mock",
@@ -76,7 +75,6 @@ class TestPerOperationLLMConfig:
engine = MemoryEngine(
skip_llm_verification=True,
lazy_reranker=True,
)
# Verify default config
@@ -91,6 +89,33 @@ class TestPerOperationLLMConfig:
assert engine._reflect_llm_config.provider == "mock"
assert engine._reflect_llm_config.model == "reflect-model"
def test_groq_openai_service_tier_threaded_into_per_operation_configs(self, monkeypatch):
"""The groq/openai service-tier config knobs must reach every per-operation
LLM config, like bedrock/gemini already do. Previously they were parsed into
HindsightConfig but never threaded into the constructed providers, so setting
them was a silent no-op (groq is the default provider)."""
from hindsight_api import MemoryEngine
from hindsight_api.config import clear_config_cache
monkeypatch.setenv("HINDSIGHT_API_LLM_GROQ_SERVICE_TIER", "flex")
monkeypatch.setenv("HINDSIGHT_API_LLM_OPENAI_SERVICE_TIER", "flex")
clear_config_cache()
engine = MemoryEngine(
skip_llm_verification=True,
)
for cfg in (
engine._llm_config,
engine._retain_llm_config,
engine._reflect_llm_config,
engine._consolidation_llm_config,
):
assert cfg.groq_service_tier == "flex"
assert cfg.openai_service_tier == "flex"
clear_config_cache()
def test_memory_engine_with_explicit_params(self):
"""Test that explicit params override env config."""
from hindsight_api import MemoryEngine
@@ -103,7 +128,6 @@ class TestPerOperationLLMConfig:
reflect_llm_provider="mock",
reflect_llm_model="explicit-reflect",
skip_llm_verification=True,
lazy_reranker=True,
)
assert engine._llm_config.model == "explicit-default"
@@ -126,7 +150,6 @@ class TestPerOperationLLMConfig:
engine = MemoryEngine(
skip_llm_verification=True,
lazy_reranker=True,
)
# All should fall back to default
@@ -240,7 +263,6 @@ class TestRetainUsesRetainLLMConfig:
reflect_llm_provider="mock",
reflect_llm_model="reflect-specific-model",
skip_llm_verification=True,
lazy_reranker=True,
)
# Verify the retain LLM config is set correctly
@@ -266,7 +288,6 @@ class TestReflectUsesReflectLLMConfig:
reflect_llm_provider="mock",
reflect_llm_model="reflect-specific-model",
skip_llm_verification=True,
lazy_reranker=True,
)
# Verify the reflect LLM config is set correctly
@@ -292,7 +313,6 @@ class TestReflectUsesReflectLLMConfig:
reflect_llm_provider="mock",
reflect_llm_model="reflect-specific-model",
skip_llm_verification=True,
lazy_reranker=True,
)
engine._authenticate_tenant = AsyncMock() # type: ignore[method-assign]
@@ -69,6 +69,25 @@ class TestCleanAnswerText:
cleaned = _clean_answer_text(text)
assert cleaned == "Summary of findings."
def test_clean_text_recovers_leaked_done_arguments(self):
"""A done tool-call argument object rendered as text should keep only answer."""
text = """{
"answer": "Use the inbound table for API consumers.",
"directive_compliance": "Directive 1 followed.",
"memory_ids": ["mem-1"],
"mental_model_ids": [],
"observation_ids": []
}"""
cleaned = _clean_answer_text(text)
assert cleaned == "Use the inbound table for API consumers."
assert "directive_compliance" not in cleaned
def test_clean_text_leaves_non_done_json_answer_unchanged(self):
"""Plain JSON answers are valid user-visible content."""
text = '{"status": "ok", "items": [1, 2]}'
cleaned = _clean_answer_text(text)
assert cleaned == text
class TestCleanDoneAnswer:
"""Test cleanup of answer field from done() tool call that leaks structured output."""
@@ -142,6 +161,37 @@ class TestCleanDoneAnswer:
assert "Point 2" in cleaned
assert "mental_model_ids" not in cleaned
def test_clean_answer_recovers_leaked_done_arguments(self):
"""A done answer that contains leaked done arguments should keep answer content."""
text = """{
"answer": "Render two markdown tables: inbound and outbound.",
"directive_compliance": "All directives followed.",
"memory_ids": ["mem-1", "mem-2"],
"mental_model_ids": [],
"observation_ids": ["obs-1"]
}"""
cleaned = _clean_done_answer(text)
assert cleaned == "Render two markdown tables: inbound and outbound."
def test_clean_answer_recovers_fenced_leaked_done_arguments(self):
"""Some providers put leaked done arguments in a JSON code fence."""
text = """```json
{
"answer": "The current interface is HTTP only.",
"memory_ids": [],
"mental_model_ids": [],
"observation_ids": []
}
```"""
cleaned = _clean_done_answer(text)
assert cleaned == "The current interface is HTTP only."
def test_clean_answer_rejects_done_arguments_with_unexpected_keys(self):
"""Avoid rewriting user-requested JSON that happens to contain answer."""
text = '{"answer": "yes", "payload": {"format": "json"}, "memory_ids": []}'
cleaned = _clean_done_answer(text)
assert cleaned == text
class TestToolNameNormalization:
"""Test tool name normalization for various LLM output formats."""
@@ -13,6 +13,7 @@ from hindsight_api._vector_index import (
validate_extension,
)
from hindsight_api.engine.retain import bank_utils
from hindsight_api.migrations import _bootstrap_vector_extension_for_migrations
class RecordingConn:
@@ -69,6 +70,25 @@ def test_bootstrap_extension_scann_installs_vector_before_alloydb_scann():
]
def test_migration_bootstrap_vchord_skips_pgvector_preflight():
conn = RecordingConn()
_bootstrap_vector_extension_for_migrations(conn, "vchord")
assert conn.statements == ["CREATE EXTENSION IF NOT EXISTS vchord CASCADE"]
def test_migration_bootstrap_scann_uses_dispatcher_without_legacy_pgvector_check():
conn = RecordingConn()
_bootstrap_vector_extension_for_migrations(conn, "scann")
assert conn.statements == [
"CREATE EXTENSION IF NOT EXISTS vector",
"CREATE EXTENSION IF NOT EXISTS alloydb_scann CASCADE",
]
def test_scann_index_creation_defers_until_table_is_large_enough():
assert should_defer_index_creation("scann", 0)
assert should_defer_index_creation("scann", SCANN_MIN_ROWS_FOR_AUTO_INDEX - 1)
+1 -1
View File
@@ -121,7 +121,7 @@ This runs a stdio-based MCP server that can be used directly with MCP-compatible
- **Entity Graph** — Automatic entity extraction and relationship tracking
- **Temporal Reasoning** — Native support for time-based queries
- **Disposition Traits** — Configurable skepticism, literalism, and empathy influence opinion formation
- **Three Memory Types** — World facts, bank actions, and formed opinions with confidence scores
- **Three Memory Types** — World facts, experience facts (the bank's own actions), and observations
## Documentation
+2 -2
View File
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-api"
version = "0.8.3"
version = "0.8.4"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim[all]==0.8.3",
"hindsight-api-slim[all]==0.8.4",
]
[tool.uv.sources]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hindsight-cli"
version = "0.8.3"
version = "0.8.4"
edition = "2021"
authors = ["Hindsight Team"]
description = "A beautiful CLI for Hindsight - semantic memory system"
+3 -1
View File
@@ -153,7 +153,9 @@ impl ApiClient {
pub fn get_stats(&self, agent_id: &str, _verbose: bool) -> Result<AgentStats> {
self.runtime.block_on(async {
let response = self.client.get_agent_stats(agent_id, None).await?;
// Third arg is the `refresh` query param (force fresh stats); the CLI
// always reads the cached value, so pass None.
let response = self.client.get_agent_stats(agent_id, None, None).await?;
let value = response.into_inner();
// Convert to JSON Value first, then parse into our type
let json_value = serde_json::to_value(&value)?;
+145 -111
View File
@@ -180,12 +180,9 @@ impl App {
query_receiver: None,
};
// Select first item by default
// Select first bank by default. Headered lists select their first data
// row after data is loaded.
app.banks_state.select(Some(0));
app.memories_state.select(Some(0));
app.entities_state.select(Some(0));
app.documents_state.select(Some(0));
app.query_results_state.select(Some(0));
app
}
@@ -255,9 +252,7 @@ impl App {
)?;
self.memories = response.items;
if !self.memories.is_empty() && self.memories_state.selected().is_none() {
self.memories_state.select(Some(0));
}
normalize_headered_selection(&mut self.memories_state, self.memories.len());
self.status_message = format!("Loaded {} memories (limit: {}, offset: {})",
self.memories.len(), self.memories_limit, self.memories_offset);
@@ -286,9 +281,7 @@ impl App {
let response = self.client.list_entities(bank_id, Some(100), None, false)?;
self.entities = response.items;
if !self.entities.is_empty() && self.entities_state.selected().is_none() {
self.entities_state.select(Some(0));
}
normalize_headered_selection(&mut self.entities_state, self.entities.len());
self.status_message = format!("Loaded {} entities", self.entities.len());
Ok(())
@@ -298,9 +291,7 @@ impl App {
let response = self.client.list_documents(bank_id, None, Some(100), Some(0), false)?;
self.documents = response.items;
if !self.documents.is_empty() && self.documents_state.selected().is_none() {
self.documents_state.select(Some(0));
}
normalize_headered_selection(&mut self.documents_state, self.documents.len());
self.status_message = format!("Loaded {} documents", self.documents.len());
Ok(())
@@ -386,9 +377,7 @@ impl App {
match receiver.try_recv() {
Ok(QueryResult::Recall(Ok(results))) => {
self.query_results = results;
if !self.query_results.is_empty() {
self.query_results_state.select(Some(0));
}
normalize_headered_selection(&mut self.query_results_state, self.query_results.len());
self.loading = false;
self.status_message = format!("Found {} results", self.query_results.len());
self.query_receiver = None;
@@ -478,57 +467,17 @@ impl App {
self.banks_state.select(Some(i));
}
View::Memories(_) => {
let i = match self.memories_state.selected() {
Some(i) => {
if i >= self.memories.len().saturating_sub(1) {
0
} else {
i + 1
}
}
None => 0,
};
self.memories_state.select(Some(i));
select_next_headered_row(&mut self.memories_state, self.memories.len());
}
View::Entities(_) => {
let i = match self.entities_state.selected() {
Some(i) => {
if i >= self.entities.len().saturating_sub(1) {
0
} else {
i + 1
}
}
None => 0,
};
self.entities_state.select(Some(i));
select_next_headered_row(&mut self.entities_state, self.entities.len());
}
View::Documents(_) => {
let i = match self.documents_state.selected() {
Some(i) => {
if i >= self.documents.len().saturating_sub(1) {
0
} else {
i + 1
}
}
None => 0,
};
self.documents_state.select(Some(i));
select_next_headered_row(&mut self.documents_state, self.documents.len());
}
View::Query(_) => {
if self.query_mode == QueryMode::Recall {
let i = match self.query_results_state.selected() {
Some(i) => {
if i >= self.query_results.len().saturating_sub(1) {
0
} else {
i + 1
}
}
None => 0,
};
self.query_results_state.select(Some(i));
select_next_headered_row(&mut self.query_results_state, self.query_results.len());
}
}
}
@@ -550,57 +499,17 @@ impl App {
self.banks_state.select(Some(i));
}
View::Memories(_) => {
let i = match self.memories_state.selected() {
Some(i) => {
if i == 0 {
self.memories.len().saturating_sub(1)
} else {
i - 1
}
}
None => 0,
};
self.memories_state.select(Some(i));
select_previous_headered_row(&mut self.memories_state, self.memories.len());
}
View::Entities(_) => {
let i = match self.entities_state.selected() {
Some(i) => {
if i == 0 {
self.entities.len().saturating_sub(1)
} else {
i - 1
}
}
None => 0,
};
self.entities_state.select(Some(i));
select_previous_headered_row(&mut self.entities_state, self.entities.len());
}
View::Documents(_) => {
let i = match self.documents_state.selected() {
Some(i) => {
if i == 0 {
self.documents.len().saturating_sub(1)
} else {
i - 1
}
}
None => 0,
};
self.documents_state.select(Some(i));
select_previous_headered_row(&mut self.documents_state, self.documents.len());
}
View::Query(_) => {
if self.query_mode == QueryMode::Recall {
let i = match self.query_results_state.selected() {
Some(i) => {
if i == 0 {
self.query_results.len().saturating_sub(1)
} else {
i - 1
}
}
None => 0,
};
self.query_results_state.select(Some(i));
select_previous_headered_row(&mut self.query_results_state, self.query_results.len());
}
}
}
@@ -620,7 +529,7 @@ impl App {
}
}
View::Memories(_) => {
if let Some(i) = self.memories_state.selected() {
if let Some(i) = selected_headered_data_index(&self.memories_state) {
if let Some(memory) = self.memories.get(i) {
self.viewing_memory = Some(memory.clone());
self.status_message = "Viewing memory details (Esc to close)".to_string();
@@ -628,7 +537,7 @@ impl App {
}
}
View::Entities(_) => {
if let Some(i) = self.entities_state.selected() {
if let Some(i) = selected_headered_data_index(&self.entities_state) {
if let Some(entity) = self.entities.get(i).cloned() {
self.viewing_entity = Some(entity);
self.status_message = "Viewing entity details (Esc to close)".to_string();
@@ -636,7 +545,7 @@ impl App {
}
}
View::Documents(bank_id) => {
if let Some(i) = self.documents_state.selected() {
if let Some(i) = selected_headered_data_index(&self.documents_state) {
if let Some(doc) = self.documents.get(i) {
// Fetch full document content
let doc_id = doc.get("id")
@@ -664,7 +573,7 @@ impl App {
View::Query(_) => {
// View recall result details if in recall mode
if self.query_mode == QueryMode::Recall {
if let Some(i) = self.query_results_state.selected() {
if let Some(i) = selected_headered_data_index(&self.query_results_state) {
if let Some(result) = self.query_results.get(i).cloned() {
self.viewing_recall_result = Some(result);
self.status_message = "Viewing recall result (Esc to close)".to_string();
@@ -717,7 +626,7 @@ impl App {
fn delete_selected_document(&mut self) -> Result<()> {
if let View::Documents(bank_id) = &self.view {
if let Some(i) = self.documents_state.selected() {
if let Some(i) = selected_headered_data_index(&self.documents_state) {
if let Some(doc) = self.documents.get(i) {
let doc_id = doc.get("id")
.and_then(|v| v.as_str())
@@ -741,6 +650,46 @@ impl App {
}
}
fn normalize_headered_selection(state: &mut ListState, data_len: usize) {
if data_len == 0 {
state.select(None);
return;
}
let selected = match state.selected() {
Some(i) if (1..=data_len).contains(&i) => i,
Some(i) if i > data_len => data_len,
_ => 1,
};
state.select(Some(selected));
}
fn selected_headered_data_index(state: &ListState) -> Option<usize> {
state.selected()?.checked_sub(1)
}
fn select_next_headered_row(state: &mut ListState, data_len: usize) {
if data_len == 0 {
state.select(None);
return;
}
let current = selected_headered_data_index(state).unwrap_or(0);
let next = if current + 1 >= data_len { 0 } else { current + 1 };
state.select(Some(next + 1));
}
fn select_previous_headered_row(state: &mut ListState, data_len: usize) {
if data_len == 0 {
state.select(None);
return;
}
let current = selected_headered_data_index(state).unwrap_or(0);
let previous = if current == 0 { data_len - 1 } else { current - 1 };
state.select(Some(previous + 1));
}
fn ui(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
@@ -982,7 +931,8 @@ fn render_memories(f: &mut Frame, app: &mut App, area: Rect) {
.direction(Direction::Vertical)
.constraints([
Constraint::Length(7), // Memory metadata
Constraint::Min(0), // Full text content
Constraint::Percentage(45), // Full text content
Constraint::Percentage(55), // Complete JSON details
])
.split(area);
@@ -1020,6 +970,13 @@ fn render_memories(f: &mut Frame, app: &mut App, area: Rect) {
.style(Style::default().fg(Color::White));
f.render_widget(content_widget, chunks[1]);
let details_widget = Paragraph::new(format_memory_details_json(memory))
.block(Block::default().borders(Borders::ALL).title("Details JSON"))
.wrap(Wrap { trim: false })
.style(Style::default().fg(Color::White));
f.render_widget(details_widget, chunks[2]);
} else {
// Show memory list as table
let mut items = vec![
@@ -1063,6 +1020,11 @@ fn render_memories(f: &mut Frame, app: &mut App, area: Rect) {
}
}
fn format_memory_details_json(memory: &Map<String, Value>) -> String {
serde_json::to_string_pretty(memory)
.unwrap_or_else(|_| "Unable to render memory details".to_string())
}
fn render_entities(f: &mut Frame, app: &mut App, area: Rect) {
// If viewing an entity, show its details
if let Some(entity) = &app.viewing_entity {
@@ -1568,3 +1530,75 @@ pub fn run(client: &ApiClient) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn headered_selection_maps_visible_rows_to_data_indices() {
let mut state = ListState::default();
normalize_headered_selection(&mut state, 3);
assert_eq!(state.selected(), Some(1));
assert_eq!(selected_headered_data_index(&state), Some(0));
select_next_headered_row(&mut state, 3);
assert_eq!(state.selected(), Some(2));
assert_eq!(selected_headered_data_index(&state), Some(1));
select_next_headered_row(&mut state, 3);
assert_eq!(state.selected(), Some(3));
assert_eq!(selected_headered_data_index(&state), Some(2));
select_next_headered_row(&mut state, 3);
assert_eq!(state.selected(), Some(1));
assert_eq!(selected_headered_data_index(&state), Some(0));
}
#[test]
fn headered_selection_never_selects_the_header_row() {
let mut state = ListState::default();
state.select(Some(0));
normalize_headered_selection(&mut state, 2);
assert_eq!(state.selected(), Some(1));
select_previous_headered_row(&mut state, 2);
assert_eq!(state.selected(), Some(2));
assert_eq!(selected_headered_data_index(&state), Some(1));
}
#[test]
fn headered_selection_clears_when_no_data_exists() {
let mut state = ListState::default();
state.select(Some(2));
normalize_headered_selection(&mut state, 0);
assert_eq!(state.selected(), None);
select_next_headered_row(&mut state, 0);
assert_eq!(state.selected(), None);
}
#[test]
fn memory_details_json_includes_non_text_attributes() {
let memory = serde_json::json!({
"id": "mem_123",
"fact_type": "world",
"text": "Alice works at Google",
"entities": [
{"canonical_name": "Alice", "type": "person"}
],
"metadata": {
"source": "import"
}
});
let details = format_memory_details_json(memory.as_object().unwrap());
assert!(details.contains("\"id\": \"mem_123\""));
assert!(details.contains("\"entities\""));
assert!(details.contains("\"metadata\""));
}
}
+706 -1
View File
@@ -7,7 +7,7 @@ info:
name: Apache 2.0
url: https://www.apache.org/licenses/LICENSE-2.0.html
title: Hindsight HTTP API
version: 0.8.3
version: 0.8.4
servers:
- url: /
paths:
@@ -583,6 +583,19 @@ paths:
title: Bank Id
type: string
style: simple
- description: "Force a fresh recompute, bypassing the cached value (and refreshing\
\ the cache)."
explode: true
in: query
name: refresh
required: false
schema:
default: false
description: "Force a fresh recompute, bypassing the cached value (and refreshing\
\ the cache)."
title: Refresh
type: boolean
style: form
- explode: false
in: header
name: authorization
@@ -1369,6 +1382,348 @@ paths:
summary: Clear mental model content
tags:
- Mental Models
/v1/default/banks/{bank_id}/knowledge-base/tree:
get:
description: Return the knowledge base as a nested tree of folders and pages.
operationId: get_knowledge_base_tree
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/KnowledgeTreeResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Get the knowledge-base tree
tags:
- Knowledge Base
/v1/default/banks/{bank_id}/knowledge-base/folders:
post:
description: "Create a folder, optionally nested under a parent folder."
operationId: create_knowledge_folder
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CreateFolderRequest'
required: true
responses:
"201":
content:
application/json:
schema:
$ref: '#/components/schemas/KnowledgeNode'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Create a knowledge-base folder
tags:
- Knowledge Base
/v1/default/banks/{bank_id}/knowledge-base/pages:
post:
description: Create a page (a mental model + tree node). Content is generated
asynchronously; use the returned operation_id to track completion.
operationId: create_knowledge_page
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CreatePageRequest'
required: true
responses:
"201":
content:
application/json:
schema:
$ref: '#/components/schemas/CreateKnowledgePageResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Create a knowledge-base page
tags:
- Knowledge Base
/v1/default/banks/{bank_id}/knowledge-base/graph:
get:
description: "Return pages as nodes linked by shared tags, for the constellation\
\ view."
operationId: get_knowledge_base_graph
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/KnowledgePageGraphResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Knowledge-base constellation graph
tags:
- Knowledge Base
/v1/default/banks/{bank_id}/knowledge-base/export:
get:
description: "Return a portable OKF bundle: a nested index.md, one <id>.md per\
\ page, and history logs."
operationId: export_knowledge_base
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/KnowledgePageBundleResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Export the knowledge base as an OKF bundle
tags:
- Knowledge Base
/v1/default/banks/{bank_id}/knowledge-base/pages/{page_id}:
get:
description: Return a single page as an OKF document (frontmatter + markdown
body).
operationId: get_knowledge_page
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: path
name: page_id
required: true
schema:
title: Page Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/KnowledgePageResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Get a knowledge-base page
tags:
- Knowledge Base
/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}:
delete:
description: Delete a folder or page and its whole subtree (pages' mental models
are removed too).
operationId: delete_knowledge_node
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: path
name: node_id
required: true
schema:
title: Node Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema: {}
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Delete a knowledge-base node
tags:
- Knowledge Base
patch:
description: "Rename a node (set `name`) and/or move it under another folder\
\ (set `parent_id`, null for the root)."
operationId: update_knowledge_node
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: path
name: node_id
required: true
schema:
title: Node Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateNodeRequest'
required: true
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/KnowledgeNode'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Rename or move a knowledge-base node
tags:
- Knowledge Base
/v1/default/banks/{bank_id}/directives:
get:
description: List hard rules that are injected into prompts.
@@ -5082,6 +5437,42 @@ components:
- content
- name
title: CreateDirectiveRequest
CreateFolderRequest:
description: Create a folder under an optional parent folder.
example:
parent_id: parent_id
name: name
properties:
name:
title: Name
type: string
parent_id:
nullable: true
type: string
required:
- name
title: CreateFolderRequest
CreateKnowledgePageResponse:
description: "Result of creating a page: the node id, its mental model, and\
\ the refresh op."
example:
page_id: page_id
operation_id: operation_id
mental_model_id: mental_model_id
properties:
page_id:
title: Page Id
type: string
mental_model_id:
title: Mental Model Id
type: string
operation_id:
nullable: true
type: string
required:
- mental_model_id
- page_id
title: CreateKnowledgePageResponse
CreateMentalModelRequest:
description: Request model for creating a mental model.
example:
@@ -5140,6 +5531,64 @@ components:
required:
- operation_id
title: CreateMentalModelResponse
CreatePageRequest:
description: Create a page (a mental model + tree node) under an optional parent
folder.
example:
source_query: source_query
max_tokens: 0
parent_id: parent_id
name: name
trigger:
mode: full
refresh_after_consolidation: false
recall_chunks_max_tokens: 1
tag_groups:
- match: any_strict
tags:
- tags
- tags
- match: any_strict
tags:
- tags
- tags
fact_types:
- world
- world
exclude_mental_model_ids:
- exclude_mental_model_ids
- exclude_mental_model_ids
include_chunks: true
tags_match: any
exclude_mental_models: false
recall_max_tokens: 6
tags:
- tags
- tags
properties:
name:
title: Name
type: string
source_query:
title: Source Query
type: string
parent_id:
nullable: true
type: string
tags:
items:
type: string
nullable: true
type: array
max_tokens:
nullable: true
type: integer
trigger:
$ref: '#/components/schemas/MentalModelTrigger-Input'
required:
- name
- source_query
title: CreatePageRequest
CreateWebhookRequest:
description: Request model for registering a webhook.
example:
@@ -5943,6 +6392,226 @@ components:
source_facts:
$ref: '#/components/schemas/SourceFactsIncludeOptions'
title: IncludeOptions
KnowledgeNode:
description: |-
A node in the knowledge-base tree — a folder or a page.
Pages carry ``description``/``tags`` from their backing mental model. The
knowledge base is client-managed (CRUD); ``managed`` lets a client tag a node
as system-owned vs. hand-authored.
example:
children:
- null
- null
kind: folder
parent_id: parent_id
managed: false
name: name
description: description
id: id
mental_model_id: mental_model_id
tags:
- tags
- tags
timestamp: timestamp
properties:
id:
title: Id
type: string
kind:
enum:
- folder
- page
title: Kind
type: string
name:
title: Name
type: string
parent_id:
nullable: true
type: string
mental_model_id:
nullable: true
type: string
managed:
default: false
description: "Client-set flag: true = system-owned, false = hand-authored."
title: Managed
type: boolean
description:
nullable: true
type: string
tags:
default: []
items:
type: string
type: array
timestamp:
nullable: true
type: string
children:
default: []
items:
$ref: '#/components/schemas/KnowledgeNode'
type: array
required:
- id
- kind
- name
title: KnowledgeNode
KnowledgePageBundleFile:
description: One file in a portable OKF bundle.
example:
path: path
content: content
properties:
path:
title: Path
type: string
content:
title: Content
type: string
required:
- content
- path
title: KnowledgePageBundleFile
KnowledgePageBundleResponse:
description: A portable OKF bundle — a flat set of markdown files (index + pages
+ logs).
example:
files:
- path: path
content: content
- path: path
content: content
properties:
files:
items:
$ref: '#/components/schemas/KnowledgePageBundleFile'
type: array
required:
- files
title: KnowledgePageBundleResponse
KnowledgePageGraphResponse:
description: Constellation graph of knowledge pages linked by shared tags.
example:
total_edges: 6
nodes:
- key: ""
- key: ""
edges:
- key: ""
- key: ""
total_pages: 0
properties:
nodes:
items:
additionalProperties: {}
type: array
edges:
items:
additionalProperties: {}
type: array
total_pages:
title: Total Pages
type: integer
total_edges:
title: Total Edges
type: integer
required:
- edges
- nodes
- total_edges
- total_pages
title: KnowledgePageGraphResponse
KnowledgePageResponse:
description: A knowledge page rendered as an OKF document.
example:
name: name
markdown: markdown
description: description
id: id
type: type
body: body
tags:
- tags
- tags
timestamp: timestamp
properties:
id:
title: Id
type: string
name:
title: Name
type: string
type:
description: "OKF document type — from a `type:<x>` tag, else 'knowledge-page'."
title: Type
type: string
description:
nullable: true
type: string
tags:
default: []
items:
type: string
type: array
timestamp:
nullable: true
type: string
body:
nullable: true
type: string
markdown:
description: "The full OKF document: YAML frontmatter + markdown body."
title: Markdown
type: string
required:
- id
- markdown
- name
- type
title: KnowledgePageResponse
KnowledgeTreeResponse:
description: The knowledge base as a nested folder/page tree.
example:
roots:
- children:
- null
- null
kind: folder
parent_id: parent_id
managed: false
name: name
description: description
id: id
mental_model_id: mental_model_id
tags:
- tags
- tags
timestamp: timestamp
- children:
- null
- null
kind: folder
parent_id: parent_id
managed: false
name: name
description: description
id: id
mental_model_id: mental_model_id
tags:
- tags
- tags
timestamp: timestamp
properties:
roots:
items:
$ref: '#/components/schemas/KnowledgeNode'
type: array
required:
- roots
title: KnowledgeTreeResponse
LLMRequestEntry:
description: "A single LLM request trace row, as returned by the read API."
example:
@@ -6728,6 +7397,29 @@ components:
title: MentalModelResponse
MentalModelTrigger-Input:
description: Trigger settings for a mental model.
example:
mode: full
refresh_after_consolidation: false
recall_chunks_max_tokens: 1
tag_groups:
- match: any_strict
tags:
- tags
- tags
- match: any_strict
tags:
- tags
- tags
fact_types:
- world
- world
exclude_mental_model_ids:
- exclude_mental_model_ids
- exclude_mental_model_ids
include_chunks: true
tags_match: any
exclude_mental_models: false
recall_max_tokens: 6
properties:
mode:
default: full
@@ -8101,6 +8793,19 @@ components:
trigger:
$ref: '#/components/schemas/MentalModelTrigger-Input'
title: UpdateMentalModelRequest
UpdateNodeRequest:
description: Rename and/or move a node. Each field applies only when present.
example:
parent_id: parent_id
name: name
properties:
name:
nullable: true
type: string
parent_id:
nullable: true
type: string
title: UpdateNodeRequest
UpdateWebhookRequest:
description: Request model for updating a webhook. Only provided fields are
updated.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
API version: 0.8.4
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
API version: 0.8.4
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+14 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
API version: 0.8.4
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -540,9 +540,16 @@ type ApiGetAgentStatsRequest struct {
ctx context.Context
ApiService *BanksAPIService
bankId string
refresh *bool
authorization *string
}
// Force a fresh recompute, bypassing the cached value (and refreshing the cache).
func (r ApiGetAgentStatsRequest) Refresh(refresh bool) ApiGetAgentStatsRequest {
r.refresh = &refresh
return r
}
func (r ApiGetAgentStatsRequest) Authorization(authorization string) ApiGetAgentStatsRequest {
r.authorization = &authorization
return r
@@ -591,6 +598,12 @@ func (a *BanksAPIService) GetAgentStatsExecute(r ApiGetAgentStatsRequest) (*Bank
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
if r.refresh != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "refresh", r.refresh, "form", "")
} else {
var defaultValue bool = false
r.refresh = &defaultValue
}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
API version: 0.8.4
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
API version: 0.8.4
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
API version: 0.8.4
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.3
API version: 0.8.4
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.

Some files were not shown because too many files have changed in this diff Show More