Compare commits

...
Author SHA1 Message Date
Ben 3b4c73cc24 chore(paperclip): apply prettier formatting and update skills changelog 2026-04-15 09:52:08 -04:00
Ben 4d5a559a3c Merge branch 'main' into feat/paperclip-plugin-replace 2026-04-15 08:52:01 -04:00
Nicolò Boschi 568e3c3028 fix(reflect): forward mental model max_tokens to refresh (#1076)
* fix(reflect): forward mental model max_tokens to refresh

refresh_mental_model loaded the mental model (which carries a
max_tokens column populated via create/update APIs) but never forwarded
that value to reflect_async. The call therefore used reflect_async's
default of 4096, so the per-model limit was silently ignored and
refreshed content could exceed the configured cap whenever there were
enough facts to synthesize.

* fix(reflect): enforce max_tokens through gemini and agent loop

The mental_models max_tokens cap was leaking past the wire even after
refresh_mental_model started forwarding it, because:

1. The Gemini provider's call/call_with_tools silently dropped
   max_completion_tokens — it never set Gemini's max_output_tokens, so
   responses were uncapped on Gemini-backed deployments.

2. The reflect agent only passed max_completion_tokens on the
   forced-final paths. The agent can also short-circuit and return text
   directly from a tool-call iteration (the "no tool calls" branch),
   and that path used the uncapped call_with_tools.

Map max_completion_tokens to max_output_tokens in the Gemini provider
and forward it to call_with_tools in the agent loop so the mental
model's configured cap is honored end-to-end. Adds an integration test
that retains a batch of facts, refreshes a mental model with a small
max_tokens, and asserts the resulting content is within the cap.

* revert(reflect): keep tool-call iterations uncapped

Drop the max_completion_tokens forwarding into call_with_tools — only
the final-answer paths should carry the user-facing token cap. Tool-
call iterations need the full budget for tool-call JSON and intermediate
reasoning, and the forced-final synthesis path already enforces the cap
on the user-visible answer.

* test(mental-models): drop integration cap test — unit test is sufficient

The end-to-end content-length assertion was flaky: the reflect agent
can legitimately short-circuit and return text directly from a tool-
call iteration (uncapped by design, per the tool-call-budget rule),
so content length depends on which path the agent takes. The unit
test already proves the real regression (refresh_mental_model forwards
the stored max_tokens to reflect_async), and the Gemini/forced-final
provider changes are exercised by the existing reflect test suite.

* Revert "test(mental-models): drop integration cap test — unit test is sufficient"

This reverts commit 96a8644583.

* fix(reflect): cap the short-circuit answer path

When the reflect agent short-circuits and returns text directly from a
tool-call iteration (instead of the forced-final synthesis path), that
text becomes the user-visible answer and must respect max_tokens — the
same as any other final-answer path. Previously it returned uncapped
because call_with_tools is intentionally not given the cap (tool-call
iterations need full budget for tool-call JSON + intermediate reasoning).

Fix: after receiving short-circuit text, if it exceeds max_tokens, run
one extra capped rewrite call to fit it within the budget. This keeps
tool-call iterations uncapped while guaranteeing the final answer
respects the user's limit.

* test(reflect): unit-test the short-circuit rewrite with a mock LLM

Two pure-unit tests for the agent's short-circuit path:
- oversized short-circuit answer triggers a capped rewrite call and
  the final text is the rewritten version
- short-circuit answer that already fits skips the extra call

These lock in the cap behavior without needing a real LLM or DB.
2026-04-15 14:35:36 +02:00
Nicolò Boschi cbaec36f66 fix(control-plane): encode bank ids in URLs end-to-end (#1079)
Bank ids can contain URL-unsafe characters (e.g. openclaw composite ids
like `agent::channel::user`), which broke navigation and proxy requests
when interpolated raw into template strings. Some routes encoded, most
did not, leading to inconsistent routing and display.

Introduce `bankRoute`, `bankApi`, `bankStatsApi`, `memoryApi`,
`documentApi`, and `dataplaneBankUrl` helpers and migrate every bank-id
URL interpolation (client navigation, control-plane API client, and
server-side proxy routes) through them.

Refs #1069
2026-04-15 14:07:49 +02:00
Nicolò Boschi d8aada7b0e docs: update 0.5.2 blog post image 2026-04-15 13:58:59 +02:00
Nicolò Boschi 16e1cc4934 docs: add screenshots to 0.5.2 release post (#1078) 2026-04-15 13:39:02 +02:00
Nicolò Boschi 3ee9437020 release: 0.5.2 notes and blog post (#1074)
* release: 0.5.2 notes and blog post

Adds the 0.5.2 changelog entry and blog post, and teaches the
main changelog generator to exclude integration-only commits
(integrations now have their own release cadence and per-integration
changelogs).

* feat(changelog): add contributors grid to generated entries

Fetches GitHub authors for each commit via `gh api` and renders a
grid of avatars linking to their profiles at the bottom of the
entry. Applies to both the main and per-integration changelogs.
Also backfills the 0.5.2 entry with the new section.

* refactor(changelog): put author avatar next to each entry

* style(changelog): mute author/commit metadata with smaller font

* style(changelog): switch meta to emphasis color for contrast, italic handle

* style(changelog): align entry metadata in right-hand column

* style(changelog): inline GitHub-release layout (title · @author · hash)

* style(changelog): apply ruff format

* chore: regenerate docs skill mirror for 0.5.2
2026-04-15 12:26:22 +02:00
Nicolò Boschi 9671786faf release(openclaw): v0.6.4 2026-04-15 11:55:03 +02:00
Nicolò Boschi 33645e08cd feat(openclaw): session-scoped document_id and structured per-message timestamp (#1075)
- Add `retainDocumentScope` config (default `session`) so all retains within
  an OpenClaw session accumulate under one Hindsight document
  (`openclaw:{sessionKey}`) instead of minting a new per-turn document id.
  Set `retainDocumentScope: 'turn'` to keep the legacy `:turn:NNNNNN` /
  `:window:NNNNNN` suffix behavior.
- Lift OpenClaw's per-message `timestamp` into a structured `timestamp`
  ISO-8601 field on each message in the retained JSON, and strip the inline
  `[Www YYYY-MM-DD HH:MM GMT±N]` prefix OpenClaw injects into user text.
  Facts are no longer polluted by weekday/date prefixes that vary per turn.
2026-04-15 11:53:55 +02:00
Nicolò Boschi 2f5844b38d release(cloudflare-oauth-proxy): v1.0.1 2026-04-15 11:42:03 +02:00
Nicolò Boschi 1267e61edd fix(changelog): allow cloudflare-oauth-proxy in generate-changelog allowlist 2026-04-15 11:41:44 +02:00
Nicolò Boschi 931f2a77ff release(opencode): v0.1.4 2026-04-15 11:37:21 +02:00
Nicolò Boschi eeff5001af release(paperclip): v0.1.2 2026-04-15 11:37:06 +02:00
Nicolò Boschi f835c731fe release(autogen): v0.1.2 2026-04-15 11:36:54 +02:00
Nicolò Boschi b6dbd614fc release(codex): v0.2.1 2026-04-15 11:36:39 +02:00
Nicolò Boschi 32fc9b7477 release(claude-code): v0.3.1 2026-04-15 11:36:15 +02:00
Nicolò Boschi e4f54a6071 release(strands): v0.1.2 2026-04-15 11:35:57 +02:00
Nicolò Boschi 343b972a95 release(nemoclaw): v0.1.2 2026-04-15 11:35:45 +02:00
Nicolò Boschi 58c02feef0 release(llamaindex): v0.1.4 2026-04-15 11:35:32 +02:00
Nicolò Boschi a5f8b58ab5 release(langgraph): v0.1.2 2026-04-15 11:35:21 +02:00
Nicolò Boschi 78008a1ad0 release(chat): v0.4.20 2026-04-15 11:35:09 +02:00
Nicolò Boschi 2128c02e0e release(ai-sdk): v0.4.20 2026-04-15 11:34:57 +02:00
Nicolò Boschi 2eab07834a release(ag2): v0.1.2 2026-04-15 11:34:45 +02:00
Nicolò Boschi 9f41d98172 release(crewai): v0.4.20 2026-04-15 11:34:30 +02:00
Nicolò Boschi 84bab9c5b7 release(pydantic-ai): v0.4.20 2026-04-15 11:34:17 +02:00
Nicolò Boschi d73e552189 release(litellm): v0.5.1 2026-04-15 11:34:03 +02:00
Nicolò Boschi 712a862841 Release v0.5.2
- Update version to 0.5.2 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.5
2026-04-15 11:16:44 +02:00
Nicolò Boschi f64c5d2097 feat(entities): add co-occurrence graph view in control plane (#1058)
* feat(entities): add co-occurrence graph view in control plane

Adds a Relations (constellation) view to the bank Entities page, backed by
a new GET /v1/default/banks/{bank_id}/entities/graph endpoint that returns
entity nodes and co-occurrence edges from the materialized
entity_cooccurrences table.

The shared Constellation component gains optional nodeSizeFn, nodeHeatFn,
compactLabels, and legend captions so each caller can map size/color to a
meaningful dimension without touching the component internals:
  - entities: size = total co-occurrence weight, color = recency of last
    co-occurrence
  - observations: size = source fact count (proof_count), color = recency
  - world/experience memories: default sizing, color = recency

Also swaps the heat gradient from an all-blue ramp to a more contrasty
indigo -> magenta -> orange -> gold ramp so older/newer reads at a glance.

* chore(cli): skip get_entity_graph in CLI OpenAPI coverage manifest

* chore: sync generated hindsight-docs skill openapi reference

* chore(entities-graph): drop dead var, type entity-graph response

- Remove unused max_mentions accumulator in get_entity_graph.
- Replace the raw-dict node accumulator with a small dataclass.
- Tighten entities-view: store and consume the typed getEntityGraph
  response instead of casting to any.
2026-04-15 11:03:41 +02:00
Nicolò Boschi d4bf740618 fix(consolidation): tighten retry budget config handling and repair tests (#1073)
* fix(consolidation): tighten retry budget config handling and repair tests

Followup to #1064:

- Replace `getattr(config, "...", None) or 3` with explicit `is not None`
  check. Prior form silently coerced `max_attempts=0` to 3; both fields
  are now required attributes on HindsightConfig so getattr is unnecessary.
- Fix test fixtures: memories require an `id` key — without it the suite
  failed with KeyError before reaching the assertions, so the new tests
  weren't actually exercising the retry logic on main.
- Drop dead `or call_kwargs[1].get(...)` and `if ... else {}` branches
  from the assertions; `call_args.kwargs` is always a dict.

* refactor(consolidation): require config in _consolidate_batch_with_llm

The config=None default was dead defensive code — every production call
site threads config through. The None fallbacks (max_attempts=3,
observations_mission=None, etc.) silently masked bugs where config
failed to propagate.

Make config a required parameter and raise ValueError if None, so
programmer errors surface immediately instead of running with defaults.

Drops the None branches from the three config reads in the function
body and updates the test that asserted the defaulting behavior to
instead assert it raises.
2026-04-15 10:58:38 +02:00
Nicolò Boschi 70a7411659 chore(lint): share ruff/prettier config across integrations (#1072)
* chore(lint): share ruff/prettier config across integrations

Adds root ruff.toml and .prettierrc.json so every integration package is
formatted with the same rules. lint.sh now also lints integration
packages — only those with modified files locally, all of them in CI
(when $CI is set, or via LINT_ALL_INTEGRATIONS=1).

* style(integrations): apply shared ruff/prettier formatting

Mechanical reformat — output of ruff format / prettier --write under the
new shared configs. No behavior changes.

* chore: regenerate docs skill
2026-04-15 10:39:34 +02:00
r266-techandr266-tech dee581396b fix: wire consolidation retry budget to LLM call site (#1042) (#1064)
HINDSIGHT_API_CONSOLIDATION_LLM_MAX_RETRIES existed in config and docs
but was never threaded to the actual llm_config.call() in
consolidator.py — operators had no knob to limit inner retries during
upstream outages.

Also adds HINDSIGHT_API_CONSOLIDATION_MAX_ATTEMPTS (default 3) to make
the outer retry loop configurable, capping worst-case API calls per
batch from unbounded 33 to MAX_ATTEMPTS × (MAX_RETRIES + 1).

Signed-off-by: r266-tech <[email protected]>
Co-authored-by: r266-tech <[email protected]>
2026-04-15 09:32:23 +02:00
Mr. Khachaturov 6a1d5fcd30 feat(docs): move template manifests to per-file manifest_file refs (#1066)
hindsight-docs/src/data/templates.json holds both presentation metadata
and inline BankTemplateManifest bodies. A contributor who only tweaks
retain_mission has to touch a 130-line file full of metadata they did
not mean to edit.

Move each manifest into its own file under src/data/templates/. The
catalog entry keeps the presentation fields and replaces inline
manifest with a manifest_file path. The renderer uses webpack's
require.context to bundle every manifest file at build time, so
adding a template only needs a new file plus a catalog entry.
scripts/check-templates.mjs follows manifest_file off disk.

Add a "Submit a template" CTA button to the gallery banner, like the
integrations page already has.

Existing templates render unchanged in the Template Hub.
2026-04-15 09:19:08 +02:00
Mr. Khachaturov 16ed93b9a2 fix(docs): regenerate bank-template-schema.json and guard drift (#1065)
hindsight-docs/static/bank-template-schema.json is hand-edited.
Nothing regenerates it and nothing checks it. Three PRs have
changed BankTemplateManifest since it was last touched:
#902 flipped entity_labels from list[str] to list[dict[str, Any]],
#1044 added ten BankTemplateConfig fields, #1048 added three
MentalModelTrigger fields.

None of the bundled templates use the new fields, so Ajv in
check-templates.mjs still passes. A template that uses the
dict-shaped label format fails with 'should be string' on
every label.

Regenerate from BankTemplateManifest.model_json_schema() and
hook the generator into verify-generated-files alongside
generate-openapi and generate-clients.
2026-04-15 09:18:19 +02:00
Mr. Khachaturov 581bbf3fc6 fix(ts-sdk): re-export BankTemplate types from package root (#1063)
BankTemplate types were added in #819 and registered in the Python
client's hindsight_client_api.models top-level export. The TypeScript
client's hand-maintained src/index.ts re-export block was never
updated to match, so downstream TypeScript consumers cannot reach
BankTemplateManifest or its five related types from the package
root. The generated types already exist in generated/types.gen.ts,
but the package's exports field only surfaces the "." entry, which
means tsc rejects the deep subpath import.

Python and TypeScript have had an asymmetric public type surface
since #819 merged. This closes the gap by adding the five types to
the existing re-export block, matching what Python already does.

- Add BankTemplateManifest, BankTemplateConfig, BankTemplateMentalModel,
  BankTemplateDirective, BankTemplateImportResponse to the import type
  pull-in and the export type re-export block in
  hindsight-clients/typescript/src/index.ts

Non-breaking. Existing exports unchanged. No client regeneration
needed. Per CONTRIBUTING.md, src/index.ts is hand-maintained and
clients are only regenerated at release time. This commit only
widens the package's public surface.
2026-04-15 09:17:17 +02:00
Nicolò Boschi d00c843262 docs(opencode): drop npm install step, document Hindsight Cloud (#1056)
* docs(opencode): drop misleading npm install step, document Hindsight Cloud

OpenCode auto-installs plugins listed in the "plugin" array at startup via
Bun; the prior instructions to `npm install` the package were misleading.
Also add a dedicated Hindsight Cloud section with api.hindsight.vectorize.io
and token guidance.

* fix(opencode): default-export the Plugin function directly

OpenCode's plugin loader iterates Object.entries(mod) and invokes every
export as a Plugin factory `(input) => Promise<Hooks>`, deduping by
identity. Our prior default export was a PluginModule object
(`{ id, server }`), which opencode tried to call as a function and
crashed with `fn3 is not a function. (In 'fn3(input)', 'fn3' is an
instance of Object)` at load time.

Default-export the HindsightPlugin function itself so both default and
named `HindsightPlugin` exports point to the same reference (dedupe
suppresses a second call). Update the default-export smoke test to
assert this invariant.

Verified end-to-end against opencode 1.1.49 with the built dist — the
plugin now initializes, registers tools/hooks, and processes session
events without error.
2026-04-14 18:25:23 +02:00
DK09876andClaude Opus 4.6 33442f1961 fix(opencode): replace unconditional console.error with debugLog (#1057)
PR #993 added hardcoded console.error calls throughout hooks.ts for
debugging the message parsing fix. These are not gated behind the debug
config flag, so they spam every user's TUI with red error text on every
event, message parse, and retain cycle.

Replace all console.error calls with debugLog(config, ...) so they only
appear when debug: true is set in plugin options.

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-04-14 18:25:14 +02:00
Nicolò Boschi a525df4837 release(openclaw): v0.6.3 2026-04-14 18:18:24 +02:00
Nicolò Boschi 90a2201655 fix(openclaw): make identity skip filters config-aware for per-agent banking (#1054)
* fix(openclaw): make identity skip filters config-aware for per-agent banking

When dynamicBankGranularity includes 'agent', each agent should get its own
bank — including 'main' and CLI sessions. The existing filters in
getIdentitySkipReason() unconditionally rejected agent:*:main sessions,
provider 'main', and anonymous senderIds, which prevented per-agent banks
from ever being created for the main agent or any CLI-accessed agent.

Thread pluginConfig through resolveAndCacheIdentity to getIdentitySkipReason,
and when per-agent banking is enabled:
- allow agent:*:main sessions through
- allow provider 'main' (still skip cron/heartbeat/subagent)
- synthesize agent-user:<agentId> for anonymous CLI sessions

Default behavior is unchanged when dynamicBankGranularity does not include
'agent'.

Fixes #1046

* fix(openclaw): also bypass CLI session filters for static bankId mode

Broaden the carve-out so the same skip-bypass behavior fires when the user
has explicitly opted into a single named bank via dynamicBankId=false +
bankId. In that mode every session — including agent:*:main, provider 'main',
and anonymous senders — should retain into the configured bank.

The carve-out still requires a non-empty bankId; dynamicBankId=false alone
doesn't trigger it (the bank would be unresolvable).

* fix(openclaw): strip inline retain tags in structured block path

extractStructuredBlocks was calling stripMemoryTags + stripMetadataEnvelopes
but not stripInlineRetainTags, so <retain_tags>...</retain_tags> directives
survived into the retained JSON transcript on the default
retainFormat=json + retainToolCalls=true path.

* test(openclaw): update hook integration tests to default json retain format

The two transcript-format assertions still expected the legacy text markers
(`[role: user] ... [user:end]`), but the default retainFormat is now 'json'
with Anthropic-shaped typed blocks. Parse the JSON and assert against the
structured shape instead.
2026-04-14 17:55:48 +02:00
Nicolò Boschi 34365c3248 feat(control-plane): revamp bank stats view and modernize shared UI primitives (#1055)
* feat(control-plane): revamp bank stats view and modernize shared UI primitives

Rework the bank stats tab to be dashboard-grade. Adds a new memories-ingested
time-series endpoint (1h/12h/1d/7d/30d/90d, zero-filled UTC buckets, per
fact-type breakdown), per-fact-type toggleable area chart, consolidated card
layout, modern palette, period switcher, and a memory-type staleness card for
mental models.

Also modernizes shared UI primitives so the new look propagates everywhere:

- ui/card.tsx: drop the harsh white border, use a soft ring + dark-mode-aware
  shadow, rounded-xl.
- ui/table.tsx: self-contained rounded card with subtle ring, modern uppercase
  header tint, softer row borders, last-row border collapse. Callers no longer
  need border/rounded wrapping divs.
- fact-type-filter.tsx: align memory-type switch colors (World=violet,
  Experience=pink, Observation=indigo) with the stats chart palette.

Backend:
- BankStatsResponse gains operations_by_status (all statuses grouped).
- GET /v1/default/banks/{bank_id}/stats/memories-timeseries returns padded
  bucket sets anchored on UTC for a stable, timezone-independent response.
- Both fields/endpoints covered by tests in tests/test_bank_stats.py.

Clients: OpenAPI + Python/TypeScript/Go SDKs regenerated.

* fix(bank-stats-ui): appease CI — type errors, docs-skill regen, cli coverage

- bank-stats-view.tsx: use recharts TooltipContentProps (not TooltipProps) with
  Partial<> so <Tooltip content={<ChartTooltip />}> type-checks in recharts v3;
  introduce OpsStatusEntry to widen the tuple-inferred literal union.
- Regenerate skills/hindsight-docs/references/openapi.json via
  scripts/generate-docs-skill.sh so verify-generated-files passes.
- Add get_memories_timeseries to hindsight-cli/.openapi-coverage.toml skip
  list; this endpoint only makes sense for the UI chart.
2026-04-14 16:20:15 +02:00
Ben 06c912df34 blog: What's new in hindsight-openclaw 0.6 (#1040)
* blog: What's new in hindsight-openclaw 0.6
2026-04-14 09:46:38 -04:00
Nicolò Boschi 43dc50dd3f test: stabilize flaky retain and load batch tests (#1053)
- test_retain.py: pin fact_type_override="world" on retains that later
  filter recall by fact_type=["world"]; the LLM was classifying facts as
  "experience" non-deterministically, returning 0 recall results.
- test_load_large_batch.py: add disable_observations fixture so inline
  consolidation (SyncTaskBackend) doesn't run during load tests — the
  pool-under-load mock wasn't handling scope="consolidation" and was
  timing out under 10 concurrent retains.
- test_load_large_batch.py: mark the file with xdist_group so the heavy
  load tests don't contend for CPU/memory with other parallel workers.
2026-04-14 15:29:46 +02:00
Nicolò Boschi 7d5d5b2781 release(opencode): v0.1.3 2026-04-14 15:17:36 +02:00
AldousandAldous the Orchestrator b79ab2b752 feat(openclaw): merge inline retain tags with defaults (#948)
* feat(openclaw): close remaining retain parity gaps

* docs(openclaw): preserve transcript format for retain parity patch

* refactor(openclaw): drop unused retain prefix config

* fix(openclaw): keep retain tag normalization narrow

* feat(openclaw): merge inline retain tags with defaults

---------

Co-authored-by: Aldous the Orchestrator <[email protected]>
2026-04-14 14:17:50 +02:00
Mr. Khachaturov cf9918891b docs(configuration): document HINDSIGHT_API_RETAIN_CHUNK_BATCH_SIZE (#1045)
The retain_chunk_batch_size hierarchical config field and its
ENV_RETAIN_CHUNK_BATCH_SIZE loader have existed in HindsightConfig
since the retain streaming batch landed, but the Retain section of
the configuration reference never got a row for them — users who
want to cap chunk-batch size on large document ingestion had to
discover the env var by grepping the source.

Add a row to the Retain table next to the other chunk/batch knobs,
with the same format as surrounding entries and an explicit note
that the field is configurable per bank via the bank config API.
2026-04-14 14:06:43 +02:00
Nicolò Boschi 9372462e13 fix(clients): set identifying User-Agent on all HTTP requests (#1041) (#1052)
Cloudflare (and other proxies with UA-based bot filtering) block the
default "Python-urllib/X.Y" and "reqwest/..." UA strings with error 1010,
causing all retain/recall traffic to silently fail against self-hosted
deployments.

Generated-client wrappers now send "hindsight-client-<lang>/<version>"
by default and expose a user_agent/userAgent override so integrations
can identify themselves. Each integration passes its own UA
("hindsight-<integration>/<version>") at client construction.

Integrations using raw urllib/fetch (claude-code, codex, openclaw,
paperclip) set the header directly in their HTTP layer — this fixes
the reported Cloudflare 1010 issue for the claude-code plugin.
2026-04-14 13:58:44 +02:00
Nicolò Boschi f2fc8f9f26 feat(api): add recall controls to mental model trigger (#1048)
* feat(api): add recall controls to mental model trigger

Internal recall during mental model refresh used to hardcode
include_chunks=True with fixed token budgets, wasting prompt budget on
chunks that some refreshes don't need.

Adds three knobs exposed both as hierarchical config (env -> tenant ->
bank) and as per-mental-model overrides on the trigger JSONB field:

- recall_include_chunks / trigger.include_chunks
- recall_max_tokens / trigger.recall_max_tokens
- recall_chunks_max_tokens / trigger.recall_chunks_max_tokens

Trigger value (when set) wins over bank/global config. Both refresh
paths (task handler and synchronous refresh_mental_model) forward the
overrides into reflect_async.

* feat(control-plane): expose recall trigger fields in mental model dialogs

Adds form fields under the Options tab for the three new trigger
overrides (include_chunks, recall_max_tokens, recall_chunks_max_tokens)
in both the create and update mental model dialogs. Empty/Default means
inherit the bank/global config.

* fix(control-plane): cap mental model dialog height and add scroll

* style(control-plane): theme scrollbars to match app surface

* refactor(control-plane): group mental model options into Refresh/Tags/Recall sections

* refactor(control-plane): move Fact Types into Recall, add Other Mental Models section

* fix(cli): pass new recall trigger fields in MentalModelTriggerInput

* chore: regenerate hindsight-docs skill openapi/configuration

* test(hierarchical-config): bump configurable field count for new recall fields
2026-04-14 13:27:16 +02:00
Nicolò Boschi 6a80ecbf65 docs: reframe observations as evidence-grounded consolidated knowledge (#1051)
* docs: reframe observations as evidence-grounded consolidated knowledge

The previous framing leaned on "synthesis" and "patterns", which reads as
LLM summarization and undersells what observations actually are: deduplicated
beliefs grounded in specific source memories (with quotes), refined — not
overwritten — when new evidence arrives, and carrying a computed freshness
trend (stable / strengthening / weakening / stale).

* docs: regenerate hindsight-docs skill references
2026-04-14 12:30:04 +02:00
Nicolò Boschi 870bf4a3d1 feat(operations): expose task_payload and document_ids on async ops (#1049)
* feat(operations): expose task_payload and document_ids on async ops

Add a "Load raw" affordance to the operations dialog so users can
inspect which document(s) an async operation was processing. Motivated
by pending/failed retain ops where there was previously no way to tell
which content was in flight.

- API: `GET /v1/default/banks/{bank_id}/operations/{operation_id}` now
  accepts `?include_payload=true` and returns `task_payload` (the raw
  submission params). Off by default since payloads can be large.
- Retain: replaces the singular `generated_document_id` in
  `result_metadata` with a `document_ids: list[str]` that captures
  every effective doc id (user-provided or generated), via an atomic,
  idempotent JSONB set-append. Multi-doc retains and user-supplied ids
  are now visible from the operation row.
- Control plane: dialog shows `result_metadata` as JSON (always) and
  a "Load raw" button that fetches the payload on demand; handles
  parent ops (payload lives on children) with a clear message.
- Regenerate OpenAPI spec and Python/TS/Rust/Go clients.
- Add tests covering user-supplied/generated/shared document_ids and
  the include_payload query param.

* chore: regenerate hindsight-docs skill openapi.json

* fix(cli): pass new include_payload arg to get_operation_status
2026-04-14 11:57:52 +02:00
Mr. Khachaturov 099f4c925a fix(bank-template): align BankTemplateConfig with _CONFIGURABLE_FIELDS (#1044)
BankTemplateConfig declared 12 hierarchical config fields, but
HindsightConfig._CONFIGURABLE_FIELDS — the allowlist the engine uses
to decide what can be overridden per-bank — contains 22. Ten fields
existed in HindsightConfig and config_resolver.update_bank_config()
accepted them, but the template import path at
POST /v1/default/banks/{id}/import couldn't deliver them: the
manifest handler resolves overrides via BankTemplateConfig.get_config_updates(),
which is a model_dump() filter, so any field not declared on the model
is silently dropped before reaching update_bank_config().

Expose the ten missing fields on BankTemplateConfig so they flow
through get_config_updates() and reach update_bank_config() unchanged:
retain_default_strategy, retain_strategies, retain_chunk_batch_size,
mcp_enabled_tools, consolidation_llm_batch_size,
consolidation_source_facts_max_tokens,
consolidation_source_facts_max_tokens_per_observation,
max_observations_per_scope, reflect_source_facts_max_tokens,
llm_gemini_safety_settings.

No engine changes. No new validation. config_resolver.update_bank_config()
already validates these fields correctly through _CONFIGURABLE_FIELDS;
the template manifest schema was the only thing blocking the path.

Adds a parametrized integration test that POSTs each new field through
/v1/default/banks/{id}/import and asserts the applied value round-trips
via GET /v1/default/banks/{id}/config under the "overrides" slot, matching
the shape test_import_applies_config already uses at
tests/test_bank_templates.py.
2026-04-14 11:54:34 +02:00
Nicolò Boschi e08faadc17 feat(worker): log [PENDING_BREAKDOWN] bucketing pending rows by claim filter (#1050)
Production incident: a 'pending' retain sat in the queue for hours while
workers had free slots. WORKER_STATS only reports the global pending count,
so there was no way to tell whether the rows were claimable-but-not-claimed
(real bug) vs filtered out by the claim WHERE clause (data state — orphaned
batch_retain parents with task_payload IS NULL, retry backoff, or worker_id
already stamped).

Add one extra periodic line, only when global_pending > 0, that buckets
pending rows per operation_type by the predicates the claim query filters
on. ``claimable`` is the residual that should be picked up next poll; if
``claimable > 0`` while workers report free slots, the bug is somewhere
else (lock contention, tenant discovery) and that line narrows the search.

[PENDING_BREAKDOWN] batch_retain: total=1 claimable=0 payload_null=1 ...
                  | retain: total=3 claimable=1 payload_null=0 retry_blocked=1 assigned=1
                  | consolidation: total=26 claimable=26 payload_null=0 ...

Implementation reuses the existing per-schema loop in _log_progress_if_due,
adding one GROUP BY query per schema. Buckets are aggregated across schemas
before rendering.
2026-04-14 11:36:38 +02:00
Nicolò Boschi dbd1d1a743 fix(retain): prevent IndexError on embeddings/facts length mismatch (#1037) (#1047)
`generate_embeddings_batch` now raises if the backend returns a different
number of vectors than input texts, instead of letting `zip()` silently
drop facts and surface later as `IndexError` in `_map_results_to_contents`.

`_map_results_to_contents` is also reworked to iterate `processed_facts`
(which is 1:1 with `unit_ids` by construction) and validates the lengths
match, providing defense-in-depth against any future drift.
2026-04-14 11:14:07 +02:00
Ben c084765950 blog: Update OpenClaw post for v0.6.0/v0.6.2 (#1038)
* blog: update OpenClaw post to reflect v0.6.0/v0.6.2 plugin changes
2026-04-13 15:39:00 -04:00
Nicolò Boschi d6ad53986a feat: add hindsight-architect skill (#1035) 2026-04-13 18:13:44 +02:00
DK09876andClaude Opus 4.6 6076354a9c fix(opencode): fix message parsing, shared state, and post-compaction retain (#1034)
Three bugs fixed:
1. msg.role → msg.info.role: OpenCode SDK wraps role inside info, so all
   messages were silently filtered out, breaking retain and recall (#941)
2. Move PluginState to module level so it persists across sessions instead
   of being recreated per plugin instantiation
3. Reset lastRetainedTurn after compaction so idle-retain resumes when the
   message list shrinks

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-04-13 18:05:25 +02:00
Ben 3960764522 feat(paperclip): replace library with Paperclip plugin (v0.2.0)
Replaces the @vectorize-io/hindsight-paperclip npm library with a proper
Paperclip plugin. Works with all adapter types (Claude, Codex, Cursor, HTTP,
Process) via the event system — no code changes required by operators.

- Auto-recalls on agent.run.started, auto-retains on agent.run.finished
- hindsight_recall and hindsight_retain agent tools for mid-run access
- onValidateConfig with live connectivity check
- 15 tests passing
2026-04-08 08:57:24 -04:00
558 changed files with 20200 additions and 8879 deletions
+4
View File
@@ -2547,6 +2547,9 @@ jobs:
- name: Run generate-openapi
run: ./scripts/generate-openapi.sh
- name: Run generate-bank-template-schema
run: ./scripts/generate-bank-template-schema.sh
- name: Run generate-clients
run: ./scripts/generate-clients.sh
@@ -2566,6 +2569,7 @@ jobs:
echo ""
echo "Please run the following commands locally and commit the changes:"
echo " ./scripts/generate-openapi.sh"
echo " ./scripts/generate-bank-template-schema.sh"
echo " ./scripts/generate-clients.sh"
echo " ./scripts/generate-docs-skill.sh"
echo " ./scripts/hooks/lint.sh"
+7
View File
@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.5.1
appVersion: "0.5.1"
version: 0.5.2
appVersion: "0.5.2"
keywords:
- ai
- memory
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@vectorize-io/hindsight-all",
"version": "0.5.1",
"version": "0.5.2",
"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",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-all-slim"
version = "0.5.1"
version = "0.5.2"
description = "Hindsight: Agent Memory That Works Like Human Memory - Slim All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-all"
version = "0.5.1"
version = "0.5.2"
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -46,4 +46,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.5.1"
__version__ = "0.5.2"
+188 -2
View File
@@ -294,6 +294,44 @@ class EntityListResponse(BaseModel):
offset: int
class EntityGraphResponse(BaseModel):
"""Response model for entity co-occurrence graph endpoint."""
model_config = ConfigDict(
json_schema_extra={
"example": {
"nodes": [
{"data": {"id": "uuid-1", "label": "Alice", "mentionCount": 12, "color": "#42a5f5"}},
{"data": {"id": "uuid-2", "label": "Google", "mentionCount": 8, "color": "#42a5f5"}},
],
"edges": [
{
"data": {
"id": "uuid-1-uuid-2",
"source": "uuid-1",
"target": "uuid-2",
"linkType": "cooccurrence",
"weight": 5,
"color": "#ffd700",
"lineStyle": "solid",
"lastCooccurred": "2024-02-01T14:00:00Z",
}
}
],
"total_entities": 2,
"total_edges": 1,
"limit": 1000,
}
}
)
nodes: list[dict[str, Any]]
edges: list[dict[str, Any]]
total_entities: int
total_edges: int
limit: int
class EntityDetailResponse(BaseModel):
"""Response model for entity detail endpoint."""
@@ -1431,12 +1469,37 @@ class BankStatsResponse(BaseModel):
links_breakdown: dict[str, dict[str, int]]
pending_operations: int
failed_operations: int
operations_by_status: dict[str, int] = Field(
default_factory=dict,
description="Async operations grouped by status (pending, in_progress, completed, failed, cancelled).",
)
# Consolidation stats
last_consolidated_at: str | None = Field(default=None, description="When consolidation last ran (ISO format)")
pending_consolidation: int = Field(default=0, description="Number of memories not yet processed into observations")
total_observations: int = Field(default=0, description="Total number of observations")
class MemoryTimeseriesBucket(BaseModel):
"""One bucket in the memory ingestion time-series."""
time: str = Field(description="Bucket start timestamp in ISO-8601 (UTC).")
world: int = Field(default=0, description="World-fact memories ingested in this bucket.")
experience: int = Field(default=0, description="Experience memories ingested in this bucket.")
observation: int = Field(default=0, description="Observations recorded in this bucket.")
class MemoriesTimeseriesResponse(BaseModel):
"""Time-series of memory ingestion bucketed by time and fact type."""
bank_id: str
period: str = Field(description="One of: 1h, 12h, 1d, 7d, 30d, 90d.")
trunc: str = Field(description="Bucket granularity: minute, hour, day.")
buckets: list[MemoryTimeseriesBucket] = Field(
default_factory=list,
description="Per-bucket counts, always returned fully padded for the requested period.",
)
# Mental Model models
@@ -1526,6 +1589,27 @@ class MentalModelTrigger(BaseModel):
"Supports nested and/or/not expressions for complex tag-based scoping."
),
)
include_chunks: bool | None = Field(
default=None,
description=(
"Override whether the internal recall used during refresh returns raw chunk text. "
"None means use the bank/global config default (recall_include_chunks)."
),
)
recall_max_tokens: int | None = Field(
default=None,
description=(
"Override the token budget for facts returned by the internal recall during refresh. "
"None means use the bank/global config default (recall_max_tokens)."
),
)
recall_chunks_max_tokens: int | None = Field(
default=None,
description=(
"Override the token budget for raw chunks returned by the internal recall during refresh. "
"None means use the bank/global config default (recall_chunks_max_tokens)."
),
)
@field_validator("fact_types")
@classmethod
@@ -1673,6 +1757,36 @@ class BankTemplateConfig(BaseModel):
entities_allow_free_form: bool | None = Field(
default=None, description="Allow entities outside the label vocabulary"
)
retain_default_strategy: str | None = Field(
default=None, description="Name of the default retain strategy (key into retain_strategies map)"
)
retain_strategies: dict | None = Field(
default=None, description="Map of retain strategy name to per-strategy config dict"
)
retain_chunk_batch_size: int | None = Field(
default=None, description="Max chunks per streaming batch (0 disables batching)"
)
mcp_enabled_tools: list[str] | None = Field(
default=None, description="MCP tool allowlist for this bank (None = all tools)"
)
consolidation_llm_batch_size: int | None = Field(
default=None, description="LLM batch size for observation consolidation"
)
consolidation_source_facts_max_tokens: int | None = Field(
default=None, description="Max tokens of source facts per consolidation batch"
)
consolidation_source_facts_max_tokens_per_observation: int | None = Field(
default=None, description="Max tokens of source facts per observation"
)
max_observations_per_scope: int | None = Field(
default=None, description="Max observations to retain per consolidation scope"
)
reflect_source_facts_max_tokens: int | None = Field(
default=None, description="Max tokens of source facts per reflect call"
)
llm_gemini_safety_settings: list | None = Field(
default=None, description="Per-bank Gemini/VertexAI safety filter settings"
)
def get_config_updates(self) -> dict[str, Any]:
"""Return only the fields that were explicitly set (non-None)."""
@@ -2084,6 +2198,10 @@ class OperationStatusResponse(BaseModel):
child_operations: list[ChildOperationStatus] | None = Field(
default=None, description="Child operations for batch operations (if applicable)"
)
task_payload: dict[str, Any] | None = Field(
default=None,
description="Raw task payload (params the operation was submitted with). Only populated when include_payload=true.",
)
class AsyncOperationSubmitResponse(BaseModel):
@@ -3248,6 +3366,7 @@ def _register_routes(app: FastAPI):
links_breakdown=links_breakdown,
pending_operations=ops.get("pending", 0),
failed_operations=ops.get("failed", 0),
operations_by_status=ops,
last_consolidated_at=stats["last_consolidated_at"],
pending_consolidation=stats["pending_consolidation"],
total_observations=stats["total_observations"],
@@ -3263,6 +3382,35 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in /v1/default/banks/{bank_id}/stats: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/stats/memories-timeseries",
response_model=MemoriesTimeseriesResponse,
summary="Memory ingestion time-series",
description="Memories ingested over a period, bucketed by time and broken down by fact type.",
operation_id="get_memories_timeseries",
tags=["Banks"],
)
async def api_memories_timeseries(
bank_id: str,
period: str = "7d",
request_context: RequestContext = Depends(get_request_context),
):
try:
data = await app.state.memory.get_memories_timeseries(
bank_id, period=period, request_context=request_context
)
return MemoriesTimeseriesResponse(**data)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in /v1/default/banks/{bank_id}/stats/memories-timeseries: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/entities",
response_model=EntityListResponse,
@@ -3299,6 +3447,36 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in /v1/default/banks/{bank_id}/entities: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/entities/graph",
response_model=EntityGraphResponse,
summary="Get entity co-occurrence graph",
description="Return a graph of entities (nodes) and their co-occurrences (edges) for visualization.",
operation_id="get_entity_graph",
tags=["Entities"],
)
async def api_entity_graph(
bank_id: str,
limit: int = Query(default=1000, description="Maximum number of co-occurrence edges to return"),
min_count: int = Query(default=1, description="Minimum cooccurrence_count to include an edge"),
request_context: RequestContext = Depends(get_request_context),
):
"""Return entity co-occurrence graph for a bank."""
try:
return await app.state.memory.get_entity_graph(
bank_id, limit=limit, min_count=min_count, request_context=request_context
)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in /v1/default/banks/{bank_id}/entities/graph: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/entities/{entity_id}",
response_model=EntityDetailResponse,
@@ -4168,7 +4346,13 @@ def _register_routes(app: FastAPI):
tags=["Operations"],
)
async def api_get_operation_status(
bank_id: str, operation_id: str, request_context: RequestContext = Depends(get_request_context)
bank_id: str,
operation_id: str,
include_payload: bool = Query(
default=False,
description="Include the raw task payload (submission params) in the response. May be large.",
),
request_context: RequestContext = Depends(get_request_context),
):
"""Get the status of an async operation."""
try:
@@ -4178,7 +4362,9 @@ def _register_routes(app: FastAPI):
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid operation_id format: {operation_id}")
result = await app.state.memory.get_operation_status(bank_id, operation_id, request_context=request_context)
result = await app.state.memory.get_operation_status(
bank_id, operation_id, request_context=request_context, include_payload=include_payload
)
return OperationStatusResponse(**result)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
@@ -339,6 +339,7 @@ ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS = "HINDSIGHT_API_CONSOLIDATION_SOURCE_
ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION = (
"HINDSIGHT_API_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION"
)
ENV_CONSOLIDATION_MAX_ATTEMPTS = "HINDSIGHT_API_CONSOLIDATION_MAX_ATTEMPTS"
ENV_OBSERVATIONS_MISSION = "HINDSIGHT_API_OBSERVATIONS_MISSION"
ENV_MAX_OBSERVATIONS_PER_SCOPE = "HINDSIGHT_API_MAX_OBSERVATIONS_PER_SCOPE"
ENV_ENABLE_OBSERVATION_HISTORY = "HINDSIGHT_API_ENABLE_OBSERVATION_HISTORY"
@@ -387,6 +388,9 @@ ENV_REFLECT_MAX_CONTEXT_TOKENS = "HINDSIGHT_API_REFLECT_MAX_CONTEXT_TOKENS"
ENV_REFLECT_WALL_TIMEOUT = "HINDSIGHT_API_REFLECT_WALL_TIMEOUT"
ENV_REFLECT_MISSION = "HINDSIGHT_API_REFLECT_MISSION"
ENV_REFLECT_SOURCE_FACTS_MAX_TOKENS = "HINDSIGHT_API_REFLECT_SOURCE_FACTS_MAX_TOKENS"
ENV_RECALL_INCLUDE_CHUNKS = "HINDSIGHT_API_RECALL_INCLUDE_CHUNKS"
ENV_RECALL_MAX_TOKENS = "HINDSIGHT_API_RECALL_MAX_TOKENS"
ENV_RECALL_CHUNKS_MAX_TOKENS = "HINDSIGHT_API_RECALL_CHUNKS_MAX_TOKENS"
# Audit log settings
ENV_AUDIT_LOG_ENABLED = "HINDSIGHT_API_AUDIT_LOG_ENABLED"
@@ -551,6 +555,7 @@ DEFAULT_FILE_DELETE_AFTER_RETAIN = True # Delete file bytes after retain (saves
DEFAULT_ENABLE_OBSERVATIONS = True # Observations enabled by default
DEFAULT_ENABLE_OBSERVATION_HISTORY = True # Observation history tracking enabled by default
DEFAULT_ENABLE_MENTAL_MODEL_HISTORY = True # Mental model history tracking enabled by default
DEFAULT_CONSOLIDATION_MAX_ATTEMPTS = 3 # Outer retry attempts for consolidation LLM batch calls
DEFAULT_CONSOLIDATION_BATCH_SIZE = 50 # Memories to load per batch (internal memory optimization)
DEFAULT_CONSOLIDATION_LLM_BATCH_SIZE = 8 # Facts per LLM call (1 = no batching; >1 = batch mode)
DEFAULT_CONSOLIDATION_MAX_TOKENS = 512 # Max tokens for recall when finding related observations
@@ -587,6 +592,9 @@ DEFAULT_REFLECT_MAX_ITERATIONS = 10 # Max tool call iterations before forcing r
DEFAULT_REFLECT_MAX_CONTEXT_TOKENS = 100_000 # Max accumulated context tokens before forcing final prompt
DEFAULT_REFLECT_WALL_TIMEOUT = 300 # Wall-clock timeout in seconds for the entire reflect operation (5 minutes)
DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS = -1 # Token budget for source facts in search_observations (-1 = disabled)
DEFAULT_RECALL_INCLUDE_CHUNKS = True # Whether internal recall (e.g. mental model refresh) returns raw chunks
DEFAULT_RECALL_MAX_TOKENS = 2048 # Token budget for facts returned by internal recall
DEFAULT_RECALL_CHUNKS_MAX_TOKENS = 1000 # Token budget for raw chunks returned by internal recall
# Disposition defaults (None = not set, fall back to bank DB value or 3)
DEFAULT_DISPOSITION_SKEPTICISM = None
@@ -911,6 +919,7 @@ class HindsightConfig:
consolidation_max_tokens: int
consolidation_source_facts_max_tokens: int
consolidation_source_facts_max_tokens_per_observation: int
consolidation_max_attempts: int
observations_mission: str | None
max_observations_per_scope: int
@@ -925,6 +934,11 @@ class HindsightConfig:
reflect_mission: str | None
reflect_source_facts_max_tokens: int
# Recall settings (used by internal recall, e.g. during mental model refresh)
recall_include_chunks: bool
recall_max_tokens: int
recall_chunks_max_tokens: int
# Disposition settings (hierarchical - can be overridden per bank; None = fall back to DB)
disposition_skepticism: int | None
disposition_literalism: int | None
@@ -1038,6 +1052,10 @@ class HindsightConfig:
# Reflect settings
"reflect_mission",
"reflect_source_facts_max_tokens",
# Recall settings (used by internal recall, e.g. mental model refresh)
"recall_include_chunks",
"recall_max_tokens",
"recall_chunks_max_tokens",
# Disposition settings
"disposition_skepticism",
"disposition_literalism",
@@ -1489,6 +1507,9 @@ class HindsightConfig:
str(DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION),
)
),
consolidation_max_attempts=int(
os.getenv(ENV_CONSOLIDATION_MAX_ATTEMPTS, str(DEFAULT_CONSOLIDATION_MAX_ATTEMPTS))
),
observations_mission=os.getenv(ENV_OBSERVATIONS_MISSION) or DEFAULT_OBSERVATIONS_MISSION,
max_observations_per_scope=int(
os.getenv(ENV_MAX_OBSERVATIONS_PER_SCOPE, str(DEFAULT_MAX_OBSERVATIONS_PER_SCOPE))
@@ -1523,6 +1544,12 @@ class HindsightConfig:
reflect_source_facts_max_tokens=int(
os.getenv(ENV_REFLECT_SOURCE_FACTS_MAX_TOKENS, str(DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS))
),
recall_include_chunks=os.getenv(ENV_RECALL_INCLUDE_CHUNKS, str(DEFAULT_RECALL_INCLUDE_CHUNKS)).lower()
in ("true", "1", "yes"),
recall_max_tokens=int(os.getenv(ENV_RECALL_MAX_TOKENS, str(DEFAULT_RECALL_MAX_TOKENS))),
recall_chunks_max_tokens=int(
os.getenv(ENV_RECALL_CHUNKS_MAX_TOKENS, str(DEFAULT_RECALL_CHUNKS_MAX_TOKENS))
),
# Disposition settings (None = fall back to DB value)
disposition_skepticism=int(os.getenv(ENV_DISPOSITION_SKEPTICISM))
if os.getenv(ENV_DISPOSITION_SKEPTICISM)
@@ -1131,11 +1131,13 @@ async def _consolidate_batch_with_llm(
memories: list[dict[str, Any]],
union_observations: "list[MemoryFact]",
union_source_facts: "dict[str, MemoryFact]",
config: Any = None,
config: Any,
remaining_observation_slots: int | None = None,
max_observations_per_scope: int = -1,
) -> _BatchLLMResult:
"""Single LLM call for a batch of facts against a pooled set of observations."""
if config is None:
raise ValueError("config is required for _consolidate_batch_with_llm")
if union_observations:
obs_list = _build_observations_for_llm(union_observations, union_source_facts)
observations_text = json.dumps(obs_list, indent=2)
@@ -1172,8 +1174,7 @@ async def _consolidate_batch_with_llm(
f"(out of {max_observations_per_scope}). Prefer UPDATE over CREATE when possible."
)
observations_mission = config.observations_mission if config is not None else None
prompt_template = build_batch_consolidation_prompt(observations_mission, observation_capacity_note)
prompt_template = build_batch_consolidation_prompt(config.observations_mission, observation_capacity_note)
prompt = prompt_template.format(
facts_text=facts_lines,
observations_text=observations_text,
@@ -1182,15 +1183,19 @@ async def _consolidate_batch_with_llm(
# Use a constrained response model when observation limit is active
response_model = _build_response_model(max_creates=remaining_observation_slots)
max_attempts = 3
max_attempts = config.consolidation_max_attempts
inner_max_retries = config.consolidation_llm_max_retries
last_exc: Exception | None = None
for attempt in range(1, max_attempts + 1):
try:
response: _ConsolidationBatchResponse = await llm_config.call(
messages=[{"role": "user", "content": prompt}],
response_format=response_model,
scope="consolidation",
)
call_kwargs: dict[str, Any] = {
"messages": [{"role": "user", "content": prompt}],
"response_format": response_model,
"scope": "consolidation",
}
if inner_max_retries is not None:
call_kwargs["max_retries"] = inner_max_retries
response: _ConsolidationBatchResponse = await llm_config.call(**call_kwargs)
# Defensive truncation: some LLM providers may not enforce JSON schema max_length
creates = response.creates
if remaining_observation_slots is not None and remaining_observation_slots >= 0:
@@ -24,7 +24,13 @@ import asyncpg
import httpx
import tiktoken
from ..config import DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS, get_config
from ..config import (
DEFAULT_RECALL_CHUNKS_MAX_TOKENS,
DEFAULT_RECALL_INCLUDE_CHUNKS,
DEFAULT_RECALL_MAX_TOKENS,
DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS,
get_config,
)
from ..metrics import get_metrics_collector
from ..tracing import create_operation_span
from ..utils import mask_network_location
@@ -228,6 +234,44 @@ def _get_tiktoken_encoding():
return _TIKTOKEN_ENCODING
@dataclass(frozen=True)
class _TimeseriesPeriodConfig:
"""How one period slices the time axis for the memories-ingested chart."""
interval: str # postgres interval literal used in the `now() - interval '...'` filter
trunc: str # date_trunc unit (minute/hour/day)
step: timedelta # distance between adjacent buckets
count: int # total buckets rendered for the period
_MEMORIES_TIMESERIES_PERIODS: dict[str, _TimeseriesPeriodConfig] = {
"1h": _TimeseriesPeriodConfig("1 hour", "minute", timedelta(minutes=1), 60),
"12h": _TimeseriesPeriodConfig("12 hours", "hour", timedelta(hours=1), 12),
"1d": _TimeseriesPeriodConfig("24 hours", "hour", timedelta(hours=1), 24),
"7d": _TimeseriesPeriodConfig("7 days", "day", timedelta(days=1), 7),
"30d": _TimeseriesPeriodConfig("30 days", "day", timedelta(days=1), 30),
"90d": _TimeseriesPeriodConfig("90 days", "day", timedelta(days=1), 90),
}
@dataclass
class MemoryTimeseriesBucketData:
"""One bucket of the memories-ingested time series (engine-side)."""
time: str
world: int = 0
experience: int = 0
observation: int = 0
def as_dict(self) -> dict[str, Any]:
return {
"time": self.time,
"world": self.world,
"experience": self.experience,
"observation": self.observation,
}
@dataclass(frozen=True)
class RefreshTagFiltering:
"""Resolved tag filtering parameters for mental model refresh."""
@@ -952,6 +996,9 @@ class MemoryEngine(MemoryEngineInterface):
fact_types = trigger_data.get("fact_types")
exclude_mental_models = trigger_data.get("exclude_mental_models", False)
stored_exclude_ids: list[str] = trigger_data.get("exclude_mental_model_ids") or []
recall_include_chunks_override = trigger_data.get("include_chunks")
recall_max_tokens_override = trigger_data.get("recall_max_tokens")
recall_chunks_max_tokens_override = trigger_data.get("recall_chunks_max_tokens")
tag_filtering = _resolve_refresh_tag_filtering(mental_model.get("tags"), trigger_data)
@@ -967,6 +1014,9 @@ class MemoryEngine(MemoryEngineInterface):
fact_types=fact_types,
exclude_mental_models=exclude_mental_models,
exclude_mental_model_ids=list({*stored_exclude_ids, mental_model_id}),
recall_include_chunks=recall_include_chunks_override,
recall_max_tokens_override=recall_max_tokens_override,
recall_chunks_max_tokens_override=recall_chunks_max_tokens_override,
)
generated_content = reflect_result.text or "No content generated"
@@ -5399,6 +5449,9 @@ class MemoryEngine(MemoryEngineInterface):
exclude_mental_model_ids: list[str] | None = None,
fact_types: list[str] | None = None,
exclude_mental_models: bool = False,
recall_include_chunks: bool | None = None,
recall_max_tokens_override: int | None = None,
recall_chunks_max_tokens_override: int | None = None,
_skip_span: bool = False,
) -> ReflectResult:
"""
@@ -5521,6 +5574,23 @@ class MemoryEngine(MemoryEngineInterface):
"reflect_source_facts_max_tokens", DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS
)
# Resolve recall overrides: caller arg (e.g. mental model trigger) → bank config → env default
effective_recall_include_chunks = (
recall_include_chunks
if recall_include_chunks is not None
else config_dict.get("recall_include_chunks", DEFAULT_RECALL_INCLUDE_CHUNKS)
)
effective_recall_max_tokens = (
recall_max_tokens_override
if recall_max_tokens_override is not None
else config_dict.get("recall_max_tokens", DEFAULT_RECALL_MAX_TOKENS)
)
effective_recall_chunks_max_tokens = (
recall_chunks_max_tokens_override
if recall_chunks_max_tokens_override is not None
else config_dict.get("recall_chunks_max_tokens", DEFAULT_RECALL_CHUNKS_MAX_TOKENS)
)
async def search_observations_fn(q: str, max_tokens: int = 5000) -> dict[str, Any]:
return await tool_search_observations(
self,
@@ -5541,7 +5611,14 @@ class MemoryEngine(MemoryEngineInterface):
recall_fact_types = [ft for ft in (fact_types or ["world", "experience"]) if ft in ("world", "experience")]
include_recall = bool(recall_fact_types)
async def recall_fn(q: str, max_tokens: int = 4096, max_chunk_tokens: int = 1000) -> dict[str, Any]:
# Defaults are bound at closure-definition time (re-evaluated on each
# reflect_async call), so per-bank/per-trigger overrides apply when the
# agent invokes recall without explicit token args.
async def recall_fn(
q: str,
max_tokens: int = effective_recall_max_tokens,
max_chunk_tokens: int = effective_recall_chunks_max_tokens,
) -> dict[str, Any]:
return await tool_recall(
self,
bank_id,
@@ -5553,6 +5630,7 @@ class MemoryEngine(MemoryEngineInterface):
tag_groups=tag_groups,
max_chunk_tokens=max_chunk_tokens,
fact_types=recall_fact_types if fact_types is not None else None,
include_chunks=effective_recall_include_chunks,
)
async def expand_fn(memory_ids: list[str], depth: str) -> dict[str, Any]:
@@ -5908,6 +5986,108 @@ class MemoryEngine(MemoryEngineInterface):
"offset": offset,
}
async def get_entity_graph(
self,
bank_id: str,
*,
limit: int = 1000,
min_count: int = 1,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Get entity co-occurrence graph for visualization.
Returns nodes for entities and edges from the materialized
entity_cooccurrences table. Edges are ordered by cooccurrence_count DESC
and capped at `limit` to keep the payload renderable.
"""
await self._authenticate_tenant(request_context)
if self._operation_validator:
from hindsight_api.extensions import BankReadContext
ctx = BankReadContext(bank_id=bank_id, operation="get_entity_graph", request_context=request_context)
await self._validate_operation(self._operation_validator.validate_bank_read(ctx))
pool = await self._get_pool()
async with acquire_with_retry(pool) as conn:
edge_rows = await conn.fetch(
f"""
SELECT ec.entity_id_1,
ec.entity_id_2,
ec.cooccurrence_count,
ec.last_cooccurred,
e1.canonical_name AS name_1,
e1.mention_count AS mention_count_1,
e2.canonical_name AS name_2,
e2.mention_count AS mention_count_2
FROM {fq_table("entity_cooccurrences")} ec
JOIN {fq_table("entities")} e1 ON e1.id = ec.entity_id_1
JOIN {fq_table("entities")} e2 ON e2.id = ec.entity_id_2
WHERE e1.bank_id = $1
AND e2.bank_id = $1
AND ec.cooccurrence_count >= $2
ORDER BY ec.cooccurrence_count DESC, ec.last_cooccurred DESC
LIMIT $3
""",
bank_id,
min_count,
limit,
)
@dataclass
class _EntityNode:
id: str
label: str
mention_count: int
nodes_by_id: dict[str, _EntityNode] = {}
edges: list[dict[str, Any]] = []
for row in edge_rows:
for eid, name, mentions in (
(row["entity_id_1"], row["name_1"], row["mention_count_1"]),
(row["entity_id_2"], row["name_2"], row["mention_count_2"]),
):
key = str(eid)
if key not in nodes_by_id:
nodes_by_id[key] = _EntityNode(id=key, label=name, mention_count=mentions or 0)
from_id = str(row["entity_id_1"])
to_id = str(row["entity_id_2"])
count = row["cooccurrence_count"]
edges.append(
{
"data": {
"id": f"{from_id}-{to_id}",
"source": from_id,
"target": to_id,
"linkType": "cooccurrence",
"weight": count,
"color": "#ffd700",
"lineStyle": "solid",
"lastCooccurred": row["last_cooccurred"].isoformat() if row["last_cooccurred"] else None,
}
}
)
nodes = [
{
"data": {
"id": n.id,
"label": n.label,
"mentionCount": n.mention_count,
"color": "#42a5f5" if n.mention_count > 1 else "#90caf9",
}
}
for n in nodes_by_id.values()
]
return {
"nodes": nodes,
"edges": edges,
"total_entities": len(nodes),
"total_edges": len(edges),
"limit": limit,
}
async def list_tags(
self,
bank_id: str,
@@ -6123,6 +6303,90 @@ class MemoryEngine(MemoryEngineInterface):
"total_observations": node_counts.get("observation", 0),
}
async def get_memories_timeseries(
self,
bank_id: str,
*,
period: str,
request_context: "RequestContext",
) -> dict[str, Any]:
"""Memory ingestion bucketed by time, broken down by fact_type.
Always returns the full expected bucket set for the period so the
chart line is continuous (empty buckets show as zeros). Buckets are
anchored on UTC boundaries we do this (rather than the PG session
timezone) so the API response is deterministic regardless of where
the database is deployed, and so the control-plane chart can match
buckets by ISO key on the client side.
"""
await self._authenticate_tenant(request_context)
if self._operation_validator:
from hindsight_api.extensions import BankReadContext
ctx = BankReadContext(bank_id=bank_id, operation="get_memories_timeseries", request_context=request_context)
await self._validate_operation(self._operation_validator.validate_bank_read(ctx))
cfg = _MEMORIES_TIMESERIES_PERIODS.get(period) or _MEMORIES_TIMESERIES_PERIODS["7d"]
if period not in _MEMORIES_TIMESERIES_PERIODS:
period = "7d"
pool = await self._get_pool()
async with acquire_with_retry(pool) as conn:
rows = await conn.fetch(
f"""
SELECT date_trunc('{cfg.trunc}', created_at AT TIME ZONE 'UTC') AS bucket,
fact_type, COUNT(*) AS count
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND created_at >= now() - interval '{cfg.interval}'
GROUP BY bucket, fact_type
ORDER BY bucket
""",
bank_id,
)
# Build the canonical bucket list anchored on the most recent UTC boundary.
now_utc = datetime.utcnow()
if cfg.trunc == "minute":
end = now_utc.replace(second=0, microsecond=0)
elif cfg.trunc == "hour":
end = now_utc.replace(minute=0, second=0, microsecond=0)
else:
end = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
buckets: list[MemoryTimeseriesBucketData] = []
by_iso: dict[str, MemoryTimeseriesBucketData] = {}
for i in range(cfg.count):
t = end - cfg.step * (cfg.count - 1 - i)
entry = MemoryTimeseriesBucketData(time=t.isoformat())
buckets.append(entry)
by_iso[entry.time] = entry
for row in rows:
# asyncpg hands us a tz-aware datetime when the column is timestamptz.
# Normalize to the naive-UTC format we used for the dict keys.
bucket_dt = row["bucket"]
if bucket_dt.tzinfo is not None:
bucket_dt = bucket_dt.astimezone(timezone.utc).replace(tzinfo=None)
entry = by_iso.get(bucket_dt.isoformat())
if entry is None:
# Row fell outside the requested window (clock skew / edge case).
continue
ft = row["fact_type"]
if ft == "world":
entry.world += row["count"]
elif ft == "experience":
entry.experience += row["count"]
elif ft == "observation":
entry.observation += row["count"]
return {
"bank_id": bank_id,
"period": period,
"trunc": cfg.trunc,
"buckets": [b.as_dict() for b in buckets],
}
async def get_entity(
self,
bank_id: str,
@@ -6770,12 +7034,15 @@ class MemoryEngine(MemoryEngineInterface):
fact_types = trigger_data.get("fact_types")
exclude_mental_models = trigger_data.get("exclude_mental_models", False)
stored_exclude_ids: list[str] = trigger_data.get("exclude_mental_model_ids") or []
recall_include_chunks_override = trigger_data.get("include_chunks")
recall_max_tokens_override = trigger_data.get("recall_max_tokens")
recall_chunks_max_tokens_override = trigger_data.get("recall_chunks_max_tokens")
tag_filtering = _resolve_refresh_tag_filtering(mental_model.get("tags"), trigger_data)
# Run reflect with the source query, excluding the mental model being refreshed
# Skip creating a nested "hindsight.reflect" span since we already have "hindsight.mental_model_refresh"
reflect_result = await self.reflect_async(
reflect_kwargs: dict[str, Any] = dict(
bank_id=bank_id,
query=mental_model["source_query"],
request_context=request_context,
@@ -6785,8 +7052,17 @@ class MemoryEngine(MemoryEngineInterface):
fact_types=fact_types,
exclude_mental_models=exclude_mental_models,
exclude_mental_model_ids=list({*stored_exclude_ids, mental_model_id}),
recall_include_chunks=recall_include_chunks_override,
recall_max_tokens_override=recall_max_tokens_override,
recall_chunks_max_tokens_override=recall_chunks_max_tokens_override,
_skip_span=True,
)
# Forward the per-model max_tokens so the final synthesis is capped at the
# user-configured limit rather than the reflect_async default.
stored_max_tokens = mental_model.get("max_tokens")
if stored_max_tokens is not None:
reflect_kwargs["max_tokens"] = stored_max_tokens
reflect_result = await self.reflect_async(**reflect_kwargs)
# Build reflect_response payload to store
# based_on contains MemoryFact objects for most types, but plain dicts for directives
@@ -7433,6 +7709,7 @@ class MemoryEngine(MemoryEngineInterface):
operation_id: str,
*,
request_context: "RequestContext",
include_payload: bool = False,
) -> dict[str, Any]:
"""Get the status of a specific async operation.
@@ -7455,9 +7732,10 @@ class MemoryEngine(MemoryEngineInterface):
op_uuid = uuid.UUID(operation_id)
async with acquire_with_retry(pool) as conn:
payload_column = ", task_payload" if include_payload else ""
row = await conn.fetchrow(
f"""
SELECT operation_id, operation_type, created_at, updated_at, completed_at, status, error_message, result_metadata
SELECT operation_id, operation_type, created_at, updated_at, completed_at, status, error_message, result_metadata{payload_column}
FROM {fq_table("async_operations")}
WHERE operation_id = $1 AND bank_id = $2
""",
@@ -7469,6 +7747,7 @@ class MemoryEngine(MemoryEngineInterface):
# Check if this is a parent operation
result_metadata = json.loads(row["result_metadata"]) if row["result_metadata"] else {}
is_parent = result_metadata.get("is_parent", False)
task_payload = json.loads(row["task_payload"]) if include_payload and row["task_payload"] else None
# Use status from database (parent status is updated when all children complete/fail)
db_status = row["status"]
@@ -7545,6 +7824,7 @@ class MemoryEngine(MemoryEngineInterface):
"error_message": row["error_message"],
"result_metadata": result_metadata,
"child_operations": child_statuses,
"task_payload": task_payload,
}
else:
# Regular operation (not a parent)
@@ -7557,6 +7837,7 @@ class MemoryEngine(MemoryEngineInterface):
"completed_at": row["completed_at"].isoformat() if row["completed_at"] else None,
"error_message": row["error_message"],
"result_metadata": result_metadata,
"task_payload": task_payload,
}
else:
# Operation not found
@@ -175,7 +175,7 @@ class GeminiLLM(LLMInterface):
Args:
messages: List of message dicts with 'role' and 'content'.
response_format: Optional Pydantic model for structured output.
max_completion_tokens: Maximum tokens in response (not supported by Gemini).
max_completion_tokens: Maximum tokens in response (mapped to Gemini's max_output_tokens).
temperature: Sampling temperature (0.0-2.0).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
@@ -227,6 +227,11 @@ class GeminiLLM(LLMInterface):
config_kwargs["response_schema"] = response_format
if temperature is not None:
config_kwargs["temperature"] = temperature
# Gemini's equivalent of OpenAI-style max_completion_tokens is max_output_tokens.
# Without it the model can produce arbitrarily long responses, ignoring the
# caller's intended cap (e.g. mental_models max_tokens during refresh).
if max_completion_tokens is not None:
config_kwargs["max_output_tokens"] = max_completion_tokens
# Apply safety settings: context var (per-request bank override) takes precedence over instance default
effective_safety_settings = _safety_settings_ctx.get()
@@ -401,7 +406,7 @@ class GeminiLLM(LLMInterface):
Args:
messages: List of message dicts. Can include tool results with role='tool'.
tools: List of tool definitions in OpenAI format.
max_completion_tokens: Maximum tokens (not supported by Gemini).
max_completion_tokens: Maximum tokens (mapped to Gemini's max_output_tokens).
temperature: Sampling temperature.
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
@@ -493,6 +498,10 @@ class GeminiLLM(LLMInterface):
config_kwargs["system_instruction"] = system_instruction
if temperature is not None:
config_kwargs["temperature"] = temperature
# See note in `call`: Gemini's max_output_tokens is the equivalent of
# OpenAI-style max_completion_tokens.
if max_completion_tokens is not None:
config_kwargs["max_output_tokens"] = max_completion_tokens
# Map OpenAI-style tool_choice to Gemini FunctionCallingConfig
if tool_choice == "required":
@@ -652,6 +652,46 @@ async def run_reflect_agent(
if result.content:
answer = _clean_answer_text(result.content.strip())
# The call_with_tools call above is intentionally uncapped so the
# LLM has headroom to emit tool-call JSON plus any intermediate
# reasoning. But when the LLM short-circuits and returns text
# directly, that text becomes the user-visible final answer and
# must respect max_tokens like the forced-final paths do. If it
# overshoots, run one extra capped call to rewrite it within
# the cap.
if max_tokens is not None and len(_TIKTOKEN_ENCODING.encode(answer)) > max_tokens:
rewrite_start = time.time()
rewritten, rewrite_usage = await llm_config.call(
messages=[
{
"role": "system",
"content": (
"Rewrite the user's text so it fits within the requested token "
"budget. Preserve the key facts and structure; drop lower-priority "
"detail. Respond with the rewritten text only, no preamble."
),
},
{
"role": "user",
"content": f"Target budget: {max_tokens} tokens.\n\nText to rewrite:\n{answer}",
},
],
scope="reflect",
max_completion_tokens=max_tokens,
return_usage=True,
)
total_input_tokens += rewrite_usage.input_tokens
total_output_tokens += rewrite_usage.output_tokens
llm_trace.append(
{
"scope": "final_rewrite",
"duration_ms": int((time.time() - rewrite_start) * 1000),
"input_tokens": rewrite_usage.input_tokens,
"output_tokens": rewrite_usage.output_tokens,
}
)
answer = _clean_answer_text(rewritten.strip())
# Generate structured output if schema provided
structured_output = None
if response_schema and answer:
@@ -214,6 +214,7 @@ async def tool_recall(
connection_budget: int = 1,
max_chunk_tokens: int = 1000,
fact_types: list[str] | None = None,
include_chunks: bool = True,
) -> dict[str, Any]:
"""
Search memories using TEMPR retrieval.
@@ -230,15 +231,15 @@ async def tool_recall(
tags: Filter by tags (includes untagged memories)
tags_match: How to match tags - "any" (OR), "all" (AND), or "exact"
connection_budget: Max DB connections for this recall (default 1 for internal ops)
max_chunk_tokens: Maximum tokens for raw source chunk text (default 1000, always included)
max_chunk_tokens: Maximum tokens for raw source chunk text (default 1000)
fact_types: Optional filter for fact types to retrieve. Defaults to ["experience", "world"].
include_chunks: Whether to fetch raw chunk text alongside facts (default True).
Returns:
Dict with list of matching memories including raw chunk text
Dict with list of matching memories including raw chunk text (when include_chunks)
"""
# Only world/experience are valid for raw recall (observation is handled by search_observations)
recall_fact_type = [ft for ft in (fact_types or ["experience", "world"]) if ft in ("world", "experience")]
include_chunks = True
internal_ctx = replace(request_context, internal=True)
result = await memory_engine.recall_async(
bank_id=bank_id,
@@ -47,6 +47,16 @@ async def generate_embeddings_batch(embeddings_backend, texts: list[str]) -> lis
embeddings_backend.encode,
texts,
)
return embeddings
except Exception as e:
raise Exception(f"Failed to generate batch embeddings: {str(e)}")
# Guarantee 1:1 alignment with input texts. A silent length mismatch here
# propagates downstream as zip() drops items, eventually surfacing as an
# IndexError in retain mapping (see issue #1037).
if len(embeddings) != len(texts):
raise RuntimeError(
f"Embeddings backend returned {len(embeddings)} vectors for {len(texts)} input texts; "
"expected exact 1:1 alignment"
)
return embeddings
@@ -72,7 +72,6 @@ from . import (
from .types import (
ChunkMetadata,
EntityResolutionResult,
ExtractedFact,
Phase1Result,
Phase3Context,
ProcessedFact,
@@ -302,8 +301,11 @@ async def _insert_facts_and_links(
causal_link_count = await link_creation.create_causal_links_batch(conn, bank_id, unit_ids, processed_facts)
log_buffer.append(f" Causal links: {causal_link_count} links in {time.time() - step_start:.3f}s")
# Map results back to original content items
result_unit_ids = _map_results_to_contents(contents, extracted_facts, unit_ids if unit_ids else [])
# Map results back to original content items. Use processed_facts (not
# extracted_facts) because unit_ids has 1:1 alignment with processed_facts —
# any upstream drop between extraction and processing would otherwise cause
# an IndexError (see issue #1037).
result_unit_ids = _map_results_to_contents(contents, processed_facts, unit_ids if unit_ids else [])
if outbox_callback:
await outbox_callback(conn)
@@ -487,8 +489,8 @@ async def retain_batch(
return result_unit_ids, total_usage
# Resolve effective document_id early so both delta and streaming paths
# can find existing chunks from a prior attempt. On retry, the generated
# document_id is recovered from operation result_metadata.
# can find existing chunks from a prior attempt. On retry, a generated
# document_id is recovered from operation result_metadata.document_ids[0].
effective_doc_id = document_id
if not effective_doc_id:
doc_ids = {item.get("document_id") for item in contents_dicts if item.get("document_id")}
@@ -507,26 +509,41 @@ async def retain_batch(
if isinstance(row["result_metadata"], dict)
else json.loads(row["result_metadata"])
)
effective_doc_id = meta.get("generated_document_id")
recovered = meta.get("document_ids") or []
if recovered:
effective_doc_id = recovered[0]
except Exception:
pass
if not effective_doc_id:
effective_doc_id = str(uuid.uuid4())
# Persist so retries reuse the same document_id
if operation_id:
try:
async with acquire_with_retry(pool) as conn:
await conn.execute(
f"""
UPDATE {fq_table("async_operations")}
SET result_metadata = result_metadata || $1::jsonb, updated_at = now()
WHERE operation_id = $2
""",
json.dumps({"generated_document_id": effective_doc_id}),
uuid.UUID(operation_id),
)
except Exception:
logger.warning("Failed to persist generated document_id", exc_info=True)
# Record effective_doc_id on the operation (idempotent set-append). Captures
# both user-provided and generated ids so the operation shows every document
# it touched, and lets retries reuse the same generated id.
if operation_id:
try:
async with acquire_with_retry(pool) as conn:
await conn.execute(
f"""
UPDATE {fq_table("async_operations")}
SET result_metadata = jsonb_set(
COALESCE(result_metadata, '{{}}'::jsonb),
'{{document_ids}}',
CASE
WHEN COALESCE(result_metadata->'document_ids', '[]'::jsonb) @> $1::jsonb
THEN result_metadata->'document_ids'
ELSE COALESCE(result_metadata->'document_ids', '[]'::jsonb) || $1::jsonb
END,
true
),
updated_at = now()
WHERE operation_id = $2
""",
json.dumps([effective_doc_id]),
uuid.UUID(operation_id),
)
except Exception:
logger.warning("Failed to persist document_id", exc_info=True)
# --- Append mode: prepend existing document content to new content ---
# When update_mode="append", fetch the existing document text and prepend it
@@ -1550,12 +1567,19 @@ def _build_delta_contents(
def _map_results_to_contents(
contents: list[RetainContent],
extracted_facts: list[ExtractedFact],
processed_facts: list[ProcessedFact],
unit_ids: list[str],
) -> list[list[str]]:
"""Map created unit IDs back to original content items."""
"""Map created unit IDs back to original content items.
`processed_facts` and `unit_ids` must have the same length: each unit_id
corresponds to the processed_fact at the same index.
"""
if len(processed_facts) != len(unit_ids):
raise ValueError(f"processed_facts ({len(processed_facts)}) and unit_ids ({len(unit_ids)}) length mismatch")
facts_by_content: dict[int, list[int]] = {i: [] for i in range(len(contents))}
for i, fact in enumerate(extracted_facts):
for i, fact in enumerate(processed_facts):
# Normalize content_index: some LLM providers return 1-indexed values.
# Clamp to valid range to prevent KeyError.
idx = fact.content_index
@@ -1564,12 +1588,8 @@ def _map_results_to_contents(
facts_by_content[idx].append(i)
result_unit_ids = []
unit_idx = 0
for content_index in range(len(contents)):
content_unit_ids = []
for _ in facts_by_content[content_index]:
content_unit_ids.append(unit_ids[unit_idx])
unit_idx += 1
content_unit_ids = [unit_ids[i] for i in facts_by_content[content_index]]
result_unit_ids.append(content_unit_ids)
return result_unit_ids
@@ -812,13 +812,42 @@ class WorkerPoller:
schemas = await self._get_schemas()
global_pending = 0
all_worker_counts: dict[str, int] = {}
# operation_type -> aggregated bucket counts across schemas
pending_breakdown: dict[str, dict[str, int]] = {}
async with self._pool.acquire() as conn:
for schema in schemas:
table = fq_table("async_operations", schema)
row = await conn.fetchrow(f"SELECT COUNT(*) as count FROM {table} WHERE status = 'pending'")
global_pending += row["count"] if row else 0
# Bucket pending rows by the same predicates the claim query
# filters on, so an operator can see why pending > 0 but
# nothing is being claimed (orphaned batch_retain parents,
# retry backoff, etc.).
breakdown_rows = await conn.fetch(
f"""
SELECT
operation_type,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE task_payload IS NULL) AS payload_null,
COUNT(*) FILTER (
WHERE next_retry_at IS NOT NULL AND next_retry_at > now()
) AS retry_blocked,
COUNT(*) FILTER (WHERE worker_id IS NOT NULL) AS assigned
FROM {table}
WHERE status = 'pending'
GROUP BY operation_type
"""
)
for br in breakdown_rows:
op_type = br["operation_type"] or "unknown"
bucket = pending_breakdown.setdefault(
op_type, {"total": 0, "payload_null": 0, "retry_blocked": 0, "assigned": 0}
)
bucket["total"] += br["total"]
bucket["payload_null"] += br["payload_null"]
bucket["retry_blocked"] += br["retry_blocked"]
bucket["assigned"] += br["assigned"]
global_pending += br["total"]
worker_rows = await conn.fetch(
f"""
@@ -856,6 +885,13 @@ class WorkerPoller:
f"my_active: {processing_str}"
)
# Pending breakdown - explains why pending rows aren't being claimed
# (orphaned batch_retain parents have payload_null > 0, retry storms
# show up as retry_blocked, etc.). Skip when nothing is pending so
# the line doesn't add noise on idle deployments.
if global_pending > 0:
self._log_pending_breakdown(pending_breakdown)
# Per-task lines, sorted oldest-first so stuck tasks bubble to the top.
self._log_per_task_lines(active_tasks, now=time.monotonic())
@@ -910,6 +946,35 @@ class WorkerPoller:
logger.debug(f"Pool stats unavailable: {e}")
return "unavailable"
def _log_pending_breakdown(self, breakdown: dict[str, dict[str, int]]) -> None:
"""Emit one [PENDING_BREAKDOWN] line bucketing pending rows by claimability.
Each bucket mirrors a predicate in the claim query:
* payload_null - row has no task_payload (e.g. batch_retain parent
whose reconciliation never fired); claim query
skips it forever
* retry_blocked - next_retry_at is still in the future
* assigned - worker_id already set; another worker owns it
``claimable`` is the residual that *should* be picked up on the next
poll. If ``claimable > 0`` while workers report free slots, the bug is
somewhere else (lock contention, tenant discovery, etc.) - this line
narrows the search.
"""
if not breakdown:
return
parts = []
for op_type in sorted(breakdown):
b = breakdown[op_type]
claimable = b["total"] - b["payload_null"] - b["retry_blocked"] - b["assigned"]
parts.append(
f"{op_type}: total={b['total']} claimable={claimable} "
f"payload_null={b['payload_null']} retry_blocked={b['retry_blocked']} "
f"assigned={b['assigned']}"
)
logger.info(f"[PENDING_BREAKDOWN] {' | '.join(parts)}")
def _log_per_task_lines(self, active_tasks: dict[str, ActiveTaskInfo], now: float) -> None:
"""Emit one [WORKER_TASK] line per in-flight task and dump stuck stacks.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api-slim"
version = "0.5.1"
version = "0.5.2"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
@@ -433,3 +433,130 @@ async def test_config_retain_batch_tokens_respected(memory, request_context):
# Even small batches use parent-child pattern now (simpler code path)
assert "child_operations" in status
assert status["result_metadata"]["num_sub_batches"] == 1
async def _child_metadata(memory, bank_id: str, parent_operation_id: str, request_context):
"""Fetch the first child operation's result_metadata for a parent batch_retain."""
parent = await memory.get_operation_status(
bank_id=bank_id,
operation_id=parent_operation_id,
request_context=request_context,
)
assert parent["status"] == "completed", parent
assert parent["child_operations"], "expected at least one child operation"
child_id = parent["child_operations"][0]["operation_id"]
child = await memory.get_operation_status(
bank_id=bank_id,
operation_id=child_id,
request_context=request_context,
)
return child["result_metadata"]
@pytest.mark.asyncio
async def test_retain_records_user_provided_document_ids(memory, request_context):
"""User-supplied document_ids land in child op result_metadata.document_ids."""
bank_id = "test_doc_ids_user_supplied"
d1 = str(uuid.uuid4())
d2 = str(uuid.uuid4())
contents = [
{"content": "User-supplied doc one content.", "document_id": d1},
{"content": "User-supplied doc two content.", "document_id": d2},
]
result = await memory.submit_async_retain(
bank_id=bank_id,
contents=contents,
request_context=request_context,
)
await asyncio.sleep(0.2)
meta = await _child_metadata(memory, bank_id, result["operation_id"], request_context)
assert "document_ids" in meta, meta
assert set(meta["document_ids"]) == {d1, d2}
@pytest.mark.asyncio
async def test_retain_records_generated_document_id(memory, request_context):
"""With no document_ids supplied, retain records the single generated id."""
bank_id = "test_doc_ids_generated"
contents = [
{"content": "Generated doc item one."},
{"content": "Generated doc item two."},
]
result = await memory.submit_async_retain(
bank_id=bank_id,
contents=contents,
request_context=request_context,
)
await asyncio.sleep(0.2)
meta = await _child_metadata(memory, bank_id, result["operation_id"], request_context)
assert "document_ids" in meta, meta
assert isinstance(meta["document_ids"], list)
assert len(meta["document_ids"]) == 1
# Must be a valid UUID string (generated by the orchestrator)
uuid.UUID(meta["document_ids"][0])
@pytest.mark.asyncio
async def test_retain_records_shared_document_id_once(memory, request_context):
"""Items sharing one document_id record it exactly once (idempotent set-append)."""
bank_id = "test_doc_ids_shared"
shared = str(uuid.uuid4())
# Duplicate per-item doc_ids are rejected up front, so shared-doc mode
# is exercised by a single item carrying the id.
contents = [{"content": "Shared doc, chunk A.", "document_id": shared}]
result = await memory.submit_async_retain(
bank_id=bank_id,
contents=contents,
request_context=request_context,
)
await asyncio.sleep(0.2)
meta = await _child_metadata(memory, bank_id, result["operation_id"], request_context)
assert meta.get("document_ids") == [shared]
@pytest.mark.asyncio
async def test_get_operation_status_include_payload(memory, request_context):
"""include_payload=True returns the original submission payload; default omits it."""
bank_id = "test_include_payload"
contents = [{"content": "Payload roundtrip test item."}]
result = await memory.submit_async_retain(
bank_id=bank_id,
contents=contents,
request_context=request_context,
)
await asyncio.sleep(0.2)
parent = await memory.get_operation_status(
bank_id=bank_id,
operation_id=result["operation_id"],
request_context=request_context,
)
child_id = parent["child_operations"][0]["operation_id"]
# Default: no payload
without = await memory.get_operation_status(
bank_id=bank_id,
operation_id=child_id,
request_context=request_context,
)
assert without.get("task_payload") is None
# With flag: payload populated
with_payload = await memory.get_operation_status(
bank_id=bank_id,
operation_id=child_id,
request_context=request_context,
include_payload=True,
)
payload = with_payload.get("task_payload")
assert payload is not None, with_payload
assert payload.get("bank_id") == bank_id
assert payload.get("contents")
assert payload["contents"][0]["content"] == "Payload roundtrip test item."
+170
View File
@@ -0,0 +1,170 @@
"""
Tests for the bank stats endpoint and the memories-timeseries endpoint.
Covers the new fields exposed by GET /v1/default/banks/{bank_id}/stats
(operations_by_status) and the new endpoint
GET /v1/default/banks/{bank_id}/stats/memories-timeseries.
"""
from datetime import datetime
import httpx
import pytest
import pytest_asyncio
from hindsight_api.api import create_app
@pytest_asyncio.fixture
async def api_client(memory):
app = create_app(memory, initialize_memory=False)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
yield client
@pytest.fixture
def test_bank_id():
return f"stats_test_{datetime.now().timestamp()}"
@pytest.mark.asyncio
async def test_bank_stats_exposes_operations_by_status(api_client, test_bank_id):
"""/stats should return operations_by_status with all finished operations."""
try:
# Kick off a retain so at least one completed operation exists.
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories",
json={"items": [{"content": "Alice is a software engineer.", "context": "team"}]},
)
assert response.status_code == 200
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats")
assert response.status_code == 200
stats = response.json()
assert "operations_by_status" in stats
assert isinstance(stats["operations_by_status"], dict)
# A synchronous retain finishes as "completed".
assert stats["operations_by_status"].get("completed", 0) >= 1
# pending/failed counters should still be present as scalar mirrors.
assert stats["pending_operations"] == stats["operations_by_status"].get("pending", 0)
assert stats["failed_operations"] == stats["operations_by_status"].get("failed", 0)
finally:
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
@pytest.mark.asyncio
@pytest.mark.parametrize(
"period,expected_count,expected_trunc",
[
("1h", 60, "minute"),
("12h", 12, "hour"),
("1d", 24, "hour"),
("7d", 7, "day"),
("30d", 30, "day"),
("90d", 90, "day"),
],
)
async def test_memories_timeseries_periods(
api_client, test_bank_id, period, expected_count, expected_trunc
):
"""Every period must return the full expected bucket count and trunc."""
try:
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories",
json={"items": [{"content": "Bob works on infrastructure.", "context": "team"}]},
)
assert response.status_code == 200
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/stats/memories-timeseries",
params={"period": period},
)
assert response.status_code == 200
body = response.json()
assert body["bank_id"] == test_bank_id
assert body["period"] == period
assert body["trunc"] == expected_trunc
assert len(body["buckets"]) == expected_count
for bucket in body["buckets"]:
assert "time" in bucket
assert bucket["world"] >= 0
assert bucket["experience"] >= 0
assert bucket["observation"] >= 0
finally:
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
@pytest.mark.asyncio
async def test_memories_timeseries_invalid_period_falls_back(api_client, test_bank_id):
"""An unknown period must fall back to the 7d default."""
try:
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/stats/memories-timeseries",
params={"period": "nonsense"},
)
assert response.status_code == 200
body = response.json()
assert body["period"] == "7d"
assert body["trunc"] == "day"
assert len(body["buckets"]) == 7
finally:
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
@pytest.mark.asyncio
async def test_memories_timeseries_empty_bank_returns_zero_filled_buckets(
api_client, test_bank_id
):
"""A bank with no memories must still return the full zero-filled bucket set."""
try:
# Ensure the bank exists.
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
assert response.status_code == 200
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/stats/memories-timeseries",
params={"period": "7d"},
)
assert response.status_code == 200
body = response.json()
assert len(body["buckets"]) == 7
for bucket in body["buckets"]:
assert bucket["world"] == 0
assert bucket["experience"] == 0
assert bucket["observation"] == 0
finally:
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
@pytest.mark.asyncio
async def test_memories_timeseries_reflects_retained_memories(api_client, test_bank_id):
"""Freshly-retained memories must show up in today's bucket counts."""
try:
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories",
json={
"items": [
{"content": "Alice is a software engineer.", "context": "team"},
{"content": "Bob works on infrastructure.", "context": "team"},
]
},
)
assert response.status_code == 200
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/stats/memories-timeseries",
params={"period": "7d"},
)
assert response.status_code == 200
body = response.json()
totals = sum(b["world"] + b["experience"] + b["observation"] for b in body["buckets"])
assert totals >= 2, "expected at least two memories across all buckets"
# Those memories should land in the most-recent bucket.
latest = body["buckets"][-1]
assert latest["world"] + latest["experience"] + latest["observation"] >= 2
finally:
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
@@ -0,0 +1,106 @@
"""Verify that BankTemplateConfig exposes every hierarchical field that
_CONFIGURABLE_FIELDS already accepts at the engine layer.
This test guards the fix for the gap described in the upstream PR title
"fix(bank-template): align BankTemplateConfig with _CONFIGURABLE_FIELDS".
Each new field is POSTed through /v1/default/banks/{id}/import and then
read back via the bank-config endpoint; assertion is that the applied
value round-trips through the engine.
Runs via: uv run pytest tests/test_bank_template_configurable_fields.py -v
The api_client fixture (shared with tests/test_bank_templates.py) wraps
create_app(memory, initialize_memory=False) in an httpx.ASGITransport
with base_url http://test — in-process, no network, no tenant extension.
Copy the fixture inline here so the test file does not depend on a
conftest we do not ship in the patch.
"""
from __future__ import annotations
from datetime import datetime
import httpx
import pytest
import pytest_asyncio
from hindsight_api.api import create_app
from hindsight_api.api.http import BankTemplateConfig
# Each tuple is (field_name, applied_value). Values chosen to differ
# visibly from defaults so round-trip bugs surface.
NEW_FIELDS: list[tuple[str, object]] = [
("retain_default_strategy", "strategy-a"),
("retain_strategies", {"strategy-a": {"mode": "concise", "max_tokens": 512}}),
("retain_chunk_batch_size", 7),
("mcp_enabled_tools", ["list_banks", "get_bank_profile"]),
("consolidation_llm_batch_size", 11),
("consolidation_source_facts_max_tokens", 2048),
("consolidation_source_facts_max_tokens_per_observation", 256),
("max_observations_per_scope", 13),
("reflect_source_facts_max_tokens", 4096),
("llm_gemini_safety_settings", [{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}]),
]
@pytest_asyncio.fixture
async def api_client(memory):
"""Matches the fixture in tests/test_bank_templates.py — in-process
ASGI test client, no tenant extension, no auth."""
app = create_app(memory, initialize_memory=False)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
yield client
@pytest.fixture
def bank_id():
return f"tmpl_config_{datetime.now().timestamp()}"
def test_bank_template_config_declares_every_configurable_field():
"""Pydantic-level guard: every field in NEW_FIELDS must be a declared
attribute of BankTemplateConfig so get_config_updates() picks it up."""
declared = set(BankTemplateConfig.model_fields.keys())
missing = [name for name, _ in NEW_FIELDS if name not in declared]
assert not missing, f"BankTemplateConfig missing fields: {missing}"
@pytest.mark.asyncio
@pytest.mark.parametrize("field_name,applied_value", NEW_FIELDS, ids=[n for n, _ in NEW_FIELDS])
async def test_new_field_round_trips_through_import(
api_client: httpx.AsyncClient,
bank_id: str,
field_name: str,
applied_value: object,
):
"""POST a minimal manifest with one new field set, then read bank
config back and assert the value made it through.
Bank config response shape per upstream's test_import_applies_config:
top-level keys are resolved hierarchical config; per-bank overrides
live under config["overrides"][<field>]. Assert on the override slot.
"""
unique_bank_id = f"{bank_id}_{field_name}"
manifest = {
"version": "1",
"bank": {field_name: applied_value},
}
resp = await api_client.post(
f"/v1/default/banks/{unique_bank_id}/import",
json=manifest,
)
assert resp.status_code == 200, resp.text
# Read bank config back — field must reflect the applied value
# under the "overrides" slot, matching upstream's own test shape.
read = await api_client.get(f"/v1/default/banks/{unique_bank_id}/config")
assert read.status_code == 200, read.text
config = read.json()
overrides = config.get("overrides", {})
assert overrides.get(field_name) == applied_value, (
f"round-trip mismatch for {field_name}: "
f"sent {applied_value!r}, got {overrides.get(field_name)!r} "
f"(full overrides: {overrides!r})"
)
@@ -0,0 +1,100 @@
"""Tests for consolidation retry budget configurability (issue #1042)."""
import pytest
from unittest.mock import AsyncMock, MagicMock
from hindsight_api.engine.consolidation.consolidator import _consolidate_batch_with_llm
@pytest.fixture
def mock_llm_config():
llm = AsyncMock()
response = MagicMock()
response.creates = []
response.updates = []
response.deletes = []
llm.call.return_value = response
return llm
@pytest.fixture
def mock_config():
config = MagicMock()
config.observations_mission = None
config.consolidation_max_attempts = 3
config.consolidation_llm_max_retries = None
return config
class TestConsolidationRetryBudget:
@pytest.mark.asyncio
async def test_config_is_required(self, mock_llm_config):
"""Passing config=None raises — it's a programmer error, not a runtime fallback."""
with pytest.raises(ValueError, match="config is required"):
await _consolidate_batch_with_llm(
llm_config=mock_llm_config,
memories=[{"id": "m1", "text": "test"}],
union_observations=[],
union_source_facts={},
config=None,
)
@pytest.mark.asyncio
async def test_configurable_max_attempts(self, mock_llm_config, mock_config):
"""consolidation_max_attempts controls the outer retry loop."""
mock_config.consolidation_max_attempts = 5
mock_llm_config.call.side_effect = RuntimeError("fail")
result = await _consolidate_batch_with_llm(
llm_config=mock_llm_config,
memories=[{"id": "m1", "text": "test"}],
union_observations=[],
union_source_facts={},
config=mock_config,
)
assert result.failed
assert mock_llm_config.call.call_count == 5
@pytest.mark.asyncio
async def test_max_retries_threaded_to_call(self, mock_llm_config, mock_config):
"""consolidation_llm_max_retries is passed to llm_config.call()."""
mock_config.consolidation_llm_max_retries = 3
await _consolidate_batch_with_llm(
llm_config=mock_llm_config,
memories=[{"id": "m1", "text": "test"}],
union_observations=[],
union_source_facts={},
config=mock_config,
)
assert mock_llm_config.call.call_args.kwargs.get("max_retries") == 3
@pytest.mark.asyncio
async def test_max_retries_not_passed_when_none(self, mock_llm_config, mock_config):
"""When consolidation_llm_max_retries is None, max_retries is not passed."""
mock_config.consolidation_llm_max_retries = None
await _consolidate_batch_with_llm(
llm_config=mock_llm_config,
memories=[{"id": "m1", "text": "test"}],
union_observations=[],
union_source_facts={},
config=mock_config,
)
assert "max_retries" not in mock_llm_config.call.call_args.kwargs
@pytest.mark.asyncio
async def test_reduced_budget_limits_total_calls(self, mock_llm_config, mock_config):
"""Setting both to low values caps total failure attempts."""
mock_config.consolidation_max_attempts = 2
mock_config.consolidation_llm_max_retries = 2
mock_llm_config.call.side_effect = RuntimeError("upstream 503")
result = await _consolidate_batch_with_llm(
llm_config=mock_llm_config,
memories=[{"id": "m1", "text": "test"}],
union_observations=[],
union_source_facts={},
config=mock_config,
)
assert result.failed
assert mock_llm_config.call.call_count == 2
for call_args in mock_llm_config.call.call_args_list:
assert call_args.kwargs.get("max_retries") == 2
@@ -98,7 +98,7 @@ async def test_hierarchical_fields_categorization():
assert "retain_chunk_batch_size" in configurable
# Verify count is correct
assert len(configurable) == 22
assert len(configurable) == 25
# Verify credential fields (NEVER exposed)
assert "llm_api_key" in credentials
@@ -458,7 +458,7 @@ async def test_config_get_bank_config_no_static_or_credential_fields_leak(memory
assert field in config, f"Expected configurable field '{field}' missing from config"
# Should have a small number of configurable fields (not hundreds)
assert len(config) < 25, f"Too many fields returned: {len(config)}"
assert len(config) < 30, f"Too many fields returned: {len(config)}"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@@ -286,6 +286,34 @@ async def test_full_api_workflow(api_client, test_bank_id):
)
assert response.status_code == 410 # Deprecated endpoint
# Entity co-occurrence graph — shape is stable even when there are no
# co-occurrences; every edge must reference two nodes that are also present.
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/entities/graph")
assert response.status_code == 200
entity_graph = response.json()
assert set(entity_graph.keys()) >= {"nodes", "edges", "total_entities", "total_edges", "limit"}
assert entity_graph["limit"] == 1000
assert len(entity_graph["nodes"]) == entity_graph["total_entities"]
assert len(entity_graph["edges"]) == entity_graph["total_edges"]
node_ids = {n["data"]["id"] for n in entity_graph["nodes"]}
for edge in entity_graph["edges"]:
assert edge["data"]["source"] in node_ids
assert edge["data"]["target"] in node_ids
assert edge["data"]["linkType"] == "cooccurrence"
assert edge["data"]["weight"] >= 1
# min_count filter — raising the threshold can only shrink the edge set.
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/entities/graph?min_count=1000000"
)
assert response.status_code == 200
filtered_graph = response.json()
assert filtered_graph["total_edges"] == 0
# "graph" must route to the graph endpoint, not be parsed as an entity_id.
# Regression guard in case someone reorders the FastAPI route registration.
assert entity_graph["total_entities"] >= 0
# ================================================================
# 9. List All Banks (should include our test bank)
# ================================================================
@@ -25,6 +25,8 @@ from hindsight_api.engine.llm_wrapper import TokenUsage
logger = logging.getLogger(__name__)
pytestmark = pytest.mark.xdist_group("load_batch_tests")
def generate_content(char_count: int) -> str:
"""Generate realistic content of approximately char_count characters."""
@@ -117,9 +119,18 @@ class TestLargeBatchRetain:
except Exception:
pass
@pytest.fixture
def disable_observations(self):
from hindsight_api.config import _get_raw_config
config = _get_raw_config()
original = config.enable_observations
config.enable_observations = False
yield
config.enable_observations = original
@pytest.mark.asyncio
@pytest.mark.timeout(300) # 5 minute timeout
async def test_large_batch_500k_chars_20_items(self, memory_with_mock_llm, request_context):
async def test_large_batch_500k_chars_20_items(self, memory_with_mock_llm, request_context, disable_observations):
"""
Test retaining a batch of 20 content items totaling ~500k chars.
@@ -283,7 +294,7 @@ class TestLargeBatchRetain:
@pytest.mark.asyncio
@pytest.mark.timeout(60)
async def test_db_connection_pool_under_load(self, memory_with_mock_llm, request_context):
async def test_db_connection_pool_under_load(self, memory_with_mock_llm, request_context, disable_observations):
"""
Test that DB connection pool handles concurrent operations.
@@ -1253,6 +1253,145 @@ class TestMentalModelTriggerTagsConfig:
await memory.delete_bank(bank_id, request_context=request_context)
class TestMentalModelRefreshMaxTokens:
"""Verify that refresh_mental_model honors the per-model max_tokens column.
These tests mock the engine's collaborators so we can assert the exact kwargs
passed to reflect_async without spinning up a DB or LLM. The bug being guarded
against: the per-model ``max_tokens`` column was ignored during refresh, so
reflect_async fell back to its default (4096) and the generated content could
exceed the user-configured limit when there were many facts to synthesize.
"""
async def test_refresh_passes_stored_max_tokens_to_reflect(self, request_context):
from unittest.mock import AsyncMock
from hindsight_api.engine.memory_engine import MemoryEngine
from hindsight_api.engine.response_models import ReflectResult
custom_max_tokens = 777
mental_model = {
"id": "mm-1",
"bank_id": "bank-1",
"name": "Capped Model",
"source_query": "Summarize the facts",
"content": "initial",
"tags": None,
"max_tokens": custom_max_tokens,
"trigger": {"refresh_after_consolidation": False},
}
engine = MemoryEngine.__new__(MemoryEngine)
engine._authenticate_tenant = AsyncMock(return_value=None) # type: ignore[method-assign]
engine.get_mental_model = AsyncMock(return_value=mental_model) # type: ignore[method-assign]
engine.reflect_async = AsyncMock( # type: ignore[method-assign]
return_value=ReflectResult(text="stub synthesis", based_on={})
)
engine.update_mental_model = AsyncMock(return_value=mental_model) # type: ignore[method-assign]
await engine.refresh_mental_model(
bank_id="bank-1",
mental_model_id="mm-1",
request_context=request_context,
)
assert engine.reflect_async.await_count == 1
kwargs = engine.reflect_async.await_args.kwargs
assert kwargs.get("max_tokens") == custom_max_tokens, (
f"refresh_mental_model should forward the stored max_tokens ({custom_max_tokens}) "
f"to reflect_async, but got max_tokens={kwargs.get('max_tokens')!r}"
)
async def test_refresh_content_respects_max_tokens(self, memory: MemoryEngine, request_context):
"""End-to-end: refreshed content must stay within the model's max_tokens cap.
We seed the bank with enough varied facts that an unconstrained synthesis
would happily produce a long answer, then refresh a mental model with a
small max_tokens and assert the resulting content is actually within the
cap (with a small tolerance for cross-tokenizer drift, since the LLM may
not use cl100k_base).
"""
from hindsight_api.engine.memory_engine import count_tokens
bank_id = f"test-refresh-cap-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id, request_context=request_context)
# Seed enough content that an uncapped reflect would produce a long answer.
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": (
"Alice is the staff frontend engineer. She owns the design system, "
"leads accessibility reviews, mentors three junior engineers, and runs "
"the weekly UI guild meeting every Thursday at 2pm Pacific."
)},
{"content": (
"Bob is the backend tech lead. He owns the payments service, the "
"billing reconciliation pipeline, and the on-call rotation for the "
"platform team. He is the primary reviewer for any database migration."
)},
{"content": (
"Carol manages the data platform. Her team operates the warehouse, "
"the streaming ingestion layer, and the metrics pipeline that feeds "
"the executive dashboards refreshed every fifteen minutes."
)},
{"content": (
"The team holds a company-wide demo every other Friday. Engineering "
"presents shipped work, design walks through prototypes, and product "
"shares roadmap updates for the upcoming quarter."
)},
{"content": (
"Dan is the security lead. He runs the quarterly threat-modeling "
"exercises, owns the incident response runbook, and coordinates the "
"annual external penetration test with the vendor."
)},
{"content": (
"Erin runs developer experience. She maintains the local-dev tooling, "
"the CI pipelines, the release automation, and the internal "
"documentation portal that everyone uses to onboard new hires."
)},
],
request_context=request_context,
)
await memory.wait_for_background_tasks()
cap = 200
mm = await memory.create_mental_model(
bank_id=bank_id,
name="Team Summary (capped)",
source_query="Give me a complete overview of every team member, what they own, and the recurring meetings.",
content="initial",
max_tokens=cap,
request_context=request_context,
)
refreshed = await memory.refresh_mental_model(
bank_id=bank_id,
mental_model_id=mm["id"],
request_context=request_context,
)
assert refreshed is not None
content = refreshed["content"]
assert content, "refresh produced empty content"
# The provider enforces the cap exactly in its own tokenizer, but our
# local count uses tiktoken (cl100k_base) which can disagree with
# provider tokenizers (Gemini's SentencePiece in particular tends to run
# ~30% higher for English prose). We use a generous tolerance — the test
# is guarding against the regression where the cap was ignored entirely
# and content grew toward reflect_async's default of 4096 tokens.
observed_tokens = count_tokens(content)
tolerance = 1.5
assert observed_tokens <= cap * tolerance, (
f"refreshed content exceeds max_tokens cap: "
f"observed≈{observed_tokens} tokens, cap={cap} (tolerance x{tolerance}). "
f"content={content!r}"
)
await memory.delete_bank(bank_id, request_context=request_context)
class TestMentalModelTriggerSchema:
"""Unit tests for MentalModelTrigger schema validation (no DB needed)."""
@@ -0,0 +1,231 @@
"""
Tests for the internal recall configuration knobs used during mental model
refresh: recall_include_chunks, recall_max_tokens, recall_chunks_max_tokens.
These are exposed both as hierarchical config fields (env → tenant → bank)
and as overrides on a mental model's `trigger` JSONB field.
"""
import dataclasses
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from hindsight_api.engine.reflect.tools import tool_recall
from hindsight_api.engine.response_models import RecallResult as RecallResultModel
from hindsight_api.models import RequestContext
def _make_mock_engine():
engine = MagicMock()
engine.recall_async = AsyncMock(return_value=RecallResultModel(results=[], entities={}, chunks={}))
return engine
@pytest.fixture
def mock_request_context():
# internal=True bypasses the tenant extension, letting these unit tests
# exercise engine methods without standing up auth.
return RequestContext(internal=True)
class TestToolRecallIncludeChunks:
"""tool_recall must honor the include_chunks parameter (was hardcoded True)."""
@pytest.mark.asyncio
async def test_default_includes_chunks(self, mock_request_context):
engine = _make_mock_engine()
await tool_recall(engine, "bank-1", "q", mock_request_context)
kwargs = engine.recall_async.call_args.kwargs
assert kwargs["include_chunks"] is True
@pytest.mark.asyncio
async def test_include_chunks_false_propagates(self, mock_request_context):
engine = _make_mock_engine()
await tool_recall(engine, "bank-1", "q", mock_request_context, include_chunks=False)
kwargs = engine.recall_async.call_args.kwargs
assert kwargs["include_chunks"] is False
@pytest.mark.asyncio
async def test_max_chunk_tokens_propagates(self, mock_request_context):
engine = _make_mock_engine()
await tool_recall(
engine, "bank-1", "q", mock_request_context, max_chunk_tokens=2500, max_tokens=512
)
kwargs = engine.recall_async.call_args.kwargs
assert kwargs["max_chunk_tokens"] == 2500
assert kwargs["max_tokens"] == 512
class TestRecallConfigFields:
"""Hierarchical config fields for internal recall."""
def test_fields_exist_on_dataclass(self):
from hindsight_api.config import HindsightConfig
names = {f.name for f in dataclasses.fields(HindsightConfig)}
assert "recall_include_chunks" in names
assert "recall_max_tokens" in names
assert "recall_chunks_max_tokens" in names
def test_fields_are_configurable(self):
from hindsight_api.config import HindsightConfig
configurable = HindsightConfig.get_configurable_fields()
assert "recall_include_chunks" in configurable
assert "recall_max_tokens" in configurable
assert "recall_chunks_max_tokens" in configurable
def test_default_values(self):
from hindsight_api.config import (
DEFAULT_RECALL_CHUNKS_MAX_TOKENS,
DEFAULT_RECALL_INCLUDE_CHUNKS,
DEFAULT_RECALL_MAX_TOKENS,
)
assert DEFAULT_RECALL_INCLUDE_CHUNKS is True
assert DEFAULT_RECALL_MAX_TOKENS == 2048
assert DEFAULT_RECALL_CHUNKS_MAX_TOKENS == 1000
def test_env_var_constants(self):
from hindsight_api.config import (
ENV_RECALL_CHUNKS_MAX_TOKENS,
ENV_RECALL_INCLUDE_CHUNKS,
ENV_RECALL_MAX_TOKENS,
)
assert ENV_RECALL_INCLUDE_CHUNKS == "HINDSIGHT_API_RECALL_INCLUDE_CHUNKS"
assert ENV_RECALL_MAX_TOKENS == "HINDSIGHT_API_RECALL_MAX_TOKENS"
assert ENV_RECALL_CHUNKS_MAX_TOKENS == "HINDSIGHT_API_RECALL_CHUNKS_MAX_TOKENS"
@patch.dict(
"os.environ",
{
"HINDSIGHT_API_RECALL_INCLUDE_CHUNKS": "false",
"HINDSIGHT_API_RECALL_MAX_TOKENS": "777",
"HINDSIGHT_API_RECALL_CHUNKS_MAX_TOKENS": "333",
},
)
def test_from_env_reads_overrides(self):
from hindsight_api.config import HindsightConfig
config = HindsightConfig.from_env()
assert config.recall_include_chunks is False
assert config.recall_max_tokens == 777
assert config.recall_chunks_max_tokens == 333
class TestMentalModelTriggerRecallFields:
"""MentalModelTrigger Pydantic model accepts the new override fields."""
def test_trigger_accepts_new_fields(self):
from hindsight_api.api.http import MentalModelTrigger
trigger = MentalModelTrigger(
include_chunks=False,
recall_max_tokens=512,
recall_chunks_max_tokens=0,
)
assert trigger.include_chunks is False
assert trigger.recall_max_tokens == 512
assert trigger.recall_chunks_max_tokens == 0
def test_trigger_defaults_are_none(self):
from hindsight_api.api.http import MentalModelTrigger
trigger = MentalModelTrigger()
assert trigger.include_chunks is None
assert trigger.recall_max_tokens is None
assert trigger.recall_chunks_max_tokens is None
class TestRefreshTriggerWiring:
"""Verify mental-model refresh forwards trigger overrides into reflect_async kwargs."""
@pytest.mark.asyncio
async def test_trigger_overrides_passed_to_reflect_async(self, mock_request_context):
from hindsight_api.engine.memory_engine import MemoryEngine
from hindsight_api.engine.response_models import ReflectResult
engine = MemoryEngine.__new__(MemoryEngine)
async def fake_get_mental_model(bank_id, mental_model_id, request_context):
return {
"id": mental_model_id,
"source_query": "What do we know?",
"tags": [],
"trigger": {
"include_chunks": False,
"recall_max_tokens": 512,
"recall_chunks_max_tokens": 0,
"fact_types": ["world"],
},
}
captured = {}
async def fake_reflect_async(**kwargs):
captured.update(kwargs)
return ReflectResult(text="ok", based_on={})
async def fake_update_mental_model(*args, **kwargs):
return None
engine.get_mental_model = fake_get_mental_model
engine.reflect_async = fake_reflect_async
engine.update_mental_model = fake_update_mental_model
engine._operation_validator = None
engine._tenant_extension = None
await engine.refresh_mental_model(
bank_id="bank-1",
mental_model_id="mm-1",
request_context=mock_request_context,
)
assert captured["recall_include_chunks"] is False
assert captured["recall_max_tokens_override"] == 512
assert captured["recall_chunks_max_tokens_override"] == 0
assert captured["fact_types"] == ["world"]
@pytest.mark.asyncio
async def test_missing_trigger_fields_pass_none(self, mock_request_context):
from hindsight_api.engine.memory_engine import MemoryEngine
from hindsight_api.engine.response_models import ReflectResult
engine = MemoryEngine.__new__(MemoryEngine)
async def fake_get_mental_model(bank_id, mental_model_id, request_context):
return {"id": mental_model_id, "source_query": "q", "tags": [], "trigger": {}}
captured = {}
async def fake_reflect_async(**kwargs):
captured.update(kwargs)
return ReflectResult(text="ok", based_on={})
async def fake_update_mental_model(*args, **kwargs):
return None
engine.get_mental_model = fake_get_mental_model
engine.reflect_async = fake_reflect_async
engine.update_mental_model = fake_update_mental_model
engine._operation_validator = None
engine._tenant_extension = None
await engine.refresh_mental_model(
bank_id="bank-1",
mental_model_id="mm-1",
request_context=mock_request_context,
)
# When trigger fields are absent, None is forwarded so reflect_async falls back to bank/global config.
assert captured["recall_include_chunks"] is None
assert captured["recall_max_tokens_override"] is None
assert captured["recall_chunks_max_tokens_override"] is None
@@ -396,6 +396,88 @@ class TestReflectAgentMocked:
# Verify recall was actually called (normalization worked)
mock_functions["recall_fn"].assert_called_once()
@pytest.mark.asyncio
async def test_short_circuit_answer_is_capped_by_max_tokens(self, mock_llm, mock_functions):
"""When the LLM short-circuits (returns text without calling a tool) and the text
exceeds max_tokens, the agent must rewrite it through a capped call so the final
user-visible answer respects the configured limit.
"""
# Build a long response that's well over the cap in cl100k_base tokens.
long_answer = " ".join(
[
"This is a detailed paragraph about the team, their roles, and their recurring meetings."
]
* 80
)
# The short-circuit path: tool_calls empty, content populated.
mock_llm.call_with_tools.return_value = LLMToolCallResult(
tool_calls=[],
content=long_answer,
finish_reason="stop",
input_tokens=10,
output_tokens=500,
)
mock_llm.call = AsyncMock(
return_value=(
"Short rewritten answer.",
TokenUsage(input_tokens=50, output_tokens=10, total_tokens=60),
)
)
cap = 50
result = await run_reflect_agent(
llm_config=mock_llm,
bank_id="test-bank",
query="test query",
bank_profile={"name": "Test", "mission": "Testing"},
max_tokens=cap,
**mock_functions,
)
# The rewrite call must have been made, and it must carry the cap.
assert mock_llm.call.await_count == 1, (
f"expected exactly one capped rewrite call, got {mock_llm.call.await_count}"
)
rewrite_kwargs = mock_llm.call.await_args.kwargs
assert rewrite_kwargs.get("max_completion_tokens") == cap, (
f"rewrite call should use max_completion_tokens={cap}, "
f"got {rewrite_kwargs.get('max_completion_tokens')}"
)
# The final answer is the rewritten text, not the oversized original.
assert result.text == "Short rewritten answer."
# The trace records the rewrite step so we can see it was invoked.
assert any(entry.scope == "final_rewrite" for entry in result.llm_trace), (
f"llm_trace should include a final_rewrite entry, got {result.llm_trace}"
)
@pytest.mark.asyncio
async def test_short_circuit_answer_under_cap_is_not_rewritten(self, mock_llm, mock_functions):
"""If the short-circuit answer already fits within max_tokens, no extra rewrite
call should happen — we don't want to pay for a second LLM call in the common case.
"""
short_answer = "Small answer that already fits."
mock_llm.call_with_tools.return_value = LLMToolCallResult(
tool_calls=[],
content=short_answer,
finish_reason="stop",
input_tokens=10,
output_tokens=8,
)
result = await run_reflect_agent(
llm_config=mock_llm,
bank_id="test-bank",
query="test query",
bank_profile={"name": "Test", "mission": "Testing"},
max_tokens=200,
**mock_functions,
)
assert result.text == short_answer
mock_llm.call.assert_not_called()
@pytest.mark.asyncio
async def test_max_iterations_reached(self, mock_llm, mock_functions):
"""Test that agent stops after max iterations even with errors."""
+2
View File
@@ -378,6 +378,7 @@ async def test_mentioned_at_vs_occurred(memory, request_context):
content="Alice graduated from MIT in March 2020.",
context="education history",
event_date=conversation_date, # When this conversation happened
fact_type_override="world",
request_context=request_context,
)
@@ -1149,6 +1150,7 @@ async def test_chunk_fact_mapping(memory, request_context):
content=content,
context="technical documentation",
document_id=document_id,
fact_type_override="world",
request_context=request_context,
)
@@ -0,0 +1,117 @@
"""Unit tests for retain orchestrator mapping and embeddings length guarantee.
Regression coverage for issue #1037: a silent length mismatch between the
extracted facts and the generated embeddings caused
`_map_results_to_contents` to raise IndexError during batch_retain.
"""
from __future__ import annotations
import asyncio
from datetime import datetime
from unittest.mock import MagicMock
import pytest
from hindsight_api.engine.retain import embedding_utils
from hindsight_api.engine.retain.orchestrator import _map_results_to_contents
from hindsight_api.engine.retain.types import ProcessedFact, RetainContent
def _make_processed_fact(content_index: int, text: str = "fact") -> ProcessedFact:
return ProcessedFact(
fact_text=text,
fact_type="world",
embedding=[0.0, 0.0, 0.0],
occurred_start=None,
occurred_end=None,
mentioned_at=datetime(2026, 1, 1),
context="",
metadata={},
content_index=content_index,
)
def _make_content(text: str = "x") -> RetainContent:
return RetainContent(content=text)
class TestMapResultsToContents:
def test_groups_unit_ids_by_content_index(self):
contents = [_make_content("a"), _make_content("b"), _make_content("c")]
processed = [
_make_processed_fact(0, "a1"),
_make_processed_fact(0, "a2"),
_make_processed_fact(2, "c1"),
]
unit_ids = ["u-a1", "u-a2", "u-c1"]
result = _map_results_to_contents(contents, processed, unit_ids)
assert result == [["u-a1", "u-a2"], [], ["u-c1"]]
def test_handles_out_of_range_content_index(self):
contents = [_make_content("a"), _make_content("b")]
processed = [
_make_processed_fact(-1, "f1"),
_make_processed_fact(99, "f2"),
]
unit_ids = ["u1", "u2"]
result = _map_results_to_contents(contents, processed, unit_ids)
assert result == [["u1"], ["u2"]]
def test_empty_inputs(self):
assert _map_results_to_contents([], [], []) == []
def test_length_mismatch_raises(self):
# Regression for #1037: previously the function silently overran unit_ids.
contents = [_make_content("a")]
processed = [_make_processed_fact(0), _make_processed_fact(0)]
unit_ids = ["u1"] # one fewer than processed_facts
with pytest.raises(ValueError, match="length mismatch"):
_map_results_to_contents(contents, processed, unit_ids)
def test_unit_ids_assigned_by_processed_fact_position(self):
# Even if processed_facts are interleaved across contents, each unit_id
# must follow its corresponding processed_fact (positional alignment).
contents = [_make_content("a"), _make_content("b")]
processed = [
_make_processed_fact(1, "b1"),
_make_processed_fact(0, "a1"),
_make_processed_fact(1, "b2"),
]
unit_ids = ["u-b1", "u-a1", "u-b2"]
result = _map_results_to_contents(contents, processed, unit_ids)
assert result == [["u-a1"], ["u-b1", "u-b2"]]
class TestEmbeddingsBatchLengthGuarantee:
def test_raises_when_backend_returns_fewer_embeddings(self):
# Regression for #1037: backends that silently truncate must not pass
# through — `zip(extracted_facts, embeddings)` would otherwise drop
# facts and break unit_id alignment downstream.
backend = MagicMock()
backend.encode.return_value = [[0.1, 0.2]] # only 1 vector for 3 inputs
with pytest.raises(RuntimeError, match="returned 1 vectors for 3 input texts"):
asyncio.run(embedding_utils.generate_embeddings_batch(backend, ["a", "b", "c"]))
def test_raises_when_backend_returns_more_embeddings(self):
backend = MagicMock()
backend.encode.return_value = [[0.1], [0.2], [0.3]]
with pytest.raises(RuntimeError, match="returned 3 vectors for 2 input texts"):
asyncio.run(embedding_utils.generate_embeddings_batch(backend, ["a", "b"]))
def test_passes_through_aligned_embeddings(self):
backend = MagicMock()
backend.encode.return_value = [[0.1], [0.2]]
result = asyncio.run(embedding_utils.generate_embeddings_batch(backend, ["a", "b"]))
assert result == [[0.1], [0.2]]
+91 -2
View File
@@ -1505,8 +1505,7 @@ async def test_consolidation_slots_reserved_when_retain_saturates(pool, clean_op
consolidation_started = [op for op, t in started.items() if t == "consolidation"]
assert len(retain_started) == 3, (
f"Retain should be capped at max_slots - consolidation_max_slots = 3, "
f"got {len(retain_started)}"
f"Retain should be capped at max_slots - consolidation_max_slots = 3, got {len(retain_started)}"
)
assert len(consolidation_started) == 1, (
f"Consolidation should claim its reserved slot even while retain saturates, "
@@ -1529,6 +1528,96 @@ async def test_consolidation_slots_reserved_when_retain_saturates(pool, clean_op
pass
@pytest.mark.asyncio
async def test_pending_breakdown_explains_unclaimable_rows(pool, clean_operations, caplog):
"""Pending rows that the claim query filters out must be visible in logs.
Background: production incident where a 'pending' retain sat in the queue for
hours while workers had free slots. With only the global pending count in
[WORKER_STATS] there's no way to tell whether the rows are claimable-but-not-
being-claimed (real bug) vs filtered out by the claim WHERE clause (data
state). This test verifies [PENDING_BREAKDOWN] surfaces each filter bucket
so operators can diagnose without DB access.
"""
import logging
from hindsight_api.worker.poller import WorkerPoller
poller = WorkerPoller(
pool=pool,
worker_id="test-worker-pending-breakdown",
executor=lambda _t: asyncio.sleep(0),
poll_interval_ms=50,
max_slots=5,
)
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
await _ensure_bank(pool, bank_id)
# Mix of pending rows that the claim query treats differently:
# * payload_null - batch_retain parent (orphan candidate)
# * retry_blocked - failed once, scheduled an hour out
# * assigned - worker_id stamped (e.g. left over from a prior crash
# that re-queued without clearing worker_id)
# * claimable - normal retain ready to go
# * consolidation - normal consolidation, also claimable
rows = [
("batch_retain", None, None, None), # payload_null
("retain", json.dumps({"type": "test"}), "future", None), # retry_blocked
("retain", json.dumps({"type": "test"}), None, "ghost-worker"), # assigned
("retain", json.dumps({"type": "test"}), None, None), # claimable
("consolidation", json.dumps({"type": "test"}), None, None), # claimable
]
for op_type, payload, retry_marker, worker_id in rows:
op_id = uuid.uuid4()
await pool.execute(
"""
INSERT INTO async_operations
(operation_id, bank_id, operation_type, status, task_payload,
next_retry_at, worker_id)
VALUES ($1, $2, $3, 'pending', $4::jsonb,
CASE WHEN $5::text = 'future' THEN now() + interval '1 hour' ELSE NULL END,
$6)
""",
op_id,
bank_id,
op_type,
payload,
retry_marker,
worker_id,
)
# Trigger one stats emit. _last_progress_log starts at 0, so the first call
# always logs.
with caplog.at_level(logging.INFO, logger="hindsight_api.worker.poller"):
await poller._log_progress_if_due()
breakdown_lines = [r.message for r in caplog.records if r.message.startswith("[PENDING_BREAKDOWN]")]
assert len(breakdown_lines) == 1, f"Expected exactly one breakdown line, got: {breakdown_lines}"
# The breakdown is global (not bank-scoped), so other rows in the table may
# contribute. Parse the per-op_type buckets from the line and assert that
# our additions appear (>= 1 for each bucket we populated).
line = breakdown_lines[0]
buckets: dict[str, dict[str, int]] = {}
for section in line.removeprefix("[PENDING_BREAKDOWN]").split("|"):
section = section.strip()
if ":" not in section:
continue
op_type, fields = section.split(":", 1)
kv = {}
for token in fields.strip().split():
k, _, v = token.partition("=")
kv[k] = int(v)
buckets[op_type.strip()] = kv
assert buckets["batch_retain"]["payload_null"] >= 1
assert buckets["retain"]["retry_blocked"] >= 1
assert buckets["retain"]["assigned"] >= 1
assert buckets["retain"]["claimable"] >= 1
assert buckets["consolidation"]["claimable"] >= 1
class TestMarkFailedParentPropagation:
"""Tests for _mark_failed parent propagation in WorkerPoller.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-api"
version = "0.5.1"
version = "0.5.2"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
+7 -1
View File
@@ -21,7 +21,13 @@
# Operation-level skips
# ---------------------------------------------------------------------------
[skip]
# (empty — every operation is currently wired)
# UI-only endpoint powering the control-plane stats chart.
# Zero-filled bucket arrays don't map to a useful CLI command.
get_memories_timeseries = "UI-only endpoint for the control plane stats chart"
# UI-only endpoint powering the control-plane entity constellation view.
# Returns nodes/edges in cytoscape shape; not a useful CLI command.
get_entity_graph = "UI-only endpoint for the control plane entity constellation"
# ---------------------------------------------------------------------------
# Per-operation parameter skips
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hindsight-cli"
version = "0.5.1"
version = "0.5.2"
edition = "2021"
authors = ["Hindsight Team"]
description = "A beautiful CLI for Hindsight - semantic memory system"
+1 -1
View File
@@ -735,7 +735,7 @@ impl ApiClient {
self.runtime.block_on(async {
let response = self
.client
.get_operation_status(bank_id, operation_id, None)
.get_operation_status(bank_id, operation_id, None, None)
.await?;
Ok(response.into_inner())
})
@@ -124,6 +124,9 @@ pub fn create(
fact_types: None,
tag_groups: None,
tags_match: None,
include_chunks: None,
recall_max_tokens: None,
recall_chunks_max_tokens: None,
})
} else {
None
@@ -198,6 +201,9 @@ pub fn update(
fact_types: None,
tag_groups: None,
tags_match: None,
include_chunks: None,
recall_max_tokens: None,
recall_chunks_max_tokens: None,
});
let request = types::UpdateMentalModelRequest {
+313 -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.5.1
version: 0.5.2
servers:
- url: /
paths:
@@ -464,6 +464,53 @@ paths:
summary: Get statistics for memory bank
tags:
- Banks
/v1/default/banks/{bank_id}/stats/memories-timeseries:
get:
description: "Memories ingested over a period, bucketed by time and broken down\
\ by fact type."
operationId: get_memories_timeseries
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: true
in: query
name: period
required: false
schema:
default: 7d
title: Period
type: string
style: form
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/MemoriesTimeseriesResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Memory ingestion time-series
tags:
- Banks
/v1/default/banks/{bank_id}/entities:
get:
description: "List all entities (people, organizations, etc.) known by the bank,\
@@ -524,6 +571,66 @@ paths:
summary: List entities
tags:
- Entities
/v1/default/banks/{bank_id}/entities/graph:
get:
description: Return a graph of entities (nodes) and their co-occurrences (edges)
for visualization.
operationId: get_entity_graph
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- description: Maximum number of co-occurrence edges to return
explode: true
in: query
name: limit
required: false
schema:
default: 1000
description: Maximum number of co-occurrence edges to return
title: Limit
type: integer
style: form
- description: Minimum cooccurrence_count to include an edge
explode: true
in: query
name: min_count
required: false
schema:
default: 1
description: Minimum cooccurrence_count to include an edge
title: Min Count
type: integer
style: form
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/EntityGraphResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Get entity co-occurrence graph
tags:
- Entities
/v1/default/banks/{bank_id}/entities/{entity_id}:
get:
description: Get detailed information about an entity including observations
@@ -1781,6 +1888,19 @@ paths:
title: Operation Id
type: string
style: simple
- description: Include the raw task payload (submission params) in the response.
May be large.
explode: true
in: query
name: include_payload
required: false
schema:
default: false
description: Include the raw task payload (submission params) in the response.
May be large.
title: Include Payload
type: boolean
style: form
- explode: false
in: header
name: authorization
@@ -3500,6 +3620,12 @@ components:
failed_operations:
title: Failed Operations
type: integer
operations_by_status:
additionalProperties:
type: integer
description: "Async operations grouped by status (pending, in_progress,\
\ completed, failed, cancelled)."
title: Operations By Status
last_consolidated_at:
nullable: true
type: string
@@ -3576,6 +3702,39 @@ components:
entities_allow_free_form:
nullable: true
type: boolean
retain_default_strategy:
nullable: true
type: string
retain_strategies:
additionalProperties: {}
nullable: true
retain_chunk_batch_size:
nullable: true
type: integer
mcp_enabled_tools:
items:
type: string
nullable: true
type: array
consolidation_llm_batch_size:
nullable: true
type: integer
consolidation_source_facts_max_tokens:
nullable: true
type: integer
consolidation_source_facts_max_tokens_per_observation:
nullable: true
type: integer
max_observations_per_scope:
nullable: true
type: integer
reflect_source_facts_max_tokens:
nullable: true
type: integer
llm_gemini_safety_settings:
items: {}
nullable: true
type: array
title: BankTemplateConfig
BankTemplateDirective:
description: |-
@@ -4381,6 +4540,58 @@ components:
- mention_count
- observations
title: EntityDetailResponse
EntityGraphResponse:
description: Response model for entity co-occurrence graph endpoint.
example:
edges:
- data:
color: '#ffd700'
id: uuid-1-uuid-2
lastCooccurred: 2024-02-01T14:00:00Z
lineStyle: solid
linkType: cooccurrence
source: uuid-1
target: uuid-2
weight: 5
limit: 1000
nodes:
- data:
color: '#42a5f5'
id: uuid-1
label: Alice
mentionCount: 12
- data:
color: '#42a5f5'
id: uuid-2
label: Google
mentionCount: 8
total_edges: 1
total_entities: 2
properties:
nodes:
items:
additionalProperties: {}
type: array
edges:
items:
additionalProperties: {}
type: array
total_entities:
title: Total Entities
type: integer
total_edges:
title: Total Edges
type: integer
limit:
title: Limit
type: integer
required:
- edges
- limit
- nodes
- total_edges
- total_entities
title: EntityGraphResponse
EntityIncludeOptions:
description: Options for including entity observations in recall results.
properties:
@@ -4736,6 +4947,44 @@ components:
- offset
- total
title: ListTagsResponse
MemoriesTimeseriesResponse:
description: Time-series of memory ingestion bucketed by time and fact type.
example:
period: period
trunc: trunc
bank_id: bank_id
buckets:
- world: 0
observation: 1
time: time
experience: 6
- world: 0
observation: 1
time: time
experience: 6
properties:
bank_id:
title: Bank Id
type: string
period:
description: "One of: 1h, 12h, 1d, 7d, 30d, 90d."
title: Period
type: string
trunc:
description: "Bucket granularity: minute, hour, day."
title: Trunc
type: string
buckets:
description: "Per-bucket counts, always returned fully padded for the requested\
\ period."
items:
$ref: '#/components/schemas/MemoryTimeseriesBucket'
type: array
required:
- bank_id
- period
- trunc
title: MemoriesTimeseriesResponse
MemoryItem:
description: Single memory item for retain.
example:
@@ -4793,6 +5042,36 @@ components:
required:
- content
title: MemoryItem
MemoryTimeseriesBucket:
description: One bucket in the memory ingestion time-series.
example:
world: 0
observation: 1
time: time
experience: 6
properties:
time:
description: Bucket start timestamp in ISO-8601 (UTC).
title: Time
type: string
world:
default: 0
description: World-fact memories ingested in this bucket.
title: World
type: integer
experience:
default: 0
description: Experience memories ingested in this bucket.
title: Experience
type: integer
observation:
default: 0
description: Observations recorded in this bucket.
title: Observation
type: integer
required:
- time
title: MemoryTimeseriesBucket
MentalModelListResponse:
description: Response model for listing mental models.
example:
@@ -4807,6 +5086,7 @@ components:
id: id
trigger:
refresh_after_consolidation: false
recall_chunks_max_tokens: 1
tag_groups:
- match: any_strict
tags:
@@ -4822,8 +5102,10 @@ components:
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
last_refreshed_at: last_refreshed_at
content: content
tags:
@@ -4839,6 +5121,7 @@ components:
id: id
trigger:
refresh_after_consolidation: false
recall_chunks_max_tokens: 1
tag_groups:
- match: any_strict
tags:
@@ -4854,8 +5137,10 @@ components:
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
last_refreshed_at: last_refreshed_at
content: content
tags:
@@ -4882,6 +5167,7 @@ components:
id: id
trigger:
refresh_after_consolidation: false
recall_chunks_max_tokens: 1
tag_groups:
- match: any_strict
tags:
@@ -4897,8 +5183,10 @@ components:
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
last_refreshed_at: last_refreshed_at
content: content
tags:
@@ -4986,11 +5274,21 @@ components:
$ref: '#/components/schemas/MentalModelTrigger_Input_tag_groups_inner'
nullable: true
type: array
include_chunks:
nullable: true
type: boolean
recall_max_tokens:
nullable: true
type: integer
recall_chunks_max_tokens:
nullable: true
type: integer
title: MentalModelTrigger
MentalModelTrigger-Output:
description: Trigger settings for a mental model.
example:
refresh_after_consolidation: false
recall_chunks_max_tokens: 1
tag_groups:
- match: any_strict
tags:
@@ -5006,8 +5304,10 @@ components:
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:
refresh_after_consolidation:
default: false
@@ -5048,6 +5348,15 @@ components:
$ref: '#/components/schemas/MentalModelTrigger_Output_tag_groups_inner'
nullable: true
type: array
include_chunks:
nullable: true
type: boolean
recall_max_tokens:
nullable: true
type: integer
recall_chunks_max_tokens:
nullable: true
type: integer
title: MentalModelTrigger
OperationResponse:
description: Response model for a single async operation.
@@ -5131,6 +5440,9 @@ components:
$ref: '#/components/schemas/ChildOperationStatus'
nullable: true
type: array
task_payload:
additionalProperties: {}
nullable: true
required:
- operation_id
- status
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+135 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.1
API version: 0.5.2
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -905,6 +905,140 @@ func (a *BanksAPIService) GetBankProfileExecute(r ApiGetBankProfileRequest) (*Ba
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiGetMemoriesTimeseriesRequest struct {
ctx context.Context
ApiService *BanksAPIService
bankId string
period *string
authorization *string
}
func (r ApiGetMemoriesTimeseriesRequest) Period(period string) ApiGetMemoriesTimeseriesRequest {
r.period = &period
return r
}
func (r ApiGetMemoriesTimeseriesRequest) Authorization(authorization string) ApiGetMemoriesTimeseriesRequest {
r.authorization = &authorization
return r
}
func (r ApiGetMemoriesTimeseriesRequest) Execute() (*MemoriesTimeseriesResponse, *http.Response, error) {
return r.ApiService.GetMemoriesTimeseriesExecute(r)
}
/*
GetMemoriesTimeseries Memory ingestion time-series
Memories ingested over a period, bucketed by time and broken down by fact type.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@return ApiGetMemoriesTimeseriesRequest
*/
func (a *BanksAPIService) GetMemoriesTimeseries(ctx context.Context, bankId string) ApiGetMemoriesTimeseriesRequest {
return ApiGetMemoriesTimeseriesRequest{
ApiService: a,
ctx: ctx,
bankId: bankId,
}
}
// Execute executes the request
// @return MemoriesTimeseriesResponse
func (a *BanksAPIService) GetMemoriesTimeseriesExecute(r ApiGetMemoriesTimeseriesRequest) (*MemoriesTimeseriesResponse, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodGet
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue *MemoriesTimeseriesResponse
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "BanksAPIService.GetMemoriesTimeseries")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/v1/default/banks/{bank_id}/stats/memories-timeseries"
localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1)
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
if r.period != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "period", r.period, "form", "")
} else {
var defaultValue string = "7d"
r.period = &defaultValue
}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
if r.authorization != nil {
parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "")
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
if localVarHTTPResponse.StatusCode == 422 {
var v HTTPValidationError
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v)
newErr.model = v
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiListBanksRequest struct {
ctx context.Context
ApiService *BanksAPIService
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+149 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.1
API version: 0.5.2
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -149,6 +149,154 @@ func (a *EntitiesAPIService) GetEntityExecute(r ApiGetEntityRequest) (*EntityDet
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiGetEntityGraphRequest struct {
ctx context.Context
ApiService *EntitiesAPIService
bankId string
limit *int32
minCount *int32
authorization *string
}
// Maximum number of co-occurrence edges to return
func (r ApiGetEntityGraphRequest) Limit(limit int32) ApiGetEntityGraphRequest {
r.limit = &limit
return r
}
// Minimum cooccurrence_count to include an edge
func (r ApiGetEntityGraphRequest) MinCount(minCount int32) ApiGetEntityGraphRequest {
r.minCount = &minCount
return r
}
func (r ApiGetEntityGraphRequest) Authorization(authorization string) ApiGetEntityGraphRequest {
r.authorization = &authorization
return r
}
func (r ApiGetEntityGraphRequest) Execute() (*EntityGraphResponse, *http.Response, error) {
return r.ApiService.GetEntityGraphExecute(r)
}
/*
GetEntityGraph Get entity co-occurrence graph
Return a graph of entities (nodes) and their co-occurrences (edges) for visualization.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@return ApiGetEntityGraphRequest
*/
func (a *EntitiesAPIService) GetEntityGraph(ctx context.Context, bankId string) ApiGetEntityGraphRequest {
return ApiGetEntityGraphRequest{
ApiService: a,
ctx: ctx,
bankId: bankId,
}
}
// Execute executes the request
// @return EntityGraphResponse
func (a *EntitiesAPIService) GetEntityGraphExecute(r ApiGetEntityGraphRequest) (*EntityGraphResponse, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodGet
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue *EntityGraphResponse
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "EntitiesAPIService.GetEntityGraph")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/v1/default/banks/{bank_id}/entities/graph"
localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1)
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
if r.limit != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "limit", r.limit, "form", "")
} else {
var defaultValue int32 = 1000
r.limit = &defaultValue
}
if r.minCount != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "min_count", r.minCount, "form", "")
} else {
var defaultValue int32 = 1
r.minCount = &defaultValue
}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
if r.authorization != nil {
parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "")
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
if localVarHTTPResponse.StatusCode == 422 {
var v HTTPValidationError
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v)
newErr.model = v
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiListEntitiesRequest struct {
ctx context.Context
ApiService *EntitiesAPIService
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -154,9 +154,16 @@ type ApiGetOperationStatusRequest struct {
ApiService *OperationsAPIService
bankId string
operationId string
includePayload *bool
authorization *string
}
// Include the raw task payload (submission params) in the response. May be large.
func (r ApiGetOperationStatusRequest) IncludePayload(includePayload bool) ApiGetOperationStatusRequest {
r.includePayload = &includePayload
return r
}
func (r ApiGetOperationStatusRequest) Authorization(authorization string) ApiGetOperationStatusRequest {
r.authorization = &authorization
return r
@@ -208,6 +215,12 @@ func (a *OperationsAPIService) GetOperationStatusExecute(r ApiGetOperationStatus
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
if r.includePayload != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "include_payload", r.includePayload, "form", "")
} else {
var defaultValue bool = false
r.includePayload = &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.5.1
API version: 0.5.2
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+2 -2
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.1
API version: 0.5.2
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -41,7 +41,7 @@ var (
queryDescape = strings.NewReplacer( "%5B", "[", "%5D", "]" )
)
// APIClient manages communication with the Hindsight HTTP API API v0.5.1
// APIClient manages communication with the Hindsight HTTP API API v0.5.2
// In most cases there should be only one, shared, APIClient.
type APIClient struct {
cfg *Configuration
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.1
API version: 0.5.2
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+24
View File
@@ -2,9 +2,31 @@ package hindsight
import (
"net/http"
"runtime/debug"
"time"
)
// defaultUserAgent returns the User-Agent string sent on every request unless
// the caller overrides cfg.UserAgent. The version is read from build info so
// it stays in sync with the module version automatically; falls back to
// "devel" when running from an unpinned local checkout.
func defaultUserAgent() string {
version := "devel"
if info, ok := debug.ReadBuildInfo(); ok {
for _, dep := range info.Deps {
if dep.Path == "github.com/vectorize-io/hindsight/hindsight-clients/go" {
version = dep.Version
break
}
}
}
return "hindsight-client-go/" + version
}
// DefaultUserAgent is the User-Agent string sent on every request unless the
// caller overrides cfg.UserAgent (e.g. for integrations identifying themselves).
var DefaultUserAgent = defaultUserAgent()
// NewAPIClientWithToken creates a new API client configured with a base URL and API token.
// The token is sent as a Bearer token in the Authorization header for all requests.
// Note: this uses http.DefaultClient which has no timeout. Use NewAPIClientWithTimeout
@@ -16,6 +38,7 @@ import (
// resp, _, err := client.MemoryAPI.RetainMemories(ctx, bankID).RetainRequest(req).Execute()
func NewAPIClientWithToken(baseURL, token string) *APIClient {
cfg := NewConfiguration()
cfg.UserAgent = DefaultUserAgent
cfg.Servers = ServerConfigurations{
{URL: baseURL},
}
@@ -32,6 +55,7 @@ func NewAPIClientWithToken(baseURL, token string) *APIClient {
// resp, _, err := client.MemoryAPI.RetainMemories(ctx, bankID).RetainRequest(req).Execute()
func NewAPIClientWithTimeout(baseURL, token string, timeout time.Duration) *APIClient {
cfg := NewConfiguration()
cfg.UserAgent = DefaultUserAgent
cfg.Servers = ServerConfigurations{
{URL: baseURL},
}
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -31,6 +31,8 @@ type BankStatsResponse struct {
LinksBreakdown map[string]map[string]int32 `json:"links_breakdown"`
PendingOperations int32 `json:"pending_operations"`
FailedOperations int32 `json:"failed_operations"`
// Async operations grouped by status (pending, in_progress, completed, failed, cancelled).
OperationsByStatus map[string]int32 `json:"operations_by_status,omitempty"`
LastConsolidatedAt NullableString `json:"last_consolidated_at,omitempty"`
// Number of memories not yet processed into observations
PendingConsolidation *int32 `json:"pending_consolidation,omitempty"`
@@ -315,6 +317,38 @@ func (o *BankStatsResponse) SetFailedOperations(v int32) {
o.FailedOperations = v
}
// GetOperationsByStatus returns the OperationsByStatus field value if set, zero value otherwise.
func (o *BankStatsResponse) GetOperationsByStatus() map[string]int32 {
if o == nil || IsNil(o.OperationsByStatus) {
var ret map[string]int32
return ret
}
return o.OperationsByStatus
}
// GetOperationsByStatusOk returns a tuple with the OperationsByStatus field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *BankStatsResponse) GetOperationsByStatusOk() (map[string]int32, bool) {
if o == nil || IsNil(o.OperationsByStatus) {
return map[string]int32{}, false
}
return o.OperationsByStatus, true
}
// HasOperationsByStatus returns a boolean if a field has been set.
func (o *BankStatsResponse) HasOperationsByStatus() bool {
if o != nil && !IsNil(o.OperationsByStatus) {
return true
}
return false
}
// SetOperationsByStatus gets a reference to the given map[string]int32 and assigns it to the OperationsByStatus field.
func (o *BankStatsResponse) SetOperationsByStatus(v map[string]int32) {
o.OperationsByStatus = v
}
// GetLastConsolidatedAt returns the LastConsolidatedAt field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *BankStatsResponse) GetLastConsolidatedAt() string {
if o == nil || IsNil(o.LastConsolidatedAt.Get()) {
@@ -441,6 +475,9 @@ func (o BankStatsResponse) ToMap() (map[string]interface{}, error) {
toSerialize["links_breakdown"] = o.LinksBreakdown
toSerialize["pending_operations"] = o.PendingOperations
toSerialize["failed_operations"] = o.FailedOperations
if !IsNil(o.OperationsByStatus) {
toSerialize["operations_by_status"] = o.OperationsByStatus
}
if o.LastConsolidatedAt.IsSet() {
toSerialize["last_consolidated_at"] = o.LastConsolidatedAt.Get()
}
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.1
API version: 0.5.2
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -31,6 +31,16 @@ type BankTemplateConfig struct {
DispositionEmpathy NullableInt32 `json:"disposition_empathy,omitempty"`
EntityLabels []map[string]interface{} `json:"entity_labels,omitempty"`
EntitiesAllowFreeForm NullableBool `json:"entities_allow_free_form,omitempty"`
RetainDefaultStrategy NullableString `json:"retain_default_strategy,omitempty"`
RetainStrategies map[string]interface{} `json:"retain_strategies,omitempty"`
RetainChunkBatchSize NullableInt32 `json:"retain_chunk_batch_size,omitempty"`
McpEnabledTools []string `json:"mcp_enabled_tools,omitempty"`
ConsolidationLlmBatchSize NullableInt32 `json:"consolidation_llm_batch_size,omitempty"`
ConsolidationSourceFactsMaxTokens NullableInt32 `json:"consolidation_source_facts_max_tokens,omitempty"`
ConsolidationSourceFactsMaxTokensPerObservation NullableInt32 `json:"consolidation_source_facts_max_tokens_per_observation,omitempty"`
MaxObservationsPerScope NullableInt32 `json:"max_observations_per_scope,omitempty"`
ReflectSourceFactsMaxTokens NullableInt32 `json:"reflect_source_facts_max_tokens,omitempty"`
LlmGeminiSafetySettings []interface{} `json:"llm_gemini_safety_settings,omitempty"`
}
// NewBankTemplateConfig instantiates a new BankTemplateConfig object
@@ -545,6 +555,399 @@ func (o *BankTemplateConfig) UnsetEntitiesAllowFreeForm() {
o.EntitiesAllowFreeForm.Unset()
}
// GetRetainDefaultStrategy returns the RetainDefaultStrategy field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *BankTemplateConfig) GetRetainDefaultStrategy() string {
if o == nil || IsNil(o.RetainDefaultStrategy.Get()) {
var ret string
return ret
}
return *o.RetainDefaultStrategy.Get()
}
// GetRetainDefaultStrategyOk returns a tuple with the RetainDefaultStrategy field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *BankTemplateConfig) GetRetainDefaultStrategyOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.RetainDefaultStrategy.Get(), o.RetainDefaultStrategy.IsSet()
}
// HasRetainDefaultStrategy returns a boolean if a field has been set.
func (o *BankTemplateConfig) HasRetainDefaultStrategy() bool {
if o != nil && o.RetainDefaultStrategy.IsSet() {
return true
}
return false
}
// SetRetainDefaultStrategy gets a reference to the given NullableString and assigns it to the RetainDefaultStrategy field.
func (o *BankTemplateConfig) SetRetainDefaultStrategy(v string) {
o.RetainDefaultStrategy.Set(&v)
}
// SetRetainDefaultStrategyNil sets the value for RetainDefaultStrategy to be an explicit nil
func (o *BankTemplateConfig) SetRetainDefaultStrategyNil() {
o.RetainDefaultStrategy.Set(nil)
}
// UnsetRetainDefaultStrategy ensures that no value is present for RetainDefaultStrategy, not even an explicit nil
func (o *BankTemplateConfig) UnsetRetainDefaultStrategy() {
o.RetainDefaultStrategy.Unset()
}
// GetRetainStrategies returns the RetainStrategies field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *BankTemplateConfig) GetRetainStrategies() map[string]interface{} {
if o == nil {
var ret map[string]interface{}
return ret
}
return o.RetainStrategies
}
// GetRetainStrategiesOk returns a tuple with the RetainStrategies field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *BankTemplateConfig) GetRetainStrategiesOk() (map[string]interface{}, bool) {
if o == nil || IsNil(o.RetainStrategies) {
return map[string]interface{}{}, false
}
return o.RetainStrategies, true
}
// HasRetainStrategies returns a boolean if a field has been set.
func (o *BankTemplateConfig) HasRetainStrategies() bool {
if o != nil && !IsNil(o.RetainStrategies) {
return true
}
return false
}
// SetRetainStrategies gets a reference to the given map[string]interface{} and assigns it to the RetainStrategies field.
func (o *BankTemplateConfig) SetRetainStrategies(v map[string]interface{}) {
o.RetainStrategies = v
}
// GetRetainChunkBatchSize returns the RetainChunkBatchSize field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *BankTemplateConfig) GetRetainChunkBatchSize() int32 {
if o == nil || IsNil(o.RetainChunkBatchSize.Get()) {
var ret int32
return ret
}
return *o.RetainChunkBatchSize.Get()
}
// GetRetainChunkBatchSizeOk returns a tuple with the RetainChunkBatchSize field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *BankTemplateConfig) GetRetainChunkBatchSizeOk() (*int32, bool) {
if o == nil {
return nil, false
}
return o.RetainChunkBatchSize.Get(), o.RetainChunkBatchSize.IsSet()
}
// HasRetainChunkBatchSize returns a boolean if a field has been set.
func (o *BankTemplateConfig) HasRetainChunkBatchSize() bool {
if o != nil && o.RetainChunkBatchSize.IsSet() {
return true
}
return false
}
// SetRetainChunkBatchSize gets a reference to the given NullableInt32 and assigns it to the RetainChunkBatchSize field.
func (o *BankTemplateConfig) SetRetainChunkBatchSize(v int32) {
o.RetainChunkBatchSize.Set(&v)
}
// SetRetainChunkBatchSizeNil sets the value for RetainChunkBatchSize to be an explicit nil
func (o *BankTemplateConfig) SetRetainChunkBatchSizeNil() {
o.RetainChunkBatchSize.Set(nil)
}
// UnsetRetainChunkBatchSize ensures that no value is present for RetainChunkBatchSize, not even an explicit nil
func (o *BankTemplateConfig) UnsetRetainChunkBatchSize() {
o.RetainChunkBatchSize.Unset()
}
// GetMcpEnabledTools returns the McpEnabledTools field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *BankTemplateConfig) GetMcpEnabledTools() []string {
if o == nil {
var ret []string
return ret
}
return o.McpEnabledTools
}
// GetMcpEnabledToolsOk returns a tuple with the McpEnabledTools field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *BankTemplateConfig) GetMcpEnabledToolsOk() ([]string, bool) {
if o == nil || IsNil(o.McpEnabledTools) {
return nil, false
}
return o.McpEnabledTools, true
}
// HasMcpEnabledTools returns a boolean if a field has been set.
func (o *BankTemplateConfig) HasMcpEnabledTools() bool {
if o != nil && !IsNil(o.McpEnabledTools) {
return true
}
return false
}
// SetMcpEnabledTools gets a reference to the given []string and assigns it to the McpEnabledTools field.
func (o *BankTemplateConfig) SetMcpEnabledTools(v []string) {
o.McpEnabledTools = v
}
// GetConsolidationLlmBatchSize returns the ConsolidationLlmBatchSize field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *BankTemplateConfig) GetConsolidationLlmBatchSize() int32 {
if o == nil || IsNil(o.ConsolidationLlmBatchSize.Get()) {
var ret int32
return ret
}
return *o.ConsolidationLlmBatchSize.Get()
}
// GetConsolidationLlmBatchSizeOk returns a tuple with the ConsolidationLlmBatchSize field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *BankTemplateConfig) GetConsolidationLlmBatchSizeOk() (*int32, bool) {
if o == nil {
return nil, false
}
return o.ConsolidationLlmBatchSize.Get(), o.ConsolidationLlmBatchSize.IsSet()
}
// HasConsolidationLlmBatchSize returns a boolean if a field has been set.
func (o *BankTemplateConfig) HasConsolidationLlmBatchSize() bool {
if o != nil && o.ConsolidationLlmBatchSize.IsSet() {
return true
}
return false
}
// SetConsolidationLlmBatchSize gets a reference to the given NullableInt32 and assigns it to the ConsolidationLlmBatchSize field.
func (o *BankTemplateConfig) SetConsolidationLlmBatchSize(v int32) {
o.ConsolidationLlmBatchSize.Set(&v)
}
// SetConsolidationLlmBatchSizeNil sets the value for ConsolidationLlmBatchSize to be an explicit nil
func (o *BankTemplateConfig) SetConsolidationLlmBatchSizeNil() {
o.ConsolidationLlmBatchSize.Set(nil)
}
// UnsetConsolidationLlmBatchSize ensures that no value is present for ConsolidationLlmBatchSize, not even an explicit nil
func (o *BankTemplateConfig) UnsetConsolidationLlmBatchSize() {
o.ConsolidationLlmBatchSize.Unset()
}
// GetConsolidationSourceFactsMaxTokens returns the ConsolidationSourceFactsMaxTokens field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *BankTemplateConfig) GetConsolidationSourceFactsMaxTokens() int32 {
if o == nil || IsNil(o.ConsolidationSourceFactsMaxTokens.Get()) {
var ret int32
return ret
}
return *o.ConsolidationSourceFactsMaxTokens.Get()
}
// GetConsolidationSourceFactsMaxTokensOk returns a tuple with the ConsolidationSourceFactsMaxTokens field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *BankTemplateConfig) GetConsolidationSourceFactsMaxTokensOk() (*int32, bool) {
if o == nil {
return nil, false
}
return o.ConsolidationSourceFactsMaxTokens.Get(), o.ConsolidationSourceFactsMaxTokens.IsSet()
}
// HasConsolidationSourceFactsMaxTokens returns a boolean if a field has been set.
func (o *BankTemplateConfig) HasConsolidationSourceFactsMaxTokens() bool {
if o != nil && o.ConsolidationSourceFactsMaxTokens.IsSet() {
return true
}
return false
}
// SetConsolidationSourceFactsMaxTokens gets a reference to the given NullableInt32 and assigns it to the ConsolidationSourceFactsMaxTokens field.
func (o *BankTemplateConfig) SetConsolidationSourceFactsMaxTokens(v int32) {
o.ConsolidationSourceFactsMaxTokens.Set(&v)
}
// SetConsolidationSourceFactsMaxTokensNil sets the value for ConsolidationSourceFactsMaxTokens to be an explicit nil
func (o *BankTemplateConfig) SetConsolidationSourceFactsMaxTokensNil() {
o.ConsolidationSourceFactsMaxTokens.Set(nil)
}
// UnsetConsolidationSourceFactsMaxTokens ensures that no value is present for ConsolidationSourceFactsMaxTokens, not even an explicit nil
func (o *BankTemplateConfig) UnsetConsolidationSourceFactsMaxTokens() {
o.ConsolidationSourceFactsMaxTokens.Unset()
}
// GetConsolidationSourceFactsMaxTokensPerObservation returns the ConsolidationSourceFactsMaxTokensPerObservation field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *BankTemplateConfig) GetConsolidationSourceFactsMaxTokensPerObservation() int32 {
if o == nil || IsNil(o.ConsolidationSourceFactsMaxTokensPerObservation.Get()) {
var ret int32
return ret
}
return *o.ConsolidationSourceFactsMaxTokensPerObservation.Get()
}
// GetConsolidationSourceFactsMaxTokensPerObservationOk returns a tuple with the ConsolidationSourceFactsMaxTokensPerObservation field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *BankTemplateConfig) GetConsolidationSourceFactsMaxTokensPerObservationOk() (*int32, bool) {
if o == nil {
return nil, false
}
return o.ConsolidationSourceFactsMaxTokensPerObservation.Get(), o.ConsolidationSourceFactsMaxTokensPerObservation.IsSet()
}
// HasConsolidationSourceFactsMaxTokensPerObservation returns a boolean if a field has been set.
func (o *BankTemplateConfig) HasConsolidationSourceFactsMaxTokensPerObservation() bool {
if o != nil && o.ConsolidationSourceFactsMaxTokensPerObservation.IsSet() {
return true
}
return false
}
// SetConsolidationSourceFactsMaxTokensPerObservation gets a reference to the given NullableInt32 and assigns it to the ConsolidationSourceFactsMaxTokensPerObservation field.
func (o *BankTemplateConfig) SetConsolidationSourceFactsMaxTokensPerObservation(v int32) {
o.ConsolidationSourceFactsMaxTokensPerObservation.Set(&v)
}
// SetConsolidationSourceFactsMaxTokensPerObservationNil sets the value for ConsolidationSourceFactsMaxTokensPerObservation to be an explicit nil
func (o *BankTemplateConfig) SetConsolidationSourceFactsMaxTokensPerObservationNil() {
o.ConsolidationSourceFactsMaxTokensPerObservation.Set(nil)
}
// UnsetConsolidationSourceFactsMaxTokensPerObservation ensures that no value is present for ConsolidationSourceFactsMaxTokensPerObservation, not even an explicit nil
func (o *BankTemplateConfig) UnsetConsolidationSourceFactsMaxTokensPerObservation() {
o.ConsolidationSourceFactsMaxTokensPerObservation.Unset()
}
// GetMaxObservationsPerScope returns the MaxObservationsPerScope field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *BankTemplateConfig) GetMaxObservationsPerScope() int32 {
if o == nil || IsNil(o.MaxObservationsPerScope.Get()) {
var ret int32
return ret
}
return *o.MaxObservationsPerScope.Get()
}
// GetMaxObservationsPerScopeOk returns a tuple with the MaxObservationsPerScope field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *BankTemplateConfig) GetMaxObservationsPerScopeOk() (*int32, bool) {
if o == nil {
return nil, false
}
return o.MaxObservationsPerScope.Get(), o.MaxObservationsPerScope.IsSet()
}
// HasMaxObservationsPerScope returns a boolean if a field has been set.
func (o *BankTemplateConfig) HasMaxObservationsPerScope() bool {
if o != nil && o.MaxObservationsPerScope.IsSet() {
return true
}
return false
}
// SetMaxObservationsPerScope gets a reference to the given NullableInt32 and assigns it to the MaxObservationsPerScope field.
func (o *BankTemplateConfig) SetMaxObservationsPerScope(v int32) {
o.MaxObservationsPerScope.Set(&v)
}
// SetMaxObservationsPerScopeNil sets the value for MaxObservationsPerScope to be an explicit nil
func (o *BankTemplateConfig) SetMaxObservationsPerScopeNil() {
o.MaxObservationsPerScope.Set(nil)
}
// UnsetMaxObservationsPerScope ensures that no value is present for MaxObservationsPerScope, not even an explicit nil
func (o *BankTemplateConfig) UnsetMaxObservationsPerScope() {
o.MaxObservationsPerScope.Unset()
}
// GetReflectSourceFactsMaxTokens returns the ReflectSourceFactsMaxTokens field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *BankTemplateConfig) GetReflectSourceFactsMaxTokens() int32 {
if o == nil || IsNil(o.ReflectSourceFactsMaxTokens.Get()) {
var ret int32
return ret
}
return *o.ReflectSourceFactsMaxTokens.Get()
}
// GetReflectSourceFactsMaxTokensOk returns a tuple with the ReflectSourceFactsMaxTokens field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *BankTemplateConfig) GetReflectSourceFactsMaxTokensOk() (*int32, bool) {
if o == nil {
return nil, false
}
return o.ReflectSourceFactsMaxTokens.Get(), o.ReflectSourceFactsMaxTokens.IsSet()
}
// HasReflectSourceFactsMaxTokens returns a boolean if a field has been set.
func (o *BankTemplateConfig) HasReflectSourceFactsMaxTokens() bool {
if o != nil && o.ReflectSourceFactsMaxTokens.IsSet() {
return true
}
return false
}
// SetReflectSourceFactsMaxTokens gets a reference to the given NullableInt32 and assigns it to the ReflectSourceFactsMaxTokens field.
func (o *BankTemplateConfig) SetReflectSourceFactsMaxTokens(v int32) {
o.ReflectSourceFactsMaxTokens.Set(&v)
}
// SetReflectSourceFactsMaxTokensNil sets the value for ReflectSourceFactsMaxTokens to be an explicit nil
func (o *BankTemplateConfig) SetReflectSourceFactsMaxTokensNil() {
o.ReflectSourceFactsMaxTokens.Set(nil)
}
// UnsetReflectSourceFactsMaxTokens ensures that no value is present for ReflectSourceFactsMaxTokens, not even an explicit nil
func (o *BankTemplateConfig) UnsetReflectSourceFactsMaxTokens() {
o.ReflectSourceFactsMaxTokens.Unset()
}
// GetLlmGeminiSafetySettings returns the LlmGeminiSafetySettings field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *BankTemplateConfig) GetLlmGeminiSafetySettings() []interface{} {
if o == nil {
var ret []interface{}
return ret
}
return o.LlmGeminiSafetySettings
}
// GetLlmGeminiSafetySettingsOk returns a tuple with the LlmGeminiSafetySettings field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *BankTemplateConfig) GetLlmGeminiSafetySettingsOk() ([]interface{}, bool) {
if o == nil || IsNil(o.LlmGeminiSafetySettings) {
return nil, false
}
return o.LlmGeminiSafetySettings, true
}
// HasLlmGeminiSafetySettings returns a boolean if a field has been set.
func (o *BankTemplateConfig) HasLlmGeminiSafetySettings() bool {
if o != nil && !IsNil(o.LlmGeminiSafetySettings) {
return true
}
return false
}
// SetLlmGeminiSafetySettings gets a reference to the given []interface{} and assigns it to the LlmGeminiSafetySettings field.
func (o *BankTemplateConfig) SetLlmGeminiSafetySettings(v []interface{}) {
o.LlmGeminiSafetySettings = v
}
func (o BankTemplateConfig) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
@@ -591,6 +994,36 @@ func (o BankTemplateConfig) ToMap() (map[string]interface{}, error) {
if o.EntitiesAllowFreeForm.IsSet() {
toSerialize["entities_allow_free_form"] = o.EntitiesAllowFreeForm.Get()
}
if o.RetainDefaultStrategy.IsSet() {
toSerialize["retain_default_strategy"] = o.RetainDefaultStrategy.Get()
}
if o.RetainStrategies != nil {
toSerialize["retain_strategies"] = o.RetainStrategies
}
if o.RetainChunkBatchSize.IsSet() {
toSerialize["retain_chunk_batch_size"] = o.RetainChunkBatchSize.Get()
}
if o.McpEnabledTools != nil {
toSerialize["mcp_enabled_tools"] = o.McpEnabledTools
}
if o.ConsolidationLlmBatchSize.IsSet() {
toSerialize["consolidation_llm_batch_size"] = o.ConsolidationLlmBatchSize.Get()
}
if o.ConsolidationSourceFactsMaxTokens.IsSet() {
toSerialize["consolidation_source_facts_max_tokens"] = o.ConsolidationSourceFactsMaxTokens.Get()
}
if o.ConsolidationSourceFactsMaxTokensPerObservation.IsSet() {
toSerialize["consolidation_source_facts_max_tokens_per_observation"] = o.ConsolidationSourceFactsMaxTokensPerObservation.Get()
}
if o.MaxObservationsPerScope.IsSet() {
toSerialize["max_observations_per_scope"] = o.MaxObservationsPerScope.Get()
}
if o.ReflectSourceFactsMaxTokens.IsSet() {
toSerialize["reflect_source_facts_max_tokens"] = o.ReflectSourceFactsMaxTokens.Get()
}
if o.LlmGeminiSafetySettings != nil {
toSerialize["llm_gemini_safety_settings"] = o.LlmGeminiSafetySettings
}
return toSerialize, nil
}
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -0,0 +1,270 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.2
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the EntityGraphResponse type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &EntityGraphResponse{}
// EntityGraphResponse Response model for entity co-occurrence graph endpoint.
type EntityGraphResponse struct {
Nodes []map[string]interface{} `json:"nodes"`
Edges []map[string]interface{} `json:"edges"`
TotalEntities int32 `json:"total_entities"`
TotalEdges int32 `json:"total_edges"`
Limit int32 `json:"limit"`
}
type _EntityGraphResponse EntityGraphResponse
// NewEntityGraphResponse instantiates a new EntityGraphResponse object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewEntityGraphResponse(nodes []map[string]interface{}, edges []map[string]interface{}, totalEntities int32, totalEdges int32, limit int32) *EntityGraphResponse {
this := EntityGraphResponse{}
this.Nodes = nodes
this.Edges = edges
this.TotalEntities = totalEntities
this.TotalEdges = totalEdges
this.Limit = limit
return &this
}
// NewEntityGraphResponseWithDefaults instantiates a new EntityGraphResponse object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewEntityGraphResponseWithDefaults() *EntityGraphResponse {
this := EntityGraphResponse{}
return &this
}
// GetNodes returns the Nodes field value
func (o *EntityGraphResponse) GetNodes() []map[string]interface{} {
if o == nil {
var ret []map[string]interface{}
return ret
}
return o.Nodes
}
// GetNodesOk returns a tuple with the Nodes field value
// and a boolean to check if the value has been set.
func (o *EntityGraphResponse) GetNodesOk() ([]map[string]interface{}, bool) {
if o == nil {
return nil, false
}
return o.Nodes, true
}
// SetNodes sets field value
func (o *EntityGraphResponse) SetNodes(v []map[string]interface{}) {
o.Nodes = v
}
// GetEdges returns the Edges field value
func (o *EntityGraphResponse) GetEdges() []map[string]interface{} {
if o == nil {
var ret []map[string]interface{}
return ret
}
return o.Edges
}
// GetEdgesOk returns a tuple with the Edges field value
// and a boolean to check if the value has been set.
func (o *EntityGraphResponse) GetEdgesOk() ([]map[string]interface{}, bool) {
if o == nil {
return nil, false
}
return o.Edges, true
}
// SetEdges sets field value
func (o *EntityGraphResponse) SetEdges(v []map[string]interface{}) {
o.Edges = v
}
// GetTotalEntities returns the TotalEntities field value
func (o *EntityGraphResponse) GetTotalEntities() int32 {
if o == nil {
var ret int32
return ret
}
return o.TotalEntities
}
// GetTotalEntitiesOk returns a tuple with the TotalEntities field value
// and a boolean to check if the value has been set.
func (o *EntityGraphResponse) GetTotalEntitiesOk() (*int32, bool) {
if o == nil {
return nil, false
}
return &o.TotalEntities, true
}
// SetTotalEntities sets field value
func (o *EntityGraphResponse) SetTotalEntities(v int32) {
o.TotalEntities = v
}
// GetTotalEdges returns the TotalEdges field value
func (o *EntityGraphResponse) GetTotalEdges() int32 {
if o == nil {
var ret int32
return ret
}
return o.TotalEdges
}
// GetTotalEdgesOk returns a tuple with the TotalEdges field value
// and a boolean to check if the value has been set.
func (o *EntityGraphResponse) GetTotalEdgesOk() (*int32, bool) {
if o == nil {
return nil, false
}
return &o.TotalEdges, true
}
// SetTotalEdges sets field value
func (o *EntityGraphResponse) SetTotalEdges(v int32) {
o.TotalEdges = v
}
// GetLimit returns the Limit field value
func (o *EntityGraphResponse) GetLimit() int32 {
if o == nil {
var ret int32
return ret
}
return o.Limit
}
// GetLimitOk returns a tuple with the Limit field value
// and a boolean to check if the value has been set.
func (o *EntityGraphResponse) GetLimitOk() (*int32, bool) {
if o == nil {
return nil, false
}
return &o.Limit, true
}
// SetLimit sets field value
func (o *EntityGraphResponse) SetLimit(v int32) {
o.Limit = v
}
func (o EntityGraphResponse) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o EntityGraphResponse) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["nodes"] = o.Nodes
toSerialize["edges"] = o.Edges
toSerialize["total_entities"] = o.TotalEntities
toSerialize["total_edges"] = o.TotalEdges
toSerialize["limit"] = o.Limit
return toSerialize, nil
}
func (o *EntityGraphResponse) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"nodes",
"edges",
"total_entities",
"total_edges",
"limit",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varEntityGraphResponse := _EntityGraphResponse{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varEntityGraphResponse)
if err != nil {
return err
}
*o = EntityGraphResponse(varEntityGraphResponse)
return err
}
type NullableEntityGraphResponse struct {
value *EntityGraphResponse
isSet bool
}
func (v NullableEntityGraphResponse) Get() *EntityGraphResponse {
return v.value
}
func (v *NullableEntityGraphResponse) Set(val *EntityGraphResponse) {
v.value = val
v.isSet = true
}
func (v NullableEntityGraphResponse) IsSet() bool {
return v.isSet
}
func (v *NullableEntityGraphResponse) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableEntityGraphResponse(val *EntityGraphResponse) *NullableEntityGraphResponse {
return &NullableEntityGraphResponse{value: val, isSet: true}
}
func (v NullableEntityGraphResponse) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableEntityGraphResponse) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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.5.1
API version: 0.5.2
*/
// 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