Compare commits

..
Author SHA1 Message Date
Ben f44a6a2703 Merge branch 'main' into docs-codex-cloud-first 2026-05-22 09:23:09 -04:00
Ben f559ae1649 docs(smolagents): prioritize Hindsight Cloud in quickstart (#1692)
Lead README/docs/guide Quick Start with Cloud sign-up + Cloud API
URL example; demote self-hosted localhost:8888 to a 'Self-hosting
(local development)' section below. Update docstring example.
2026-05-22 09:17:39 -04:00
Ben 8ed9a4ebb2 docs(pydantic-ai): prioritize Hindsight Cloud in quickstart (#1691)
Lead README/docs/guide Quick Start with Cloud sign-up + Cloud
base_url example; demote self-hosted localhost:8888 to a
'Self-hosting (local development)' section below. Update docstring
example in __init__.py.
2026-05-22 09:17:07 -04:00
Ben 722aa902fb Regenerate hindsight-docs skill references (#1686)
Adds opencode-go to the integration lists in the generated skill
references. Picked up by the generate-docs-skill.sh pre-commit hook
as drift from the hindsight-docs sources on main.
2026-05-22 09:16:27 -04:00
Ben 5c7e783e18 docs(pipecat): prioritize Hindsight Cloud in quickstart (#1695)
Lead README/docs/guide Quick Start with Cloud, demote self-hosted to
its own section. Updates configure() global example to Cloud default.
2026-05-22 09:15:48 -04:00
Ben e779f10fc7 docs(strands): prioritize Hindsight Cloud in quickstart (#1690)
Lead README/docs/guide Quick Start with Hindsight Cloud sign-up and
Cloud API URL example; demote self-hosted localhost:8888 to a
'Self-hosting (local development)' section below. Update docstring
example in __init__.py to show Cloud-first usage.

Includes 2-line incidental skills/hindsight-docs/ regeneration drift.
2026-05-22 09:15:24 -04:00
Ben 1b2c9f63aa docs(crewai): prioritize Hindsight Cloud in quickstart (#1689)
- Lead README/docs/guide Quick Start with Hindsight Cloud sign-up
  and the Cloud API URL example; demote self-hosted localhost:8888
  to a "Self-hosting (local development)" section below.
- Fix unconfigured-fallback inconsistency in HindsightStorage and
  HindsightReflectTool: previously fell back to localhost:8888
  even though the documented default is Cloud. Now both fallbacks
  use DEFAULT_HINDSIGHT_API_URL.
- Update docstring examples in __init__.py and storage.py to reflect
  the Cloud-first default.
- Update fallback assertion in tests/test_storage.py.
2026-05-22 09:14:56 -04:00
Ben 7ffe6a104b style: apply ruff format to openai_compatible_llm.py (#1703) 2026-05-21 14:36:17 -04:00
Ben d0a6dcf770 docs(codex): prioritize Hindsight Cloud over local daemon
Add Cloud Recommended callouts to README + docs + guide. Reframe the
'Local Daemon' section as the self-hosting alternative rather than a
peer option. No code default changes — codex still defaults to empty
hindsightApiUrl (local daemon) to avoid breaking existing local users.

Includes 2-line incidental skills/hindsight-docs/ regen drift.
2026-05-21 13:26:59 -04:00
Ben 113d7da987 Blog: Agent Memory Consolidation framework (#1672)
* Add blog post: Agent Memory Consolidation framework
2026-05-21 10:42:41 -04:00
Chandler bd86e7ead0 fix(typescript-client): update repository URL to correct repo (#1657) 2026-05-19 16:51:46 -04:00
Ben 795c081d9f fix(api): auto-refresh openai-codex OAuth access_token (#1637) (#1661)
The openai-codex provider was a startup-only credential loader: it read
~/.codex/auth.json once at __init__ and used the cached access_token
forever. ChatGPT OAuth tokens are short-lived (hours), so any
long-running deployment 401d on every request once the cached token
expired. The only recovery was an external cron + container restart.

This change makes the provider refresh tokens itself, mirroring the
canonical @openai/codex CLI (codex-rs/login/src/auth/manager.rs):

- Loads tokens.refresh_token from auth.json (previously discarded).
- Proactive refresh: decodes the access_token JWT's exp claim and
  refreshes ~60s before expiry. Cheap when the token is fresh.
- Reactive refresh: on a 401/403 from the codex backend, refreshes
  once and retries the request without consuming a normal-retry budget
  slot.
- Single-flight: serializes through asyncio.Lock so concurrent callers
  produce one network refresh, not N. Re-checks under the lock by
  comparing the cached token before/after wait to handle the case
  where another coroutine rotated mid-wait.
- Atomic persistence: writes auth.json via tempfile + os.replace with
  mode 0600. The upstream Rust CLI uses truncate-and-overwrite, which
  a concurrent reader can catch mid-write; tempfile+rename is strictly
  safer.
- Terminal error handling: refresh_token_expired/reused/invalidated
  (and any 401 from the refresh endpoint) raise CodexRefreshExpiredError
  with a clear "run codex auth login" remediation, and do not loop.
- No secrets in logs: refresh logs the reason and outcome but not the
  token values themselves.

OAuth request shape (POST https://auth.openai.com/oauth/token, JSON
body with hardcoded client_id app_EMoamEEZ73f0CkXaXp7hrann,
grant_type=refresh_token) matches the upstream Rust CLI exactly. The
endpoint is overridable via the CODEX_REFRESH_TOKEN_URL_OVERRIDE env
var the same way the upstream CLI supports it.

Tests: 23 new in test_codex_oauth_refresh.py covering JWT exp decode,
staleness with skew, refresh_token loading, atomic persistence with
0600 mode, request shape, in-memory + on-disk update, refresh_token
rotation, terminal-error classification, network error wrapping,
no-secrets-in-logs, single-flight under 10 concurrent callers,
proactive refresh before request, reactive 401-then-retry, and the
no-refresh-when-fresh case. Existing test_codex_tool_choice.py still
passes.

Caveat: all tests are mocked. The OAuth request shape has not been
verified against the real auth.openai.com endpoint - it is grounded
in the upstream codex-rs source on github.com/openai/codex.
Reviewers with a ChatGPT Plus subscription should validate the
end-to-end path before merge.
2026-05-19 16:49:03 -04:00
Minghao Xiao 9c161e4e59 fix(api): preserve tag group or triggers (#1655) 2026-05-19 16:35:01 -04:00
Minghao Xiao 9643e66e77 fix(api): lazy load reflect tiktoken encoding (#1654) 2026-05-19 16:34:24 -04:00
Teven Feng c29c76e3fa feat: add opencode-go LLM provider (#1652) 2026-05-19 16:34:11 -04:00
Minghao Xiao c16d9978e8 fix(api): strip Gemma thought tags (#1653) 2026-05-19 16:33:59 -04:00
Ben 943dfee624 docs: add HINDSIGHT_API_WORKER_ID tip to API quickstart (#1617)
* docs: add HINDSIGHT_API_WORKER_ID tip to API quickstart

Mirrors the tip already present in installation.md so users who follow
the API quickstart's Docker tab see the same guidance about pinning a
stable worker ID. Closes #1616.

* docs: mirror WORKER_ID tip to versioned_docs v0.6 (from #1648)

Folding in xmh1011's strict-improvement hunk from #1648: the
versioned snapshot for v0.6 should carry the same production tip
as the live doc. Same prose, same `:::tip` block. Includes the
auto-regenerated skills/ reference.
2026-05-19 16:28:17 -04:00
Ben ab0caa658e docs(changelog): correct openai-agents v0.1.1 entry (#1639)
Replaces the auto-generated entry, which credited #1123 (a core-engine
consolidation config, not openai-agents-specific) to the v0.1.1 release.
The actual openai-agents-specific work in v0.1.1 was #1134 by @DK09876:
docs/test polish — corrected SDK version requirement, added
memory_instructions() to README and API reference, added Production
Patterns section, and added test_config.py.
2026-05-19 16:27:56 -04:00
Chandler 2cb65e09e3 feat(typescript-client): replace Promise<any> with concrete generated types (#1640) 2026-05-19 16:27:14 -04:00
Ben d1903f3c9f blog: What's New in Hindsight Cloud (#1636)
* blog: What's New in Hindsight Cloud — Going Global
2026-05-19 10:50:42 -04:00
Ben 9784f6573a release(openclaw): v0.7.7 2026-05-15 15:11:52 -04:00
Ben f02e037bc7 release(openai-agents): v0.1.1 2026-05-15 14:50:39 -04:00
Ben 87734b3a44 release(litellm): v0.5.3 2026-05-15 14:46:54 -04:00
Ben f613f005a6 release(strands): v0.1.3 2026-05-15 14:22:37 -04:00
Ben 6a5e2d1800 release(claude-code): v0.6.5 2026-05-15 14:21:30 -04:00
Ben 6d495290bc release(paperclip): v0.2.2 2026-05-15 14:12:33 -04:00
Ben f94e840fe8 docs: attribute 0.6.2 release post to benfrank241 (#1635) 2026-05-14 17:07:12 -04:00
Ben 25052a56d0 docs: add 0.6.2 changelog and release blog post (#1633)
Documents the security/maintenance release: dependency CVE bumps,
mental_models.subtype migration repair, embedding-dimension OID
handling, and integration fixes for Claude Code, Agent SDK, CLI,
and Paperclip.
2026-05-14 16:55:48 -04:00
Ben 8b10231b8b Release v0.6.2
- Update version to 0.6.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.6
2026-05-14 16:00:16 -04:00
Ben 5a7996a649 blog: onboarding a new engineer onto five months of OpenCode memory (#1628)
* blog: add OpenCode onboarding use-case post
2026-05-14 14:57:37 -04:00
dependabot[bot] 059d1c3e94 chore(deps): bump the uv group across 3 directories with 8 updates (#1630) 2026-05-14 10:57:24 -04:00
Derek Bouius b20c0d8f67 fix(ci): set UV_FROZEN=1 on verify-generated-files job (#1629)
Set UV_FROZEN=1 as a job-level env var so all uv commands (sync, run,
lock) respect the committed lockfile without re-resolving. This is the
idiomatic uv approach for CI and prevents spurious uv.lock diffs that
blocked every Dependabot PR.

Reverts the lint.sh CI-specific --frozen logic from #1618 since the
env var covers it globally.
2026-05-14 10:24:47 -04:00
Ben debbd91961 fix(migrations): repair mental_models.subtype at current head (#1553) (#1627)
Three production deployments (issue #1553, plus confirmations from
@4Lienau and @khanhduyvt0101) report `column "subtype" of relation
"mental_models" does not exist` on `create_mental_model`, despite their
alembic_version showing the current head `m3rg3h3ad5f6`.

Both h3c4d5e6f7g8_mental_models_v4 (which uses `CREATE TABLE IF NOT EXISTS`
and is a no-op on databases that came through the reflections rename) and
d5y6z7a8b9c0_backfill_mental_models_subtype were meant to ensure the
column exists, but on these specific deployments neither fired
successfully — likely a casualty of the divergent-heads reorganization
that put d5y6z7a8b9c0 on a branch the affected DBs bypassed.

Add a new migration at the current head so every stuck deployment picks
it up on next container start. Idempotent (`ADD COLUMN IF NOT EXISTS`),
guarded by an existence check on the table, and matches the canonical v4
column set and CHECK allowlist from d5y6z7a8b9c0.

PG-only: Oracle's baseline creates mental_models with a different
topology and constraint shape, so this repair does not apply there.
2026-05-14 10:14:48 -04:00
Evo ed35894f55 docs(cli): document --timestamp flag on memory retain (#1622) (#1623)
* docs(cli): document new --timestamp flag on memory retain (#1622)

* docs(cli): mirror --timestamp flag in skills CLI reference
2026-05-14 09:32:09 -04:00
Evo 190c31f543 docs(claude-code): document requestTimeoutSeconds option from #1591 (#1626) 2026-05-14 09:31:11 -04:00
Chris Latimer 5a2c138779 Updated benchmark scores 2026-05-14 05:30:57 -06:00
Ben 51ea9aa286 fix(cli, control-plane): make retain Event Date / timestamp actually reach the API (#1622)
* fix(cli, control-plane): make Event Date / timestamp actually reach the API

- CLI `hindsight memory retain` now accepts `-t/--timestamp <ISO>`. The
  internal MemoryItem.timestamp was hardcoded to None, so retains from the
  CLI lost any caller-supplied event date even though the Python/Node/Go
  SDKs accept one. Add a flag and pass it through; regression test asserts
  --help advertises the option.
- Control plane "Event Date" inputs in the new-document and per-file flows
  used `<input type="datetime-local">`, which only commits a value when the
  user enters both date AND time. Typing a date alone silently left the
  value empty, so `item.timestamp` was never sent and the resulting
  operation payload had no event_date. Switch to `type="date"` and pad
  with `T00:00:00` before sending, so date-only entries reach the API as
  valid ISO datetimes.

* fix(cli): decode --timestamp into MemoryItemTimestamp enum

MemoryItem.timestamp is generated as Option<MemoryItemTimestamp>
(progenitor's anyOf wrapper), not Option<String>. Round-trip the
flag value through serde_json so the right variant is selected for
both ISO datetimes and the 'unset' sentinel. Fixes CI build break.
2026-05-13 16:51:21 -04:00
Ben f9fbfe55c2 fix(docs): use real GitHub handle for ContextForge integration author (#1621)
The `by` field was set to `omarouldali`, which is not a real GitHub user
(github.com/omarouldali returns 404). As a result the avatar request to
`github.com/omarouldali.png?size=40` failed and the integrations hub card
showed a broken-image placeholder next to the author name. The actual
GitHub handle of the contributor (author of PRs #961 and #1254) is
`ooa-andera`, which resolves cleanly.
2026-05-13 15:50:19 -04:00
Derek Bouius 5c7aea4717 fix(ci): use frozen lockfile in lint.sh during CI (#1618)
lint.sh runs `uv sync` without --frozen at the repo root, which
re-resolves uv.lock. In CI's verify-generated-files job this causes
spurious 1-line diffs on every Dependabot PR, blocking them from
merging.

Use --frozen when $CI is set so the lockfile is never modified by
the lint step. Local development keeps the non-frozen sync to handle
version bumps gracefully.
2026-05-13 13:58:48 -04:00
Derek Bouius 9dfbfb4bd0 fix: handle transient OID errors in embedding dimension migration (#1612)
The DO $$ block that drops vector indexes iterates pg_indexes via a
cursor. When concurrent pytest-xdist workers drop schemas (CASCADE),
the OID references in the cursor become stale, causing
'could not open relation with OID' errors.

Fix the root cause in migrations.py by adding EXCEPTION WHEN
internal_error handling to the PL/pgSQL DO block. Also add
defense-in-depth retry logic to the two test cases that previously
called ensure_embedding_dimension() without the retry wrapper.
2026-05-13 13:05:51 -04:00
Evo b593d40fdf fix(agent-sdk): agent_knowledge_get_page request detail=content (sister of #1543) (#1557)
* fix(agent-sdk): agent_knowledge_get_page request detail=content (sister of #1543)

* fix(agent-sdk): flatten throw to single line for prettier (printWidth 100)
2026-05-13 10:16:36 -04:00
Rogerio Saulo 55ef70679c feat(claude-code): expose configurable MCP request timeout (#1591)
Adds requestTimeoutSeconds (env: HINDSIGHT_REQUEST_TIMEOUT_SECONDS) to
the claude-code plugin config. When set, overrides the hardcoded per-call
HTTP timeouts (10s recall, 15s retain, 10-15s in knowledge MCP tools).
When unset (default), per-call defaults are preserved — fully backward
compatible.

The health check timeout (5s) is intentionally left alone, since bumping
it would degrade UX when the server is genuinely unreachable.

Fixes #1575
2026-05-13 10:08:22 -04:00
Derek Bouius fd05bdab51 security: bump litellm to >=1.83.14 in root lockfile (#1610)
Fixes 4 remaining Dependabot alerts (1 critical, 3 high) for litellm
vulnerabilities including GHSA-pq44-5pcq-4r5g and GHSA-8cjq-wjmh-q42r
that were missed in the #1609 squash merge.
2026-05-13 08:54:53 -04:00
Derek Bouius a6cd28a3b5 security: bump remaining high/critical deps across all lockfiles (#1609)
* security: bump remaining high/critical deps across all lockfiles

Root uv.lock:
- GitPython 3.1.45 → 3.1.50 (HIGH: multiple traversal/RCE fixes)
- langchain-core 1.2.23 → 1.4.0 (HIGH: path traversal)
- lxml 6.0.2 → 6.1.0 (HIGH)
- Mako 1.3.10 → 1.3.12 (HIGH)
- pillow 12.1.1 → 12.2.0 (HIGH: OOB write)
- python-multipart 0.0.22 → 0.0.28 (HIGH: arbitrary file write)
- litellm 1.83.0 → 1.83.14 (CRITICAL: multiple CVEs)

Integration lockfiles (ag2, agentcore, agno, autogen, crewai, dify,
langgraph, litellm, llamaindex, openai-agents, smolagents, strands,
pipecat, pydantic-ai, integration-tests):
- urllib3 2.6.3 → 2.7.0
- python-multipart, pillow, GitPython, langchain-core, banks, litellm
  bumped where present

Rust (hindsight-clients/rust):
- openssl 0.10.75 → 0.10.79 (HIGH: multiple CVEs)
- rustls-webpki 0.103.10 → 0.103.13 (HIGH)

crewai pinned to <1.10 — 1.10+ renamed Storage → StorageBackend;
migration tracked separately.

* fix: pin pipecat-ai <1.0 to avoid breaking module restructure

pipecat-ai 1.0+ restructured modules (removed
pipecat.processors.aggregators.openai_llm_context), breaking all tests.
Pin to <1.0 and track migration separately.
2026-05-13 07:40:10 -04:00
Derek Bouius 9533107612 security: bump urllib3 to 2.7.0 in integration lockfiles (#1603)
Fixes remaining Dependabot alerts for urllib3 decompression-bomb bypass
and sensitive header forwarding across 6 integration lockfiles:
strands, smolagents, pydantic-ai, pipecat, openai-agents, llamaindex.
2026-05-12 22:26:24 -04:00
Derek Bouius 26c5028c94 security: bump vulnerable dependencies across npm and pip (#1600) 2026-05-12 18:34:19 -04:00
Derek Bouius 9b8d8b5632 fix(ci): paperclip lint formatting + openclaw hook test expectations (#1601)
- paperclip: commit trailing whitespace and line-length fixes that the
  lint hook produces, fixing verify-generated-files on every PR
- openclaw: update agent_end hook tests to expect the system-role
  context message prepended by includeSenderContext (default: true)
2026-05-12 16:55:17 -04:00
Evo d9dd14995c fix(agent-sdk): rename agent_knowledge_recall max_results to max_tokens; bump default 10 to 1024 (#1552) 2026-05-12 16:03:08 -04:00
Evo 378097ba3d docs(paperclip): align integration guide + README with #1560 lifecycle (#1596)
* docs(paperclip): align integration guide with #1560 lifecycle (issue.comment.created retain)

* docs(paperclip): mirror integration README lifecycle after #1560
2026-05-12 15:23:15 -04:00
EvoandEvo 69703e5a31 docs(strands): document FastAPI lifecycle pattern from #1547 (#1581)
Co-authored-by: Evo <[email protected]>
2026-05-12 15:22:25 -04:00
Ben 771922cd70 docs: add Windows/China deployment guidance for embeddings config (#1549) 2026-05-12 15:22:03 -04:00
Ben 61730d5924 blog: the case against external vector DBs for agent memory (#1594)
* blog: add post on the case against external vector DBs for agent memory
2026-05-12 15:14:30 -04:00
Amir Moradi be908d5b2c fix(paperclip): align with Paperclip's actual event payloads (#1560)
* fix(paperclip): align with Paperclip's actual event payloads

The plugin's `agent.run.started` and `agent.run.finished` handlers
destructured fields (`issueTitle`, `issueDescription`, `output`, `result`)
that Paperclip's host does not publish. Paperclip emits a thin lifecycle
payload — `{runId, agentId, status, invocationSource, triggerDetail,
error, errorCode, issueId, startedAt, finishedAt}` — so both handlers
silently early-returned and the plugin never recalled or retained
anything despite registering successfully.

Changes:

- `agent.run.started` now uses `payload.issueId` to look up the issue
  via `ctx.issues.get` and builds the recall query from the issue's
  title + description.

- New `issue.comment.created` subscription replaces the
  `agent.run.finished` retain path. Comments are the durable record of
  agent + user output and the existing payload only carries a 120-char
  snippet, so we fetch the full body via `ctx.issues.listComments`.
  Bank attribution falls back to the issue's assignee when a comment
  has no agent author (e.g. user comments).

- `agent.run.finished` is kept as a debug no-op so the subscription
  stays visible and can be reused if Paperclip ever embeds output in
  the lifecycle payload.

- Manifest gains `issues.read` and `issue.comments.read` capabilities,
  required by the new SDK calls.

- Tests updated to seed issues/comments via the harness, exercise the
  new comment-created path, and cover the assignee-fallback for
  unauthored comments.

Verified end-to-end against a local Paperclip + self-hosted Hindsight:
the patched plugin retains real comment bodies to the correct bank
and Hindsight's recall API returns them on subsequent queries.

Related: vectorize-io/hindsight tracking issue (Paperclip ODIAA-84).

* Log skip retain due to missing agent attribution

Add logging for skipping retain when no agent attribution is available.

* Add test for skipping retain with no agent and assignee
2026-05-12 10:23:51 -04:00
Ben 2471f01107 blog: add category filter to blog landing page (#1580)
Replaces the Hindsight Cloud preview section with a pill-strip filter
(All / Hindsight Cloud / Deep Dives / Announcements & Releases /
Tutorials & Integrations) that filters the chronological grid by
canonical category tag via a ?cat=<slug> URL param.

Backfills the canonical category tag (release / tutorial / deep-dive)
onto the 49 existing posts that needed one. The hindsight-cloud tag is
already in use and stays unchanged.

Extends BlogTagsPostsPage with friendly titles for the new category
tags so /blog/tags/{release,tutorial,deep-dive} render like the
existing /blog/tags/hindsight-cloud page.

No existing post permalinks or tag-archive URLs change.
2026-05-11 16:07:13 -04:00
Ben 2bfd77477a fix(strands): close internally owned hindsight clients (#1547) 2026-05-11 15:40:25 -04:00
Nicolò Boschi 6aab6c89dc release(claude-code): v0.6.4 2026-05-08 16:54:53 +02:00
Offending Commit 909a4fd400 fix(claude-code-mcp): rename recall max_results→max_tokens (#1544)
The MCP tool exposed `max_results: int = 10` but piped that value
straight into the server's `max_tokens` budget. The server has no
`max_results` concept — recall returns whatever fits in the token
budget — so 10 tokens truncated every recall to an empty result set,
making the tool look like a connection failure even though the bank
contained thousands of nodes.

Rename the parameter to match server semantics and bump the default
to 1024 (same as `client.recall`'s default), so callers can request
deeper recalls by raising the budget honestly.
2026-05-08 16:54:02 +02:00
Ben 4d486b9262 blog: add cover image for How Hindsight Scales (#1545)
* blog: add cover image for How Hindsight Scales
2026-05-08 10:23:35 -04:00
Nicolò Boschi 43c4015afa docs: add 0.6.1 changelog and release blog post (#1542)
* docs: add 0.6.1 changelog and release blog post

* docs(blog): add bank dropdown memory stats section + screenshot
2026-05-08 16:13:43 +02:00
Chris Bartholomew b2a693ab7a fix(claude-code): get_page detail=content + handle tool-result spillover (#1543)
Two related changes addressing the same class of issue PR #1528 fixed
for list_pages — but on the get_page surface and on the agent prompt.

1. agent_knowledge_get_page now requests detail=content instead of
   detail=full. Measured on real banks, reflect_response is 70-95% of
   the response bytes; the actual `content` field is 1-2%. At realistic
   page sizes (200-280 KB at full) the response overflows the MCP host's
   per-tool-result token cap and spills to disk where the agent cannot
   consume it inline. Switching to detail=content drops every page to
   ~5 KB. Sample measurements:

     page                 total    content   reflect_response
     Pre-push gate        276 KB   2.8 KB    201 KB
     Local test stack     282 KB   4.0 KB    205 KB
     CI failure triage    266 KB   2.8 KB    194 KB

   The docstring promises "full synthesized content" — exactly what the
   `content` projection returns.

2. The create-agent SKILL template now tells the agent how to recover
   when get_page does spill (rare after this fix, but possible on
   genuinely large pages): Read the spill file, parse the JSON wrapper,
   or fall back to agent_knowledge_recall.

Adds a focused regression test pinning the content projection.
2026-05-08 16:12:54 +02:00
Nicolò Boschi 0dcd605751 Update 2026-05-08-how-hindsight-scales.md 2026-05-08 15:59:11 +02:00
Nicolò Boschi 838ddb0f30 blog: How Hindsight Scales (#1539)
* blog: add "How Hindsight Scales" technical deep dive

Covers performance, quality, and cost scaling across all 4 core
operations: retain, recall, consolidation, and reflect.

* blog: finalize "How Hindsight Scales" post + blog styling

Architecture-focused scaling analysis covering retain, recall,
consolidation, reflect, and mental models. Fact-checked against
codebase. Also switches blog body font to Space Grotesk and adds
colored underline treatment for bold text.
2026-05-08 15:48:14 +02:00
Nicolò Boschi 0f7c5e895b Release v0.6.1
- Update version to 0.6.1 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.6
2026-05-08 15:00:38 +02:00
Nicolò Boschi 98f33cbf6c feat(api): add litellmrouter provider for LLM fallback chains (#1537)
* feat(api): add litellmrouter provider for LLM fallback chains

Closes #1464.

New "litellmrouter" provider wraps LiteLLM Router with ordered fallback
across a configurable chain of deployments. On transient errors
(rate-limit, timeout, 5xx) the Router falls back to the next deployment
in declared order; auth errors (401/403) are not retried so a
misconfigured key cannot silently cascade through the chain.

Configuration is provider-scoped (one-word LITELLMROUTER namespace to
avoid clashing with the existing LITELLM_* settings used by the
embeddings/reranker layers):

  HINDSIGHT_API_LLM_PROVIDER=litellmrouter
  HINDSIGHT_API_LLM_LITELLMROUTER_CHAIN=<json list of deployments>

Per-operation chains are supported via the same pattern that already
exists for retain/reflect/consolidation:

  HINDSIGHT_API_RETAIN_LLM_LITELLMROUTER_CHAIN=...
  HINDSIGHT_API_REFLECT_LLM_LITELLMROUTER_CHAIN=...
  HINDSIGHT_API_CONSOLIDATION_LLM_LITELLMROUTER_CHAIN=...

Each per-op chain falls back to the default chain when unset, mirroring
the existing per-op provider/model overrides.

Chain entries are tagged as credential fields and are never exposed via
the bank-config API. Batch APIs are intentionally unsupported in router
mode; users that need batch retain should configure a single provider.

* refactor(api): dedup litellmrouter on top of LiteLLMLLM, accept arbitrary chain keys, add CI matrix entry

The retry/parse/metrics loop in LiteLLMRouterLLM was a near-verbatim copy of
LiteLLMLLM. Extract three small hooks on the base class
(_acompletion, _resolve_completion_model, _stage_label) and have the Router
provider inherit + override only what differs.

Drop strict validation of chain entries. The parser now requires only
'provider' and 'model'; everything else passes through to LiteLLM Router
unchanged. Top-level keys (rpm, tpm, weight, model_info, ...) flow to the
deployment record; an optional 'litellm_params' sub-object merges into the
inner params dict. Documented and tested.

Add a litellmrouter row to the LLM acceptance matrix using a single OpenAI
deployment in the chain. The chain JSON is built from secrets in a
dedicated step and masked in logs before being written to GITHUB_ENV.

* refactor(api): pure pass-through to litellm.Router, drop translation layer

Replace the chain-with-Hindsight-shape API with a thin pass-through to
litellm.Router. The HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG env var is now
a JSON object forwarded verbatim to Router(**config). Hindsight's only
imposed rules: model_list is non-empty, each entry has a model_name, and
requests route against the first entry's model_name.

This removes _LITELLM_PROVIDER_PREFIX (provider→prefix translation),
_build_model_list (flat→nested rewrite), and _build_fallbacks (auto-wired
ordered fallback). Users now write LiteLLM-native configs and pick their
own routing strategy — ordered fallback via 'fallbacks', load-balancing
via shared model_name + 'routing_strategy', rate-limit awareness via rpm/
tpm, and so on. The docs link to LiteLLM's reference rather than
recapitulating it.

Renames:
  ENV_LLM_LITELLMROUTER_CHAIN  -> ENV_LLM_LITELLMROUTER_CONFIG
  llm_litellmrouter_chain      -> llm_litellmrouter_config
  _parse_llm_router_chain      -> _parse_llm_router_config
  LLMProvider(litellmrouter_chain=) -> LLMProvider(litellmrouter_config=)

The dataclass fields change shape from list[dict] to dict (JSON object).

Net reduction across the touched files: ~165 lines.

* docs: regenerate hindsight-docs skill from updated configuration.md

* refactor(api): drop all shape validation on litellmrouter config, use fixed 'default' entrypoint

The previous version still inspected the user's config in two places:
the parser checked model_list/model_name shape, and __init__ pulled
primary_model_name out of model_list[0]. Both are gone.

The parser now only verifies the env var is parseable JSON. Whatever the
user supplies — dict, list, missing keys, weird shapes — flows through.
LiteLLM Router is authoritative about the shape and raises its own
errors at construction time if something's wrong.

The provider no longer extracts a 'primary' name from the input. Instead
it always issues completions against model_name='default' — the single
Hindsight-imposed convention. Users put one entry with that name in
their model_list as the entrypoint and use any names they want for
fallback/load-balance/weighted-pool members. This avoids both pre-
validation footguns and any dependence on Router's internal API
(model_names, model_list attributes) that could shift between versions.

Docs and tests updated to match. The CI matrix already used 'default'.

* docs: regenerate hindsight-docs skill

* ci(test): cap retain max_completion_tokens for litellmrouter matrix row

gpt-4.1-nano caps OpenAI completion at 32768 tokens, but Hindsight's
default DEFAULT_RETAIN_MAX_COMPLETION_TOKENS is 64000. The 'openai'
matrix row passes because OpenAICompatibleLLM has model-specific token
capping; LiteLLMLLM (and the new LiteLLMRouterLLM by inheritance) don't.
That's a pre-existing limitation orthogonal to this PR — the cap-aware
behaviour lives in OpenAICompatibleLLM and intentionally doesn't apply
to LiteLLM-routed calls.

Lower retain max_completion_tokens via env in the litellmrouter job so
CI exercises the Router path end-to-end instead of dying on a
provider-side BadRequestError that's not the thing we're testing.

* fix(api): cap LiteLLM-routed max_completion_tokens to model registry limit

Hindsight defaults retain_max_completion_tokens to 64000 — fine for
high-capacity models, but breaks against models with smaller caps
(gpt-4.1-nano: 32768; gpt-4o-mini: 16384). OpenAICompatibleLLM already
caps via a hardcoded string-match table; LiteLLMLLM and the new Router
provider didn't, so a default Hindsight install pointed at a small
model would fail with provider BadRequestError.

Cap pre-emptively using LiteLLM's own per-model registry
(litellm.get_max_tokens). For LiteLLMLLM the cap is self.model. For
LiteLLMRouterLLM the cap is the min across all configured deployments,
computed once at __init__ — this way a single max_completion_tokens
value works no matter which deployment Router picks (primary,
fallback, weighted-pool member). Unknown models contribute no cap.

Reverts the temporary CI workaround that lowered HINDSIGHT_API_RETAIN_
MAX_COMPLETION_TOKENS=32000 for the litellmrouter row — Hindsight
should work out of the box.

* docs: shorten litellmrouter config section, add models.mdx pointer

Move the discoverability pointer into models.mdx alongside the existing
LiteLLM tip, where users browsing for model options will find it. Strip
the configuration page entry to its essentials: env-var table, one
ordered-fallback example, and the three short caveats. Defer routing
details to LiteLLM's docs rather than recapitulating them.
2026-05-08 14:50:18 +02:00
Nicolò Boschi ab8cc3e605 fix(typescript-client): derive CLIENT_VERSION via tsup define (#1540)
The hardcoded `CLIENT_VERSION = "0.5.1"` in src/index.ts has fallen
behind npm releases through 0.5.6 / 0.5.7 / 0.6.0 — every published
release since 0.5.1 ships a stale constant, mis-attributing User-Agent
in server-side telemetry and foreclosing client-side feature gating.

Substitute `__CLIENT_VERSION__` with `pkg.version` via tsup's `define`
at build time. Source has no JSON import, so the fix is uniform across
runtimes (Node CJS/ESM, Deno via npm:, Deno via raw src) — unlike a
direct `import pkg from "../package.json"`, which Deno rejects without
`with { type: "json" }`, and which would in turn cascade into tsconfig
+ ts-jest reconfiguration (see #1535 for that path).

A `typeof` guard with a `0.0.0-dev` sentinel keeps raw-source loads
(jest, `npm run test:deno`) from throwing ReferenceError when the
build-time substitution hasn't run.

Verified locally: build, jest 6/6, Node CJS/ESM, Deno (dist), Deno
(raw src) all report the substituted version (or the dev sentinel
where appropriate). dist no longer inlines the full package.json
(devDependencies, scripts, repository url) — only the version string.

Closes #1535.
2026-05-08 12:51:12 +02:00
Nicolò Boschi da72f5da44 perf(locomo): scope CI to a 3-conversation curated subset (#1536)
The scheduled LoComo job has been failing on most recent runs with
``TimeoutError: Consolidation did not complete within 3000.0s`` from
``benchmark_runner._wait_for_consolidation``. The offender is
``locomo_conv-44``, the largest bank in the dataset (463 unconsolidated
items at ingestion peak), whose per-bank consolidation regularly grazes
or exceeds the hardcoded 50-minute wait budget under CI load. Because
``Publish LoComo to dashboard`` is gated on ``success()``, every such
failure also drops the entire run from the dashboard, so no LoComo
metrics have been published since the dashboard was set up.

Rather than chase the timeout up, narrow what the scheduled run
exercises. Pick three conversations that bracket accuracy on the last
clean full run (May 5):

- ``conv-26`` — best (90.79%)
- ``conv-30`` — middle (86.42%)
- ``conv-43`` — worst (82.02%)

This deliberately omits ``conv-44``: it sits at median accuracy but
carries the largest unconsolidated set in the dataset, and the goal here
is to keep the trend signal (best/median/worst spread, ingest+recall
behavior) without dragging in the bank that has been blowing the
per-bank timeout.

To plumb this through:

- ``--conversation`` becomes ``nargs="+"`` so it accepts a list of IDs
  (single-ID form still works). Help text and runner docstring updated.
- ``BenchmarkRunner.run`` widens ``specific_item`` to
  ``str | Iterable[str]`` and filters via set membership; longmemeval's
  single-string usage is unaffected.
- The workflow swaps ``locomo_max_conversations`` for
  ``locomo_conversations``: a space-separated string of IDs that
  defaults to the curated set but can be overridden at
  ``workflow_dispatch`` time.

Lint clean (``./scripts/hooks/lint.sh``); argparse ``--help`` verified.
2026-05-08 11:45:23 +02:00
Nicolò Boschi 48295b06e0 chore: fix formatting in llm_wrapper.py to pass verify-generated-files (#1534)
* chore: fix formatting in llm_wrapper.py to pass verify-generated-files

* chore: format n8n and openclaw files to pass verify-generated-files

* fix(openclaw): add missing includeSenderContext to plugin configSchema and uiHints
2026-05-08 11:12:35 +02:00
Nicolò Boschi 3f08b115f8 docs(zai): document z.ai provider and add default model (#1532)
* docs(zai): document z.ai provider and add default model

Follow-up to #1529. Adds z.ai (Zhipu GLM series) to the provider list,
example blocks, default-model table, and `.env.example`. Also wires
`zai` into `PROVIDER_DEFAULT_MODELS` so the new docs entry actually
matches what the engine resolves when only the provider is set.

* docs(zai): use glm-4.5-flash as default (free tier)

glm-4.5-air requires a paid balance on z.ai; flash is on the free
tier and works as a sensible default. Air is still listed in the
example as the paid-tier upgrade.
2026-05-08 10:50:40 +02:00
Nicolò Boschi b628716f15 fix(cp): improve access-key auth UX and harden middleware (#1533)
* fix(cp): improve access-key auth UX and harden middleware

- Move logout button from sidebar to header bar (next to GitHub icon),
  shown only when access-key auth is configured
- Remove redundant status bar from dashboard page
- Return 401 JSON for unauthenticated API requests instead of HTML redirect
- Redirect to /login on 401 in the API client (skip if already on /login)
- Allow /logo.png through middleware for the login page
- Replace brain emoji with Hindsight logo on login page
- Fix error message visibility in dark mode
- Add loading spinner for bank selector while banks are fetching
- Expose access_key_auth as a feature flag via version endpoint
- Document HINDSIGHT_CP_ACCESS_KEY in configuration and installation docs

* fix(cp): spread default features to handle unknown fields from API

* fix(cp): wrap login page in Suspense for useSearchParams
2026-05-08 10:40:50 +02:00
Rodolfo Hansen be696b0d38 feat(openclaw): prepend session-context block to retained transcripts (#1439)
When `dynamicBankGranularity` does not include `"user"`, every speaker
in an agent's bank ends up indistinguishable in similarity search --
memories from John look the same as memories from Peter, so recall can
mix them up. Bumping granularity to per-user is one fix, but it forces
fragmented banks and forfeits cross-user shared context (e.g. for an
ops/sprint-driver bot).

Add an opt-out `includeSenderContext` flag (default true) and a new
optional `sessionContext` parameter to `prepareRetentionTranscript`.
When provided, a small `[context] sender / channel / provider [/context]`
block is prepended to the transcript -- as a system-role message in the
JSON formats, or as a literal text block in the legacy text format.

That single header gives vector recall a strong, model-agnostic signal
to attribute and disambiguate memories without changing the bank
scheme. Filtered providers and missing fields collapse cleanly to null,
so the change is invisible when there's nothing useful to say.

Tests cover both formats, opt-out, missing-fields fallback, and the
no-context default.
2026-05-08 10:11:47 +02:00
Burgunthy 4c75cd9e37 feat: add z.ai (智谱) as first-class LLM provider (#1529)
Add z.ai (https://api.z.ai) as a supported provider in OpenAICompatibleLLM,
following the same pattern as deepseek, minimax, and openrouter.

Changes:
- openai_compatible_llm.py: add zai to valid_providers, base_url, api_key validation
- llm_wrapper.py: add zai to create_llm_provider routing, LLMConfig

Verified: retain (3276 in / 922 out tokens) + recall working with glm-4.5-air
2026-05-08 10:00:59 +02:00
Ariel AI c0ff87ea10 feat(cp): add optional access-key login for Control Plane (#1530)
Add HINDSIGHT_CP_ACCESS_KEY env var to enable a lightweight
shared-secret authentication gate for the Control Plane UI.

Features:
- Login page at /login with access key input form
- /api/auth/login endpoint validates key and sets HttpOnly session cookie
- /api/auth/logout endpoint clears session cookie
- Middleware protects all routes except /login, /api/auth/*, /api/health,
  /api/version, static assets, and _next
- returnTo query param preserves redirect after login
- Constant-time comparison for access key to prevent timing attacks
- Logout button in sidebar (when a bank is selected) and dashboard header
- Updated .env.example and docker-compose docs

Security:
- HttpOnly, SameSite=lax, Secure (production only) cookie
- 24-hour session lifetime
- Constant-time string comparison to prevent timing attacks
2026-05-08 09:37:30 +02:00
Chris Bartholomew 6c6ee73c56 fix(claude-code): use detail=metadata for agent_knowledge_list_pages (#1528)
agent_knowledge_list_pages was hitting GET /mental-models with no detail
parameter, so the API returned its default (detail=full) — synthesized
content + reflect_response for every page in the bank. On a bank with
many pages this produces a single JSON-RPC response that exceeds the
Claude Code MCP client's 16 MB without-newline-boundary buffer ceiling
and triggers a deterministic disconnect.

Reproduced locally driving the MCP server end-to-end:
  unpatched: 20,054,285 bytes in one JSON-RPC message → disconnect
  patched:   44,987 bytes, two messages → clean

The tool's docstring already promises "IDs and names only" — this aligns
the wire call with the documented contract. Agents that need the
synthesized content already use agent_knowledge_get_page, which keeps
detail=full and is unaffected.

Adds a focused regression test pinning the metadata projection.
2026-05-08 09:36:04 +02:00
Chris Bartholomew 7b82d05b77 fix(worker): propagate child error_message to failed batch_retain parent (#1527)
When a batch_retain parent transitions to 'failed' because at least one
child sub-batch failed, the parent's error_message was hardcoded to the
generic string "One or more sub-batches failed". Any consumer that
classifies failures by error_message (dashboards, alert filters, log
aggregators) loses signal once a batch grows children -- a class of
failures that all share the same root reason at the child level becomes
indistinguishable at the parent level.

Pull error_message in the siblings query and pick the most-common
non-empty failed-child message as the parent's error_message. When all
siblings failed for the same reason (the common case) the parent
inherits that reason verbatim; when reasons vary the most-common one is
still a useful representative. Falls back to the legacy generic string
only when no failed sibling carries an error_message at all, preserving
backward compat for that edge case.

Same change applied to both the worker poller's fallback path and the
memory engine's in-transaction path so the propagation behavior is
consistent regardless of which surface finalises the parent.

6 new unit tests for the helper plus an inheritance assertion added to
the existing integration test.
2026-05-08 09:35:38 +02:00
Chris f2a2f9fe40 fix(reflect): read document metadata from retain params (#1523) 2026-05-08 09:32:43 +02:00
Ben 8c6be6de6c blog: n8n Workflows Are Stateless. Hindsight Makes Them Compound. (#1511)
* blog: n8n Workflows Are Stateless. Hindsight Makes Them Compound.
2026-05-07 13:10:10 -04:00
Nicolò Boschi 8d77976ae9 fix(daemon): replace os.fork() with subprocess.Popen to fix MPS on macOS (#1519)
On macOS, os.fork() without exec() corrupts Apple framework state
(XPC, Metal/MPS, ObjC runtime). The daemon's double-fork pattern
caused SIGBUS crashes when PyTorch auto-selected the MPS backend
for local embeddings/reranker models.

Replace the double-fork in daemonize() with subprocess.Popen
(which uses posix_spawn on macOS), giving the daemon a clean
process where MPS works correctly. The re-exec'd child is
identified by the _HINDSIGHT_DAEMON_CHILD env var.

This also removes the macOS FORCE_CPU workaround from
hindsight-embed, since MPS now works natively in daemon mode.

Fixes #270, #1394, #1497
2026-05-07 18:58:34 +02:00
Nicolò Boschi 312bde1b4d docs: surface stable worker_id guidance and zombie-operation recovery (#1522)
* docs: surface stable worker_id guidance and zombie-operation recovery

Worker identity defaults to the container hostname, which Docker rotates
on every restart. That stranded several real deployments' consolidation
queues (issue #1470 and the related closed tickets #991 / #696 / #624).
Move the guidance from the configuration reference table — where it
only gets read after the bug bites — into the install path and add a
recovery section next to the decommission commands.

* docs(faq): add zombie-operations entry
2026-05-07 18:31:22 +02:00
Nicolò Boschi a22e8bdd22 fix(engine): remove multiplicative retry layers in fact extraction (#1412) (#1516)
Structured-output extraction had three nested retry loops that
multiplied on deterministic failures, burning up to 36 LLM calls
per chunk (inner 4 × middle 3 × outer 3).

- Remove outermost _extract_chunk_with_retry wrapper: its broad
  except-Exception added a 3× multiplier on top of already-bounded
  inner retries.
- Remove json_validate_failed retry from middle layer: the inner
  provider loop already retries 400 errors; re-entering the full
  LLM call for the same schema failure is wasted quota.
- Fix claude_code_llm.py: ValidationError was caught by a broad
  except-Exception and retried instead of raising immediately.
  Same input produces the same schema-violating output.
2026-05-07 18:26:18 +02:00
Nicolò Boschi 4088af369a fix(openclaw): backfill plugins.allow with hindsight-openclaw in setup wizard (#1521)
OpenClaw 2026.2.19+ logs a startup WARN whenever `plugins.allow` is
empty and non-bundled plugins are discovered:

  [plugins] plugins.allow is empty; discovered non-bundled plugins
            may auto-load: hindsight-openclaw (...). Set plugins.allow
            to explicit trusted ids.

Cosmetic — the plugin still loads — but the warning fires on every
gateway start and is the kind of noise users justifiably ask about.

`ensurePluginConfig` now adds `hindsight-openclaw` to `plugins.allow`
so the warning goes away. Conservative wrt user-curated lists:

- Undefined → set to `["hindsight-openclaw"]`.
- Existing array → append our id only when missing (idempotent).
- Existing array already containing our id → no-op.
- Non-array value (deliberate weirdness) → leave alone.

Four regression tests cover all four cases.
2026-05-07 18:18:52 +02:00
Nicolò Boschi be53972ab2 release(claude-code): v0.6.3 2026-05-07 18:14:38 +02:00
Nicolò Boschi 691c65acf8 feat(claude-code): resolve git worktrees + explicit directory-bank mapping (#1520)
* feat(claude-code): resolve git worktrees + explicit directory→bank mapping

Adds two new bank-resolution features so that working in a git worktree
or across multiple project directories doesn't accidentally fragment
memory across separate banks.

- resolveWorktrees (default true): detects git worktrees via
  `git rev-parse --git-common-dir` and resolves the project field to the
  main repository basename, so all worktrees of the same repo share one
  bank. Falls back to cwd basename if git is unavailable.
- directoryBankMap: explicit cwd → bankId mapping that takes priority
  over both static and dynamic modes, for users who want full control.

20 new tests cover worktree resolution, directory mapping, prefix
interaction, and graceful fallback paths.

* docs(claude-code): declare resolveWorktrees + directoryBankMap settings

Add the two new bank-resolution fields to the plugin's settings.json so
they show up in the canonical defaults, and document them in the
integration docs (Memory Bank table + a "Worktrees and explicit
mapping" subsection with a config example).
2026-05-07 18:14:04 +02:00
Nicolò Boschi bfa3115579 release(openclaw): v0.7.6 2026-05-07 17:48:43 +02:00
Nicolò Boschi ef600683c9 fix(openclaw): reuse existing token + URL when re-running setup wizard (#1518)
The wizard re-prompted for the API token / API key on every run even
when one was already stored in openclaw.json — confusing for users
(re-typing a long secret) and wasteful when running setup just to
backfill new fields like hooks.allowConversationAccess.

Now: if pluginConfig has an inline string secret (cloud token, api
token, llm api key), the wizard offers to reuse it (showing the last
4 chars masked, e.g. "Reuse the existing token (ends in …***1234)?").
Saying yes keeps the existing secret; saying no falls back to the
masked password prompt as before. SecretRef objects (env-var refs)
aren't pasteable so they keep the previous prompt path.

URL handling tightened up too:
- Cloud: prompt label adapts ("Reuse the configured Cloud URL X?" vs
  "Use the default Hindsight Cloud URL?") and reuses the existing URL
  on confirm.
- API: text prompt seeded with the existing URL via initialValue so
  the user can just press enter.
- API token confirm now defaults to "yes, needs token" when one is
  already configured, instead of always defaulting to no.

Adds a pure maskSecret helper in setup-lib.ts (testable without a
TTY) and three regression tests covering long token / very-short
input / surrounding whitespace.
2026-05-07 17:48:17 +02:00
Nicolò Boschi a34c0e7be3 release(openclaw): v0.7.5 2026-05-07 17:02:53 +02:00
Nicolò Boschi abf8487248 fix(openclaw): write hooks.allowConversationAccess in setup wizard (#1514)
* fix(openclaw): write hooks.allowConversationAccess in setup wizard

OpenClaw 2026.4.24 added a security gate (#71221) that silently drops
"conversation hooks" — including `agent_end`, which the plugin uses
to retain the transcript on every turn — for non-bundled plugins
unless `plugins.entries.<id>.hooks.allowConversationAccess` is
explicitly set to `true` in user config.

Symptom: openclaw logs `typed hook "agent_end" blocked because
non-bundled plugins must set ... allowConversationAccess=true`, the
plugin appears registered, retain count stays at 0, banks stay empty.
Affects every user on openclaw ≥ 2026.4.24 who installed via the
standard `hindsight-openclaw-setup` flow.

Fix: ensurePluginConfig (the helper every wizard mode calls before
saveConfig) now backfills `hooks.allowConversationAccess: true` when
the field is unset. Idempotent — re-running the wizard fixes existing
configs that pre-date the gate. We never override an explicit `false`,
since that's a deliberate user override.

Also extends the PluginEntry shape to include `hooks` and adds four
regression tests covering fresh, backfill, explicit-false, and
foreign-hooks-key cases.

* fix(openclaw): declare contracts.tools in plugin manifest

OpenClaw 2026.5.x added a second gate (loader.js:1448-1455): when a
plugin calls api.registerTool, the loader checks `record.contracts.tools`
(populated from the plugin manifest's `contracts.tools` array). If the
manifest doesn't declare the tool names, openclaw logs:

  ERROR [plugins] plugin must declare contracts.tools before registering
        agent tools (plugin=hindsight-openclaw, ...)

…and the registerTool call no-ops. Result on 2026.5.x: even with
enableKnowledgeTools=true, none of the agent_knowledge_* tools are
exposed to agents.

Fix: declare the seven agent_knowledge_* names in
openclaw.plugin.json's `contracts.tools` array so openclaw recognises
them at manifest-load time. Pure manifest change — runtime behavior is
still gated by `enableKnowledgeTools` in user config; this just lets
openclaw allow the registration when the runtime flag is on.

Verified locally on openclaw 2026.5.6 with the patched manifest copied
into the installed extension dir + a fresh gateway start: log goes
from "knowledge tools registered" + ERROR plugin-must-declare-contracts
→ "knowledge tools registered" with no error.

This is a pure manifest update — no code changes, no test changes
required.
2026-05-07 17:02:28 +02:00
Nicolò Boschi 5d867826a1 release(n8n): v0.1.3 2026-05-07 16:57:57 +02:00
BenandClaude Opus 4.7 e18ab10ac4 fix(n8n): drop hindsight-client runtime dep, inline HTTP calls (#1513)
* fix(n8n): drop hindsight-client runtime dep, inline HTTP calls

n8n's verified-node review (`npx @n8n/scan-community-package
@vectorize-io/[email protected]`) auto-rejects packages with
runtime dependencies via @n8n/community-nodes/no-restricted-imports.
The Hindsight node imported @vectorize-io/hindsight-client, which
triggered the rule.

Replaces the SDK calls with direct HTTP via n8n's built-in
`requestWithAuthentication` helper. The Bearer header is applied
automatically from the existing IAuthenticateGeneric credential — no
credential changes needed.

Endpoints used (verified against the SDK source we removed):
- Retain:  POST {apiUrl}/v1/default/banks/{bank_id}/memories
- Recall:  POST {apiUrl}/v1/default/banks/{bank_id}/memories/recall
- Reflect: POST {apiUrl}/v1/default/banks/{bank_id}/reflect

Body shapes match HindsightClient.retain/recall/reflect line-for-line
so server-side behavior is unchanged.

Test changes:
- Swapped the vi.mock() of @vectorize-io/hindsight-client for a mock
  of helpers.requestWithAuthentication on IExecuteFunctions
- All 22 tests still pass (8 in node-execute, 14 elsewhere)
- Added a new test asserting trailing-slash apiUrl is stripped before
  URL concatenation

Package changes:
- Drop @vectorize-io/hindsight-client from dependencies
- Bump 0.1.2 → 0.1.3

After this lands, run ./scripts/release-integration.sh n8n 0.1.3 to
publish 0.1.3 with provenance, then re-run the scan and submit at
creators.n8n.io.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(n8n): use httpRequestWithAuthentication (deprecated rename)

n8n's @n8n/community-nodes ESLint plugin flags requestWithAuthentication
as deprecated in favor of httpRequestWithAuthentication. Caught by
running the full plugin ruleset locally against the dist before publish:

  no-deprecated-workflow-functions errors in Hindsight.node.js at
  lines 217, 241, 258 (the three operation HTTP calls)

Same signature, same auth behavior — just the modern helper name.
After this rename, all 25 community-nodes lint rules pass clean.

All 22 vitest tests still pass with the helper rename.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* chore(n8n): leave version at 0.1.2 — release pipeline owns the bump

Per Nicolo: the release-integration tooling owns version bumps. This
PR should ship the code change only (drop hindsight-client dep, switch
to httpRequestWithAuthentication, retarget tests). Version 0.1.2 →
0.1.3 will happen automatically when release-integration.sh runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* chore(n8n): match main's package-lock.json version field

main's package-lock.json has version "0.1.0" (out of sync with
package.json's "0.1.2", but that's the state on main). The previous
revert overshot to "0.1.2" — restoring to "0.1.0" so the lockfile
diff vs main no longer touches the version field.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-07 16:56:39 +02:00
Nicolò Boschi 39d31ad259 perf(worker): scope progress-stats fanout to schemas with pending work (#1509)
The progress logger (_log_progress_if_due) previously ran two heavy
COUNT/GROUP BY queries against every tenant schema on every stats cycle
(every 30s). With N tenants and W workers that's 2*N*W queries per cycle.

Reuse _scan_active_schemas() — which already calls the optional
schemas_with_pending_work() routine when installed (O(1) marker-table
read) or falls back to per-schema EXISTS checks — to pre-filter schemas
before the expensive breakdown queries. Union with schemas that have
locally-tracked in-flight tasks so processing worker counts stay accurate.

Also wraps per-schema queries in try/except for partially-provisioned
tenants and caps the schema list in log output to 20 entries.
2026-05-07 16:26:48 +02:00
536 changed files with 23163 additions and 16509 deletions
+32 -2
View File
@@ -2,7 +2,7 @@
# Copy this file to .env and fill in your values
# LLM Configuration (Required)
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio, vertexai, minimax, deepseek, volcano
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio, vertexai, minimax, deepseek, zai, volcano
HINDSIGHT_API_LLM_PROVIDER=openai
HINDSIGHT_API_LLM_API_KEY=your-api-key-here
HINDSIGHT_API_LLM_MODEL=gpt-4o-mini
@@ -30,6 +30,11 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# HINDSIGHT_API_LLM_API_KEY=your-deepseek-api-key
# HINDSIGHT_API_LLM_MODEL=deepseek-v4-flash # or deepseek-v4-pro / deepseek-chat / deepseek-reasoner
# Example: z.ai configuration (Zhipu GLM series, https://z.ai)
# HINDSIGHT_API_LLM_PROVIDER=zai
# HINDSIGHT_API_LLM_API_KEY=your-zai-api-key
# HINDSIGHT_API_LLM_MODEL=glm-4.5-flash # or glm-4.5-air for the paid tier
# Example: LM Studio local configuration (Qwen 2.5 32B recommended)
# HINDSIGHT_API_LLM_PROVIDER=lmstudio
# HINDSIGHT_API_LLM_API_KEY=lmstudio
@@ -60,12 +65,25 @@ HINDSIGHT_API_LOG_LEVEL=info
# HINDSIGHT_API_VECTOR_EXTENSION=pgvectorscale # Auto-detects pg_diskann on Azure
# Embeddings Configuration (Optional - uses local by default)
# Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference)
# Provider: "local" (default), "tei", "openai", "cohere", "google", "openrouter", "litellm", or "litellm-sdk"
# HINDSIGHT_API_EMBEDDINGS_PROVIDER=local
# For local provider:
# HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=BAAI/bge-small-en-v1.5
# Optional for China network / restricted HF access:
# HF_ENDPOINT=https://hf-mirror.com
# For TEI provider:
# HINDSIGHT_API_EMBEDDINGS_TEI_URL=http://localhost:8080
# For OpenAI-compatible embeddings:
# HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=sk-xxxx
# HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL=text-embedding-3-small
# HINDSIGHT_API_EMBEDDINGS_OPENAI_BASE_URL=https://api.openai.com/v1
#
# IMPORTANT: Embedding keys require provider-specific names:
# HINDSIGHT_API_EMBEDDINGS_{PROVIDER}_{PARAMETER}
# (for example, HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL).
#
# DeepSeek note: DeepSeek is supported for LLM calls, but not for embeddings.
# If using DeepSeek as LLM provider, keep embeddings on local/openai/cohere/google/etc.
# Reranker Configuration (Optional - uses local by default)
# Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference)
@@ -89,3 +107,15 @@ HINDSIGHT_API_LOG_LEVEL=info
# Custom service name and environment (optional, defaults: hindsight-api, development)
# HINDSIGHT_API_OTEL_SERVICE_NAME=hindsight-production
# HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT=production
# -----------------------------------------------------------------------------
# Control Plane (Optional)
# -----------------------------------------------------------------------------
# Dataplane API URL - where the CP proxies requests to
# HINDSIGHT_CP_DATAPLANE_API_URL=http://localhost:8888
# Optional: Require a shared access key to view the Control Plane UI.
# When set, visitors see a login page and must enter the key before
# accessing the dashboard or any /api/* routes (except /api/health).
# HINDSIGHT_CP_ACCESS_KEY=your-shared-secret-key
+14 -8
View File
@@ -25,10 +25,10 @@ on:
- recall-with-observations
- consolidation
default: ""
locomo_max_conversations:
description: "LoComo max conversations (0 = skip, blank = all)"
type: number
default: 0
locomo_conversations:
description: "LoComo conversation IDs (space-separated). Blank = curated set (conv-26 conv-30 conv-43)."
type: string
default: ""
locomo_skip:
description: "Skip LoComo job"
type: boolean
@@ -169,14 +169,20 @@ jobs:
cd hindsight-dev && uv sync --frozen --all-extras --index-strategy unsafe-best-match
- name: Run LoComo benchmark
# Curated 3-conversation subset (best/middle/worst by accuracy on the
# last successful full run): conv-26 (best), conv-30 (middle), conv-43
# (worst). Excludes conv-44, the bank with the largest unconsolidated
# set that has been pushing scheduled runs over the per-bank
# _wait_for_consolidation timeout. Override via workflow_dispatch with
# the locomo_conversations input.
run: |
MAX_CONV_ARG=""
if [ "${{ inputs.locomo_max_conversations }}" != "0" ] && [ -n "${{ inputs.locomo_max_conversations }}" ]; then
MAX_CONV_ARG="--max-conversations ${{ inputs.locomo_max_conversations }}"
CONVERSATIONS="${{ inputs.locomo_conversations }}"
if [ -z "$CONVERSATIONS" ]; then
CONVERSATIONS="conv-26 conv-30 conv-43"
fi
uv run python hindsight-dev/benchmarks/locomo/locomo_benchmark.py \
--wait-consolidation \
$MAX_CONV_ARG
--conversation $CONVERSATIONS
- name: Upload LoComo results
if: always()
+19
View File
@@ -1130,6 +1130,10 @@ jobs:
api_key_secret: GROQ_API_KEY
- provider: bedrock
model: us.amazon.nova-2-lite-v1:0
- provider: litellmrouter
model: gpt-4.1-nano
# Single-deployment chain over OpenAI — verifies the Router-backed
# call path works end-to-end. Built from secrets in the step below.
name: LLM acceptance (${{ matrix.provider }}/${{ matrix.model }})
env:
HINDSIGHT_API_LLM_PROVIDER: ${{ matrix.provider }}
@@ -1187,6 +1191,19 @@ jobs:
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
"
- name: Build litellmrouter config
if: matrix.provider == 'litellmrouter'
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ROUTER_MODEL: ${{ matrix.model }}
run: |
cfg=$(jq -nc \
--arg model "openai/$ROUTER_MODEL" \
--arg key "$OPENAI_API_KEY" \
'{model_list: [{model_name: "default", litellm_params: {model: $model, api_key: $key}}]}')
echo "::add-mask::$cfg"
echo "HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG=$cfg" >> "$GITHUB_ENV"
- name: Run LLM acceptance tests
working-directory: ./hindsight-api-slim
run: uv run pytest tests -v -m "hs_llm_mat" --timeout 600
@@ -3446,6 +3463,8 @@ jobs:
verify-generated-files:
runs-on: ubuntu-latest
env:
UV_FROZEN: "1"
steps:
- uses: actions/checkout@v6
with:
+1 -1
View File
@@ -30,7 +30,7 @@ It eliminates the shortcomings of alternative techniques such as RAG and knowled
Hindsight is the most accurate agent memory system ever tested according to benchmark performance. It has achieved state-of-the-art performance on the LongMemEval benchmark, widely used to assess memory system performance across a variety of conversational AI scenarios. The current reported performance of Hindsight and other agent memory solutions as of January 2026 is shown here:
![Overview](./hindsight-docs/static/img/hindsight-bench.jpg)
![Overview](./hindsight-docs/static/img/hindsight-benchmarks.png)
The benchmark performance data for Hindsight has been independently reproduced by research collaborators at the Virginia Tech [Sanghani Center for Artificial Intelligence and Data Analytics](https://sanghani.cs.vt.edu/) and The Washington Post. Other scores are self-reported by software vendors.
@@ -51,6 +51,8 @@ services:
# Control Plane config
HINDSIGHT_CP_DATAPLANE_API_URL: http://localhost:8888
# Optional: Require a shared access key for Control Plane UI access
# HINDSIGHT_CP_ACCESS_KEY: your-secret-key
volumes:
# Persist embedded pg0 database
- hindsight_data:/app/data
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.6.0
appVersion: "0.6.0"
version: 0.6.2
appVersion: "0.6.2"
keywords:
- ai
- memory
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@vectorize-io/hindsight-all",
"version": "0.6.0",
"version": "0.6.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",
+2 -2
View File
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-all-slim"
version = "0.6.0"
version = "0.6.2"
description = "Hindsight: Agent Memory That Works Like Human Memory - Slim All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim==0.6.0",
"hindsight-api-slim==0.6.2",
"hindsight-client>=0.0.7",
"hindsight-embed>=0.1.0",
]
+3 -3
View File
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-all"
version = "0.6.0"
version = "0.6.2"
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim[all]==0.6.0",
"hindsight-api-slim[all]==0.6.2",
"hindsight-client>=0.0.7",
"hindsight-embed>=0.1.0",
]
@@ -21,7 +21,7 @@ hindsight-embed = { workspace = true }
[project.optional-dependencies]
local-llm = [
"hindsight-api-slim[local-llm]==0.6.0",
"hindsight-api-slim[local-llm]==0.6.2",
]
test = [
"pytest>=7.0.0",
+1 -1
View File
@@ -46,4 +46,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.6.0"
__version__ = "0.6.2"
@@ -0,0 +1,117 @@
"""Repair mental_models.subtype on databases stuck at m3rg3h3ad5f6
Three production deployments reported `column "subtype" of relation
"mental_models" does not exist` on `create_mental_model` even after their
container reported `Database migrations completed successfully` and
`alembic_version` advanced to `m3rg3h3ad5f6` (see issue #1553, #1553#1
confirmations from @4Lienau and @khanhduyvt0101).
Both `h3c4d5e6f7g8_mental_models_v4` and `d5y6z7a8b9c0_backfill_mental_models_subtype`
were meant to ensure `subtype` exists, but on databases that came through the
`reflections -> mental_models` rename chain *and* whose alembic_version
advanced past `d5y6z7a8b9c0` along an alternate path during the divergent-heads
reorganization, neither column-add actually fired. The result is a head-tagged
database with a v3-shaped `mental_models` table missing six columns:
``subtype``, ``description``, ``entity_id``, ``observations``, ``links``,
``last_updated``.
This migration sits at the current head (`m3rg3h3ad5f6`) so every affected
deployment will pick it up on next container start. It mirrors the column-add
block from `d5y6z7a8b9c0_backfill_mental_models_subtype` using
``ADD COLUMN IF NOT EXISTS`` so it is a no-op on databases where the columns
are already present.
Revision ID: 86f7a033d372
Revises: m3rg3h3ad5f6
Create Date: 2026-05-14
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "86f7a033d372"
down_revision: str | Sequence[str] | None = "m3rg3h3ad5f6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
"""Idempotently ensure mental_models has the v4 column set.
Safe to re-apply on databases that already received the columns via
`h3c4d5e6f7g8_mental_models_v4` or `d5y6z7a8b9c0_backfill_mental_models_subtype` —
every column-add uses ``IF NOT EXISTS`` and the constraint is recreated
from scratch with the canonical v4 allowlist.
"""
schema = _pg_schema_prefix()
bare_schema = schema.strip(".").strip('"') if schema else ""
schema_clause = f"AND table_schema = '{bare_schema}'" if bare_schema else ""
# Wrapped in a DO block so the existence check skips databases that
# predate the reflections -> mental_models rename chain (no table to
# repair). On those, every ALTER below would error.
op.execute(
f"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = 'mental_models'
{schema_clause}
) THEN
-- Add the six v4 columns idempotently.
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS subtype VARCHAR(32) NOT NULL DEFAULT 'structural';
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS description TEXT NOT NULL DEFAULT '';
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS entity_id UUID;
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS observations JSONB DEFAULT '{{"observations": []}}'::jsonb;
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS links VARCHAR[];
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS last_updated TIMESTAMP WITH TIME ZONE;
-- Recreate the CHECK constraint with the canonical v4 allowlist.
-- Existing rows with subtype = 'directive' (possible on databases
-- that ran the o0j1k2l3m4n5 directive-only path) are rewritten to
-- 'structural' first so the constraint add succeeds.
UPDATE {schema}mental_models SET subtype = 'structural' WHERE subtype = 'directive';
ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS ck_mental_models_subtype;
ALTER TABLE {schema}mental_models
ADD CONSTRAINT ck_mental_models_subtype
CHECK (subtype IN ('structural', 'emergent', 'pinned', 'learned'));
CREATE INDEX IF NOT EXISTS idx_mental_models_subtype
ON {schema}mental_models(bank_id, subtype);
END IF;
END$$;
"""
)
def _pg_downgrade() -> None:
"""No-op: dropping these columns would corrupt v4 application code."""
pass
def upgrade() -> None:
# PG-only: Oracle's baseline (o1a2b3c4d5e6) creates mental_models with its
# own subtype shape (chk_mm_subtype IN ('directive', 'pinned')) and a
# different table topology, so this PG-shaped repair does not apply.
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
+51 -2
View File
@@ -141,6 +141,14 @@ ENV_LLM_OPENAI_SERVICE_TIER = "HINDSIGHT_API_LLM_OPENAI_SERVICE_TIER"
ENV_LLM_EXTRA_BODY = "HINDSIGHT_API_LLM_EXTRA_BODY"
ENV_LLM_DEFAULT_HEADERS = "HINDSIGHT_API_LLM_DEFAULT_HEADERS"
# LiteLLM Router chain — provider-specific config consumed by the "litellmrouter"
# provider. Each entry is a deployment; the Router tries them in declared order and
# falls back to the next on transient errors (5xx, rate-limit, timeout).
# Provider-scoped naming mirrors other provider-specific flags (e.g. llm_groq_*,
# llm_vertexai_*). Note the single token "LITELLMROUTER" — keeping it one word
# disambiguates from the embeddings/reranker LITELLM_* settings.
ENV_LLM_LITELLMROUTER_CONFIG = "HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG"
# Defaults for service tiers
DEFAULT_LLM_GROQ_SERVICE_TIER = "auto" # "on_demand", "flex", or "auto"
DEFAULT_LLM_OPENAI_SERVICE_TIER = None # None (default) or "flex" (50% cheaper)
@@ -159,6 +167,7 @@ ENV_RETAIN_LLM_MAX_RETRIES = "HINDSIGHT_API_RETAIN_LLM_MAX_RETRIES"
ENV_RETAIN_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_RETAIN_LLM_INITIAL_BACKOFF"
ENV_RETAIN_LLM_MAX_BACKOFF = "HINDSIGHT_API_RETAIN_LLM_MAX_BACKOFF"
ENV_RETAIN_LLM_TIMEOUT = "HINDSIGHT_API_RETAIN_LLM_TIMEOUT"
ENV_RETAIN_LLM_LITELLMROUTER_CONFIG = "HINDSIGHT_API_RETAIN_LLM_LITELLMROUTER_CONFIG"
ENV_REFLECT_LLM_PROVIDER = "HINDSIGHT_API_REFLECT_LLM_PROVIDER"
ENV_REFLECT_LLM_API_KEY = "HINDSIGHT_API_REFLECT_LLM_API_KEY"
@@ -169,6 +178,7 @@ ENV_REFLECT_LLM_MAX_RETRIES = "HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES"
ENV_REFLECT_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_REFLECT_LLM_INITIAL_BACKOFF"
ENV_REFLECT_LLM_MAX_BACKOFF = "HINDSIGHT_API_REFLECT_LLM_MAX_BACKOFF"
ENV_REFLECT_LLM_TIMEOUT = "HINDSIGHT_API_REFLECT_LLM_TIMEOUT"
ENV_REFLECT_LLM_LITELLMROUTER_CONFIG = "HINDSIGHT_API_REFLECT_LLM_LITELLMROUTER_CONFIG"
ENV_CONSOLIDATION_LLM_PROVIDER = "HINDSIGHT_API_CONSOLIDATION_LLM_PROVIDER"
ENV_CONSOLIDATION_LLM_API_KEY = "HINDSIGHT_API_CONSOLIDATION_LLM_API_KEY"
@@ -179,6 +189,7 @@ ENV_CONSOLIDATION_LLM_MAX_RETRIES = "HINDSIGHT_API_CONSOLIDATION_LLM_MAX_RETRIES
ENV_CONSOLIDATION_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_CONSOLIDATION_LLM_INITIAL_BACKOFF"
ENV_CONSOLIDATION_LLM_MAX_BACKOFF = "HINDSIGHT_API_CONSOLIDATION_LLM_MAX_BACKOFF"
ENV_CONSOLIDATION_LLM_TIMEOUT = "HINDSIGHT_API_CONSOLIDATION_LLM_TIMEOUT"
ENV_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG = "HINDSIGHT_API_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG"
ENV_EMBEDDINGS_PROVIDER = "HINDSIGHT_API_EMBEDDINGS_PROVIDER"
ENV_EMBEDDINGS_LOCAL_MODEL = "HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL"
@@ -459,6 +470,8 @@ PROVIDER_DEFAULT_MODELS = {
"groq": "openai/gpt-oss-120b",
"minimax": "MiniMax-M2.7",
"deepseek": "deepseek-v4-flash",
"zai": "glm-4.5-flash",
"opencode-go": "deepseek-v4-flash",
"ollama": "gemma3:12b",
"llamacpp": "gemma-4-e2b-it",
"lmstudio": "local-model",
@@ -496,7 +509,7 @@ DEFAULT_LLM_GEMINI_SAFETY_SETTINGS = None # None = use Gemini default safety se
DEFAULT_EMBEDDINGS_PROVIDER = "local"
DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU = False # Force CPU mode for local embeddings (avoids MPS/XPC issues on macOS)
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU = False # Force CPU mode for local embeddings
DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE = False # Security: disabled by default, required for some models
DEFAULT_EMBEDDINGS_OPENAI_MODEL = "text-embedding-3-small"
DEFAULT_EMBEDDINGS_OPENAI_BATCH_SIZE = 100
@@ -507,7 +520,7 @@ DEFAULT_EMBEDDING_DIMENSION = 384
DEFAULT_RERANKER_PROVIDER = "local"
DEFAULT_RERANKER_LOCAL_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
DEFAULT_RERANKER_LOCAL_FORCE_CPU = False # Force CPU mode for local reranker (avoids MPS/XPC issues on macOS)
DEFAULT_RERANKER_LOCAL_FORCE_CPU = False # Force CPU mode for local reranker
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT = 4 # Limit concurrent CPU-bound reranking to prevent thrashing
DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE = (
False # Security: disabled by default, required for some models like jina-reranker-v2
@@ -808,6 +821,24 @@ def _get_default_model_for_provider(provider: str) -> str:
return PROVIDER_DEFAULT_MODELS.get(provider.lower(), DEFAULT_LLM_MODEL)
def _parse_llm_router_config(env_var: str) -> dict | None:
"""
Parse a LiteLLM Router configuration from a JSON env var.
The value is forwarded verbatim to ``litellm.Router(**config)``. We only
check that it parses as JSON; LiteLLM Router is authoritative about the
shape (``model_list``, ``fallbacks``, ``routing_strategy``, …). See
https://docs.litellm.ai/docs/routing.
"""
raw = os.getenv(env_var, "").strip()
if not raw:
return None
try:
return json.loads(raw)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid {env_var}: invalid JSON: {e}") from e
def _parse_default_bank_template(raw: str | None) -> dict | None:
"""
Parse HINDSIGHT_API_DEFAULT_BANK_TEMPLATE as JSON.
@@ -864,6 +895,12 @@ class HindsightConfig:
dict | None
) # Custom headers passed as default_headers to provider SDK clients (e.g. {"X-Component-Id": "hindsight"} for proxies / request tracing)
# LiteLLM Router chain (provider-specific; consumed by the "litellmrouter" provider).
# List of deployment dicts evaluated in order with fallback on transient errors.
# Each entry: {"provider": str, "model": str, "api_key": str | None, "base_url": str | None}.
# Treated as a credential field because entries embed api keys.
llm_litellmrouter_config: dict | None
# Vertex AI configuration
llm_vertexai_project_id: str | None
llm_vertexai_region: str
@@ -890,6 +927,7 @@ class HindsightConfig:
retain_llm_initial_backoff: float | None
retain_llm_max_backoff: float | None
retain_llm_timeout: float | None
retain_llm_litellmrouter_config: dict | None
reflect_llm_provider: str | None
reflect_llm_api_key: str | None
@@ -900,6 +938,7 @@ class HindsightConfig:
reflect_llm_initial_backoff: float | None
reflect_llm_max_backoff: float | None
reflect_llm_timeout: float | None
reflect_llm_litellmrouter_config: dict | None
consolidation_llm_provider: str | None
consolidation_llm_api_key: str | None
@@ -910,6 +949,7 @@ class HindsightConfig:
consolidation_llm_initial_backoff: float | None
consolidation_llm_max_backoff: float | None
consolidation_llm_timeout: float | None
consolidation_llm_litellmrouter_config: dict | None
# Embeddings
embeddings_provider: str
@@ -1149,6 +1189,11 @@ class HindsightConfig:
"retain_llm_api_key",
"reflect_llm_api_key",
"consolidation_llm_api_key",
# LiteLLM Router chains — entries embed api_keys and base_urls
"llm_litellmrouter_config",
"retain_llm_litellmrouter_config",
"reflect_llm_litellmrouter_config",
"consolidation_llm_litellmrouter_config",
# Base URLs (could expose infrastructure)
"llm_base_url",
"retain_llm_base_url",
@@ -1385,6 +1430,7 @@ class HindsightConfig:
llm_openai_service_tier=os.getenv(ENV_LLM_OPENAI_SERVICE_TIER, DEFAULT_LLM_OPENAI_SERVICE_TIER),
llm_extra_body=json.loads(os.getenv(ENV_LLM_EXTRA_BODY, "null")),
llm_default_headers=json.loads(os.getenv(ENV_LLM_DEFAULT_HEADERS, "null")),
llm_litellmrouter_config=_parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG),
# Vertex AI
llm_vertexai_project_id=os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID) or DEFAULT_LLM_VERTEXAI_PROJECT_ID,
llm_vertexai_region=os.getenv(ENV_LLM_VERTEXAI_REGION, DEFAULT_LLM_VERTEXAI_REGION),
@@ -1423,6 +1469,7 @@ class HindsightConfig:
if os.getenv(ENV_RETAIN_LLM_MAX_BACKOFF)
else None,
retain_llm_timeout=float(os.getenv(ENV_RETAIN_LLM_TIMEOUT)) if os.getenv(ENV_RETAIN_LLM_TIMEOUT) else None,
retain_llm_litellmrouter_config=_parse_llm_router_config(ENV_RETAIN_LLM_LITELLMROUTER_CONFIG),
reflect_llm_provider=os.getenv(ENV_REFLECT_LLM_PROVIDER) or None,
reflect_llm_api_key=os.getenv(ENV_REFLECT_LLM_API_KEY) or None,
reflect_llm_model=os.getenv(ENV_REFLECT_LLM_MODEL)
@@ -1447,6 +1494,7 @@ class HindsightConfig:
reflect_llm_timeout=float(os.getenv(ENV_REFLECT_LLM_TIMEOUT))
if os.getenv(ENV_REFLECT_LLM_TIMEOUT)
else None,
reflect_llm_litellmrouter_config=_parse_llm_router_config(ENV_REFLECT_LLM_LITELLMROUTER_CONFIG),
consolidation_llm_provider=os.getenv(ENV_CONSOLIDATION_LLM_PROVIDER) or None,
consolidation_llm_api_key=os.getenv(ENV_CONSOLIDATION_LLM_API_KEY) or None,
consolidation_llm_model=os.getenv(ENV_CONSOLIDATION_LLM_MODEL)
@@ -1471,6 +1519,7 @@ class HindsightConfig:
consolidation_llm_timeout=float(os.getenv(ENV_CONSOLIDATION_LLM_TIMEOUT))
if os.getenv(ENV_CONSOLIDATION_LLM_TIMEOUT)
else None,
consolidation_llm_litellmrouter_config=_parse_llm_router_config(ENV_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG),
# Embeddings
embeddings_provider=os.getenv(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER),
embeddings_local_model=os.getenv(ENV_EMBEDDINGS_LOCAL_MODEL, DEFAULT_EMBEDDINGS_LOCAL_MODEL),
+97 -35
View File
@@ -4,12 +4,20 @@ Daemon mode support for Hindsight API.
Provides idle timeout for running as a background daemon.
"""
from __future__ import annotations
import asyncio
import logging
import os
import platform
import subprocess
import sys
import time
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import IO
logger = logging.getLogger(__name__)
@@ -20,6 +28,12 @@ DEFAULT_IDLE_TIMEOUT = 0 # 0 = no auto-exit (hindsight-embed passes its own tim
# Allow override via environment variable for profile-specific logs
DAEMON_LOG_PATH = Path(os.getenv("HINDSIGHT_API_DAEMON_LOG", str(Path.home() / ".hindsight" / "daemon.log")))
# Internal env var: set by daemonize() in the re-exec'd child so the child
# skips re-exec and just redirects stdio. Also set by hindsight-embed's
# DaemonEmbedManager so the daemon launched via Popen skips re-exec entirely
# (hindsight-embed's Popen already provides a clean, detached process).
ENV_DAEMON_CHILD = "_HINDSIGHT_DAEMON_CHILD"
class IdleTimeoutMiddleware:
"""ASGI middleware that tracks activity and exits after idle timeout."""
@@ -58,57 +72,105 @@ class IdleTimeoutMiddleware:
os.kill(os.getpid(), signal.SIGTERM)
def daemonize():
def _detach_popen_kwargs(log_handle: "IO[bytes]") -> dict:
"""Cross-platform kwargs to spawn a subprocess detached from the caller.
On POSIX, ``start_new_session=True`` calls ``setsid(2)`` so the child
survives the parent's terminal. On Windows we use
``DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP``.
``log_handle`` receives the child's stdout/stderr so output never leaks
into the parent's terminal.
"""
Fork the current process into a background daemon.
if platform.system() == "Windows":
detached_process = getattr(subprocess, "DETACHED_PROCESS", 0)
create_new_process_group = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
return {
"creationflags": detached_process | create_new_process_group,
"stdin": subprocess.DEVNULL,
"stdout": log_handle,
"stderr": subprocess.STDOUT,
"close_fds": True,
}
return {
"start_new_session": True,
"stdin": subprocess.DEVNULL,
"stdout": log_handle,
"stderr": log_handle,
}
Uses double-fork technique to properly detach from terminal.
On Windows there is no fork model: the spawning parent is expected to
detach us via `CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS` and to
redirect stdout/stderr to HINDSIGHT_API_DAEMON_LOG before exec. We
still ensure the log directory exists so that any file handlers set
up by the calling app have a valid target.
def _redirect_stdio_to_log() -> None:
"""Redirect stdin/stdout/stderr to the daemon log file.
Called in the daemon child process after re-exec.
"""
if sys.platform == "win32":
DAEMON_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
return
# First fork - detach from parent
try:
pid = os.fork()
if pid > 0:
sys.exit(0)
except OSError as e:
sys.stderr.write(f"fork #1 failed: {e}\n")
sys.exit(1)
# Decouple from parent environment
os.chdir("/")
os.setsid()
os.umask(0)
# Second fork - prevent zombie
pid = os.fork()
if pid > 0:
sys.exit(0)
# Redirect standard file descriptors to log file
DAEMON_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
sys.stdout.flush()
sys.stderr.flush()
# Redirect stdin to /dev/null
with open("/dev/null", "r") as devnull:
with open(os.devnull, "r") as devnull:
os.dup2(devnull.fileno(), sys.stdin.fileno())
# Redirect stdout/stderr to log file
log_fd = open(DAEMON_LOG_PATH, "a")
os.dup2(log_fd.fileno(), sys.stdout.fileno())
os.dup2(log_fd.fileno(), sys.stderr.fileno())
def daemonize():
"""Detach the current process into a background daemon.
Uses ``subprocess.Popen`` (which maps to ``posix_spawn`` on macOS) to
re-exec the current command in a detached session. This replaces the
traditional double-fork pattern because ``os.fork()`` without ``exec()``
corrupts Apple framework state (XPC, Metal/MPS, ObjC runtime) on macOS,
causing SIGBUS crashes when PyTorch uses the MPS backend.
The function has two code paths controlled by the ``_HINDSIGHT_DAEMON_CHILD``
environment variable:
* **Parent** (env var not set): re-exec the same command via Popen with
``start_new_session=True``, stripping ``--daemon`` from argv and setting
``_HINDSIGHT_DAEMON_CHILD=1``. Then ``sys.exit(0)``.
* **Child** (env var set): redirect stdio to the daemon log file and return.
No fork, no re-exec.
On Windows there is no fork model: the spawning parent is expected to
detach us via ``CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS`` and to
redirect stdout/stderr to ``HINDSIGHT_API_DAEMON_LOG`` before exec.
We still ensure the log directory exists.
"""
if sys.platform == "win32":
DAEMON_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
return
# If we are already the daemon child (re-exec'd by a previous daemonize()
# call, or launched by hindsight-embed with the env var set), just redirect
# stdio and return — no re-exec needed.
if os.environ.get(ENV_DAEMON_CHILD) == "1":
_redirect_stdio_to_log()
return
# --- Parent path: re-exec ourselves as a detached background process ---
# Build child command: same Python, same module entry point, all args
# except --daemon (replaced by the env var).
child_args = [a for a in sys.argv[1:] if a != "--daemon"]
cmd = [sys.executable, "-m", "hindsight_api.main"] + child_args
env = os.environ.copy()
env[ENV_DAEMON_CHILD] = "1"
env["HINDSIGHT_API_DAEMON_LOG"] = str(DAEMON_LOG_PATH)
DAEMON_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(DAEMON_LOG_PATH, "ab") as log_handle:
subprocess.Popen(cmd, env=env, **_detach_popen_kwargs(log_handle))
sys.exit(0)
def check_daemon_running(port: int = DEFAULT_DAEMON_PORT) -> bool:
"""Check if a daemon is running and responsive on the given port."""
import socket
@@ -133,7 +133,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
Default: cross-encoder/ms-marco-MiniLM-L-6-v2
max_concurrent: Maximum concurrent reranking calls (default: 2).
Higher values may cause CPU thrashing under load.
force_cpu: Force CPU mode (avoids MPS/XPC issues on macOS in daemon mode).
force_cpu: Force CPU mode for local inference.
Default: False
trust_remote_code: Allow loading models with custom code (security risk).
Required for some models like jina-reranker-v2-base-multilingual.
@@ -104,7 +104,7 @@ class LocalSTEmbeddings(Embeddings):
Args:
model_name: Name of the SentenceTransformer model to use.
Default: BAAI/bge-small-en-v1.5
force_cpu: Force CPU mode (avoids MPS/XPC issues on macOS in daemon mode).
force_cpu: Force CPU mode for local inference.
Default: False
trust_remote_code: Allow loading models with custom code (security risk).
Required for some models with custom architectures.
@@ -129,6 +129,7 @@ _PROVIDERS_WITHOUT_API_KEY = frozenset(
"none",
"vertexai",
"litellm",
"litellmrouter",
"bedrock",
}
)
@@ -153,6 +154,7 @@ def create_llm_provider(
vertexai_region: str | None = None,
vertexai_credentials: Any = None,
gemini_safety_settings: list | None = None,
litellmrouter_config: dict[str, Any] | None = None,
) -> Any: # Returns LLMInterface
"""
Factory function to create the appropriate LLM provider implementation.
@@ -183,6 +185,7 @@ def create_llm_provider(
CodexLLM,
GeminiLLM,
LiteLLMLLM,
LiteLLMRouterLLM,
LlamaCppLLM,
MockLLM,
NoneLLM,
@@ -259,6 +262,23 @@ def create_llm_provider(
reasoning_effort=reasoning_effort,
)
elif provider_lower == "litellmrouter":
if not litellmrouter_config:
raise ValueError(
"Provider 'litellmrouter' requires a config object. "
"Set HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG (or the per-op variant) "
"to a JSON object accepted by litellm.Router. "
"See https://docs.litellm.ai/docs/routing."
)
return LiteLLMRouterLLM(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
config=litellmrouter_config,
reasoning_effort=reasoning_effort,
)
elif provider_lower == "bedrock":
# Bedrock is a first-class alias backed by LiteLLM with auto-prefixed model names
bedrock_model = model if model.startswith("bedrock/") else f"bedrock/{model}"
@@ -288,7 +308,18 @@ def create_llm_provider(
extra_args=config.llamacpp_extra_args,
)
elif provider_lower in ("openai", "groq", "ollama", "lmstudio", "minimax", "deepseek", "volcano", "openrouter"):
elif provider_lower in (
"openai",
"groq",
"ollama",
"lmstudio",
"minimax",
"deepseek",
"volcano",
"openrouter",
"zai",
"opencode-go",
):
return OpenAICompatibleLLM(
provider=provider,
api_key=api_key,
@@ -323,6 +354,7 @@ class LLMProvider:
gemini_safety_settings: list | None = None,
extra_body: dict[str, Any] | None = None,
default_headers: dict[str, str] | None = None,
litellmrouter_config: dict[str, Any] | None = None,
):
"""
Initialize LLM provider.
@@ -341,12 +373,18 @@ class LLMProvider:
Used by operators routing through proxies / request-tracing middleware. Falls
back to ``HindsightConfig.llm_default_headers`` (env: ``HINDSIGHT_API_LLM_DEFAULT_HEADERS``)
when ``None``.
litellmrouter_config: Provider-specific config for ``provider="litellmrouter"``.
JSON object passed verbatim to ``litellm.Router(**config)`` — see
https://docs.litellm.ai/docs/routing. Ignored unless ``provider == "litellmrouter"``.
When None and the provider is ``litellmrouter``, falls back to
``HindsightConfig.llm_litellmrouter_config``.
"""
self.provider = provider.lower()
self.api_key = api_key
self.base_url = base_url
self.model = model
self.reasoning_effort = reasoning_effort
self.litellmrouter_config = litellmrouter_config
# Service tiers from hierarchical config (not env vars)
self.groq_service_tier = groq_service_tier
self.openai_service_tier = openai_service_tier
@@ -383,9 +421,12 @@ class LLMProvider:
"minimax",
"deepseek",
"litellm",
"litellmrouter",
"bedrock",
"volcano",
"openrouter",
"zai",
"opencode-go",
]
if self.provider not in valid_providers:
raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}")
@@ -404,6 +445,10 @@ class LLMProvider:
self.base_url = "https://api.deepseek.com"
elif self.provider == "openrouter":
self.base_url = "https://openrouter.ai/api/v1"
elif self.provider == "zai":
self.base_url = "https://api.z.ai/api/coding/paas/v4"
elif self.provider == "opencode-go":
self.base_url = "https://opencode.ai/zen/go/v1"
# Prepare Vertex AI config (if applicable)
vertexai_project_id = None
@@ -459,6 +504,19 @@ class LLMProvider:
except Exception:
pass # Config may not be initialized in test environments
# For litellmrouter: prefer an explicit chain from the caller (per-op
# construction in MemoryEngine threads the right chain through). If the caller
# didn't supply one, fall back to the global ``llm_litellmrouter_config`` so
# ad-hoc constructions (e.g. ``LLMProvider.from_env()``) keep working.
router_config: dict[str, Any] | None = self.litellmrouter_config
if self.provider == "litellmrouter" and router_config is None:
from ..config import _get_raw_config
try:
router_config = _get_raw_config().llm_litellmrouter_config
except Exception:
router_config = None
# Create provider implementation using factory
self._provider_impl = create_llm_provider(
provider=self.provider,
@@ -474,6 +532,7 @@ class LLMProvider:
vertexai_region=vertexai_region,
vertexai_credentials=vertexai_credentials,
gemini_safety_settings=self.gemini_safety_settings,
litellmrouter_config=router_config,
)
# Backward compatibility: Keep mock provider properties
@@ -781,7 +840,6 @@ class LLMProvider:
def from_env(cls) -> "LLMProvider":
"""Create provider from environment variables using config.py constants."""
from ..config import (
DEFAULT_LLM_MODEL,
DEFAULT_LLM_PROVIDER,
ENV_LLM_API_KEY,
ENV_LLM_BASE_URL,
@@ -789,6 +847,7 @@ class LLMProvider:
ENV_LLM_EXTRA_BODY,
ENV_LLM_MODEL,
ENV_LLM_PROVIDER,
_get_default_model_for_provider,
)
provider = os.getenv(ENV_LLM_PROVIDER, DEFAULT_LLM_PROVIDER)
@@ -802,7 +861,7 @@ class LLMProvider:
)
base_url = os.getenv(ENV_LLM_BASE_URL, "")
model = os.getenv(ENV_LLM_MODEL, DEFAULT_LLM_MODEL)
model = os.getenv(ENV_LLM_MODEL) or _get_default_model_for_provider(provider)
extra_body = json.loads(os.getenv(ENV_LLM_EXTRA_BODY, "null"))
default_headers = json.loads(os.getenv(ENV_LLM_DEFAULT_HEADERS, "null"))
@@ -546,6 +546,7 @@ class MemoryEngine(MemoryEngineInterface):
model=memory_llm_model,
extra_body=config.llm_extra_body,
default_headers=config.llm_default_headers,
litellmrouter_config=config.llm_litellmrouter_config,
)
# Store client and model for convenience (deprecated: use _llm_config.call() instead)
@@ -574,6 +575,7 @@ class MemoryEngine(MemoryEngineInterface):
model=retain_model,
extra_body=config.llm_extra_body,
default_headers=config.llm_default_headers,
litellmrouter_config=config.retain_llm_litellmrouter_config or config.llm_litellmrouter_config,
)
# Reflect LLM config - for think/observe operations (can use lighter models)
@@ -597,6 +599,7 @@ class MemoryEngine(MemoryEngineInterface):
model=reflect_model,
extra_body=config.llm_extra_body,
default_headers=config.llm_default_headers,
litellmrouter_config=config.reflect_llm_litellmrouter_config or config.llm_litellmrouter_config,
)
# Consolidation LLM config - for mental model consolidation (can use efficient models)
@@ -620,6 +623,7 @@ class MemoryEngine(MemoryEngineInterface):
model=consolidation_model,
extra_body=config.llm_extra_body,
default_headers=config.llm_default_headers,
litellmrouter_config=config.consolidation_llm_litellmrouter_config or config.llm_litellmrouter_config,
)
# Initialize cross-encoder reranker (cached for performance)
@@ -1654,11 +1658,16 @@ class MemoryEngine(MemoryEngineInterface):
# Parent doesn't exist (shouldn't happen)
return
# Get all sibling operations (including this one)
# This query runs in the same transaction, so it sees the current child's updated status
# Get all sibling operations (including this one).
# This query runs in the same transaction, so it sees the current
# child's updated status. Pull error_message too so a parent that
# fails can inherit a representative child reason -- otherwise
# downstream consumers (dashboards, alert filters) lose the actual
# cause once a batch has children. See the worker poller's
# _summarise_child_error_messages for the propagation rationale.
siblings = await conn.fetch(
f"""
SELECT status
SELECT status, error_message
FROM {fq_table("async_operations")}
WHERE bank_id = $1
AND result_metadata::jsonb @> $2::jsonb
@@ -1682,7 +1691,12 @@ class MemoryEngine(MemoryEngineInterface):
# All siblings are done - update parent status
if any_failed:
new_status = "failed"
# Set parent error message to indicate child failure
# Set parent error message to indicate child failure. Inherit
# the most-common failed-child error_message rather than a
# generic string so downstream filters can attribute the
# cause correctly.
from hindsight_api.worker.poller import _summarise_child_error_messages
await conn.execute(
f"""
UPDATE {fq_table("async_operations")}
@@ -1691,7 +1705,7 @@ class MemoryEngine(MemoryEngineInterface):
""",
uuid.UUID(parent_operation_id),
new_status,
"One or more sub-batches failed",
_summarise_child_error_messages(siblings),
)
elif all_completed:
new_status = "completed"
@@ -9,6 +9,7 @@ from .claude_code_llm import ClaudeCodeLLM
from .codex_llm import CodexLLM
from .gemini_llm import GeminiLLM
from .litellm_llm import LiteLLMLLM
from .litellm_router_llm import LiteLLMRouterLLM
from .llamacpp_llm import LlamaCppLLM
from .mock_llm import MockLLM
from .none_llm import NoneLLM
@@ -21,6 +22,7 @@ __all__ = [
"GeminiLLM",
"LlamaCppLLM",
"LiteLLMLLM",
"LiteLLMRouterLLM",
"MockLLM",
"NoneLLM",
"OpenAICompatibleLLM",
@@ -12,6 +12,8 @@ import logging
import time
from typing import Any
from pydantic import ValidationError
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
@@ -278,6 +280,12 @@ class ClaudeCodeLLM(LLMInterface):
return result
except ValidationError:
# Pydantic schema validation failure — retrying with the same
# input won't produce a different schema. Raise immediately
# instead of burning quota on identical calls (#1412).
raise
except Exception as e:
last_exception = e
@@ -4,14 +4,26 @@ OpenAI Codex LLM provider using ChatGPT Plus/Pro OAuth authentication.
This provider enables using ChatGPT Plus/Pro subscriptions for API calls
without separate OpenAI Platform API credits. It uses OAuth tokens from
~/.codex/auth.json and communicates with the ChatGPT backend API.
Tokens are refreshed automatically: the provider decodes the access_token
JWT's ``exp`` claim and proactively refreshes via
``POST https://auth.openai.com/oauth/token`` ~60s before expiry. It also
reactively refreshes once on a 401/403 from the Codex backend before giving
up. The refresh request shape mirrors the canonical ``@openai/codex`` CLI
implementation (codex-rs/login/src/auth/manager.rs on github.com/openai/codex)
so that future server-side changes affect both clients identically.
"""
import asyncio
import base64
import binascii
import json
import logging
import os
import tempfile
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -24,6 +36,36 @@ from hindsight_api.metrics import get_metrics_collector
logger = logging.getLogger(__name__)
# OAuth refresh endpoint and client id, mirrored from the canonical
# ``@openai/codex`` CLI (codex-rs/login/src/auth/manager.rs on
# github.com/openai/codex). The endpoint is overridable via env var so that
# future Codex changes or staging environments can be pointed at without a
# code change — same env var name the upstream CLI uses.
_CODEX_REFRESH_TOKEN_URL = os.environ.get("CODEX_REFRESH_TOKEN_URL_OVERRIDE", "https://auth.openai.com/oauth/token")
_CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
# Proactively refresh this many seconds before the JWT ``exp`` claim. The
# upstream Codex CLI uses no skew (it refreshes at ``exp <= now``); the
# extra window reduces races where a request leaves the client with a token
# that the server has already declared expired by the time it arrives.
_CODEX_TOKEN_REFRESH_SKEW_SECONDS = 60
# OAuth error codes that the refresh endpoint returns when the refresh_token
# itself is no longer usable. These are terminal — retrying refresh will not
# succeed; the user must re-run ``codex auth login``.
_CODEX_TERMINAL_REFRESH_ERROR_CODES = frozenset(
{"refresh_token_expired", "refresh_token_reused", "refresh_token_invalidated"}
)
class CodexRefreshExpiredError(RuntimeError):
"""Raised when the Codex refresh_token itself is no longer valid.
The user must re-run ``codex auth login`` to obtain new credentials.
Callers should surface a clear remediation message and stop retrying.
"""
class CodexLLM(LLMInterface):
"""
LLM provider using OpenAI Codex OAuth authentication.
@@ -44,9 +86,19 @@ class CodexLLM(LLMInterface):
"""Initialize Codex LLM provider."""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
# Path is fixed at ~/.codex/auth.json — matches the upstream CLI.
# Storing it on self lets the refresh path re-read after another
# process (e.g. a sidecar) rotates the file out from under us.
self._auth_file = Path.home() / ".codex" / "auth.json"
# Single-flight refresh lock. Multiple concurrent requests racing
# toward an expired token should produce one network refresh, not N.
self._auth_lock = asyncio.Lock()
# Load Codex OAuth credentials
try:
self.access_token, self.account_id = self._load_codex_auth()
self.refresh_token = self._load_codex_refresh_token()
logger.info(f"Loaded Codex OAuth credentials for account: {self.account_id}")
except Exception as e:
raise RuntimeError(
@@ -108,6 +160,290 @@ class CodexLLM(LLMInterface):
return access_token, account_id
def _load_codex_refresh_token(self) -> str | None:
"""Load ``tokens.refresh_token`` from ``~/.codex/auth.json``.
Returns None when the auth file is unreadable or omits the field —
the provider still functions as a one-shot loader in that case, it
just can't refresh when the access_token expires. This deliberately
does not raise so that ``__init__`` keeps the existing failure mode
of raising only on missing ``access_token``.
"""
try:
with open(self._auth_file) as f:
data = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.warning(
f"Codex auth file unreadable when loading refresh_token: {type(e).__name__}. "
"Token refresh will not be available; the access_token in memory will be used until it expires."
)
return None
return data.get("tokens", {}).get("refresh_token")
@staticmethod
def _decode_jwt_exp_unixtime(token: str) -> int | None:
"""Return the JWT ``exp`` claim as a unix timestamp, or None on parse failure.
ChatGPT/Codex access_tokens are JWTs whose payload includes ``exp``
(RFC 7519). We need the expiry to schedule proactive refresh — the
``auth.json`` file does not persist a separate ``expires_at`` field
in the upstream CLI's shape, so decoding the JWT itself is the
canonical way to know when the token is stale.
We do not verify the signature — the server is the source of truth
on whether the token is actually accepted, and the only thing this
method affects is the *timing* of refresh, not whether to trust the
token contents.
"""
try:
parts = token.split(".")
if len(parts) < 2:
return None
payload_b64 = parts[1]
# JWT uses base64url without padding. Re-pad before decoding.
padding = "=" * (-len(payload_b64) % 4)
payload_bytes = base64.urlsafe_b64decode(payload_b64 + padding)
payload = json.loads(payload_bytes.decode("utf-8"))
exp = payload.get("exp")
return int(exp) if exp is not None else None
except (ValueError, TypeError, json.JSONDecodeError, binascii.Error):
return None
def _token_is_stale(self, skew_seconds: int = _CODEX_TOKEN_REFRESH_SKEW_SECONDS) -> bool:
"""True when the cached access_token is past expiry (with skew).
Returns False when expiry cannot be determined — we'd rather use a
possibly-expired token and recover via the reactive 401 path than
refresh aggressively on every request when ``exp`` parsing fails.
"""
exp = self._decode_jwt_exp_unixtime(self.access_token)
if exp is None:
return False
return exp <= int(time.time()) + skew_seconds
def _persist_auth_atomic(self, updated_tokens: dict[str, Any]) -> None:
"""Write the rotated tokens back to ``~/.codex/auth.json`` atomically.
Strategy: re-read the on-disk auth.json (so we don't clobber fields
another process may have added), patch ``tokens.*`` and
``last_refresh``, write to a tempfile in the same directory with
mode 0600, then ``os.replace`` onto the target. ``os.replace`` is
atomic within the same filesystem on POSIX and Windows, so a
concurrent reader will see either the old file or the fully-written
new file — never a partial truncate, which is the upstream CLI's
worst-case race.
On non-Unix platforms the chmod is a best-effort no-op; the parent
directory permissions still bound access.
"""
current: dict[str, Any]
try:
with open(self._auth_file) as f:
loaded = json.load(f)
# auth.json should always be a JSON object at the top level; if
# someone has hand-edited it into a non-object shape, fall back
# to the minimal default rather than crashing the refresh path.
current = loaded if isinstance(loaded, dict) else {"auth_mode": "chatgpt", "tokens": {}}
except (OSError, json.JSONDecodeError):
# If the file became unreadable between our last read and now,
# construct a minimal shape rather than refusing to persist.
current = {"auth_mode": "chatgpt", "tokens": {}}
existing_tokens = current.get("tokens")
tokens: dict[str, Any] = existing_tokens if isinstance(existing_tokens, dict) else {}
for key in ("access_token", "refresh_token", "id_token", "account_id"):
if key in updated_tokens and updated_tokens[key] is not None:
tokens[key] = updated_tokens[key]
current["tokens"] = tokens
current["last_refresh"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
# Write to a sibling tempfile so the rename is same-filesystem.
parent = self._auth_file.parent
parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(prefix=".auth.", suffix=".json.tmp", dir=str(parent))
try:
with os.fdopen(fd, "w") as f:
json.dump(current, f, indent=2)
f.flush()
os.fsync(f.fileno())
try:
os.chmod(tmp_path, 0o600)
except OSError:
pass # best-effort on platforms that don't support chmod
os.replace(tmp_path, self._auth_file)
except Exception:
# Clean up the orphaned tempfile if rename fails.
try:
os.unlink(tmp_path)
except OSError:
pass
raise
async def _refresh_oauth_tokens(self, reason: str = "", *, force: bool = False) -> None:
"""Refresh the OAuth access_token using the stored refresh_token.
Single-flight: serialized through ``self._auth_lock`` so concurrent
callers produce one network request. The first caller refreshes; the
rest wake up and observe that either (a) the in-memory token is no
longer stale (proactive case) or (b) the in-memory token has changed
since they entered (reactive case), and return without re-refreshing.
Args:
reason: Free-form string included in log lines for diagnostics.
force: When True, refresh even if the JWT exp claim looks fresh.
Used by the reactive 401 path — the server rejected the
token, so we cannot trust the JWT's self-reported expiry.
Raises:
CodexRefreshExpiredError: when the server returns a terminal
error code (refresh_token_expired/reused/invalidated) or any
401 on the refresh endpoint itself.
RuntimeError: for other refresh failures (network, 5xx, etc.).
"""
# Capture the token we'd be refreshing BEFORE acquiring the lock so
# that we can detect mid-wait rotation by another coroutine.
token_before_lock = self.access_token
async with self._auth_lock:
if force:
# Reactive: skip only if another coroutine already rotated
# the token while we were waiting on the lock.
if self.access_token != token_before_lock:
return
else:
# Proactive: skip if the token is no longer stale (the
# canonical "another coroutine refreshed first" check).
if not self._token_is_stale():
return
if not self.refresh_token:
raise RuntimeError(
"Codex access_token is expired but no refresh_token is available. "
"Run 'codex auth login' to re-authenticate."
)
log_reason = f" ({reason})" if reason else ""
logger.info(f"Refreshing Codex OAuth access_token{log_reason}")
request_body = {
"client_id": _CODEX_CLIENT_ID,
"grant_type": "refresh_token",
"refresh_token": self.refresh_token,
}
try:
response = await self._client.post(
_CODEX_REFRESH_TOKEN_URL,
json=request_body,
headers={"Content-Type": "application/json"},
timeout=30.0,
)
except httpx.RequestError as e:
raise RuntimeError(f"Codex OAuth refresh network error: {type(e).__name__}") from e
if response.status_code == 401:
# Classify by ``error.code`` (or top-level ``error`` string) — same
# mapping as the upstream Rust CLI's request_chatgpt_token_refresh.
error_code = self._extract_oauth_error_code(response)
if error_code in _CODEX_TERMINAL_REFRESH_ERROR_CODES:
raise CodexRefreshExpiredError(
f"Codex refresh_token is permanently invalid (error.code={error_code}). "
"Run 'codex auth login' to re-authenticate."
)
# Unknown 401 — treat as terminal too, matching the upstream classification.
raise CodexRefreshExpiredError(
f"Codex OAuth refresh returned 401 with unrecognized error code "
f"({error_code or 'none'}). Run 'codex auth login' to re-authenticate."
)
if response.status_code >= 400:
# 5xx and other 4xx are transient/retryable from the caller's
# perspective; surface as RuntimeError without leaking the
# request body in logs.
raise RuntimeError(f"Codex OAuth refresh failed with HTTP {response.status_code}")
try:
body = response.json()
except json.JSONDecodeError as e:
raise RuntimeError(f"Codex OAuth refresh returned non-JSON body: {e}") from e
new_access = body.get("access_token")
if not new_access:
raise RuntimeError("Codex OAuth refresh returned no access_token")
# The refresh_token may rotate on each refresh — adopt the new
# one if the server sent it, otherwise keep the existing.
new_refresh = body.get("refresh_token") or self.refresh_token
new_id_token = body.get("id_token")
# Update in-memory state first so callers waiting on the lock
# see fresh credentials immediately, even if disk write fails.
self.access_token = new_access
self.refresh_token = new_refresh
persisted = {
"access_token": new_access,
"refresh_token": new_refresh,
}
if new_id_token:
persisted["id_token"] = new_id_token
try:
self._persist_auth_atomic(persisted)
except OSError as e:
# In-memory creds are valid; warn but don't fail the request
# path. Future process starts will fall back to the stale
# on-disk auth.json and immediately refresh.
logger.warning(
f"Codex OAuth refresh succeeded but persisting auth.json failed: {type(e).__name__}. "
"In-memory credentials are up to date; on-disk file is stale."
)
logger.info("Codex OAuth access_token refreshed successfully")
@staticmethod
def _extract_oauth_error_code(response: "httpx.Response") -> str | None:
"""Pull the OAuth error code out of a 4xx response body, if present.
The refresh endpoint returns shapes like
``{"error": "...", "error_code": "..."}`` or
``{"error": {"code": "..."}}``. We don't fail the call if the body
is unparseable — the caller falls back to a generic "unknown" error.
"""
try:
body = response.json()
except (json.JSONDecodeError, ValueError):
return None
if not isinstance(body, dict):
return None
# Shape 1: error is a nested object with "code"
err = body.get("error")
if isinstance(err, dict):
code = err.get("code")
if isinstance(code, str):
return code
# Shape 2: top-level error_code string
code = body.get("error_code")
if isinstance(code, str):
return code
# Shape 3: error is itself a string code
if isinstance(err, str):
return err
return None
async def _ensure_fresh_token(self) -> None:
"""Refresh the access_token proactively if it is near or past expiry.
Called at the top of every API-bound method. Cheap when the token is
fresh (just decodes the JWT exp claim and returns).
"""
if self._token_is_stale():
try:
await self._refresh_oauth_tokens(reason="proactive (token near expiry)")
except CodexRefreshExpiredError:
# Surface to the caller as the same RuntimeError shape the
# request loop has historically raised, so existing error
# handling paths keep working.
raise
def _map_reasoning_effort(self, effort: str) -> str:
"""
Map standard reasoning effort to Codex reasoning summary format.
@@ -189,6 +525,15 @@ class CodexLLM(LLMInterface):
"""Make API call to Codex backend with SSE streaming."""
start_time = time.time()
# Proactively refresh the OAuth access_token if it's near expiry.
# Cheap when fresh: a JWT exp decode + comparison.
await self._ensure_fresh_token()
# Tracks whether we've already attempted a reactive refresh in
# response to a 401 from the backend. Set once on the first auth
# failure so we retry exactly once after refresh, not in a loop.
attempted_refresh_after_auth_error = False
# Prepare system instructions
system_instruction = ""
user_messages = []
@@ -244,7 +589,12 @@ class CodexLLM(LLMInterface):
url = f"{self.base_url}/codex/responses"
last_exception = None
for attempt in range(max_retries + 1):
# Manual attempt tracking instead of ``for attempt in range(...)`` so
# that the reactive-refresh path can retry once without consuming a
# normal-retry budget slot. The refresh-retry is conceptually a
# separate auth-recovery attempt that shouldn't compete with backoff.
attempt = 0
while True:
try:
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
response.raise_for_status()
@@ -269,6 +619,7 @@ class CodexLLM(LLMInterface):
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
last_exception = e
attempt += 1
continue
raise
@@ -332,8 +683,38 @@ class CodexLLM(LLMInterface):
last_exception = e
status_code = e.response.status_code
# Fast fail on auth errors
# Auth error: try one OAuth refresh + retry before giving up.
# The proactive refresh at the top of this method catches most
# expiries, but a token can also become invalid mid-request if
# another process rotates auth.json out from under us, or if
# the JWT exp claim is unparseable and we never knew it was
# stale. Reactive refresh is the safety net.
if status_code in (401, 403):
if not attempted_refresh_after_auth_error:
attempted_refresh_after_auth_error = True
try:
await self._refresh_oauth_tokens(
reason=f"reactive (HTTP {status_code} from codex backend)",
force=True,
)
# Rebuild the Authorization header with the new
# token and retry without consuming a normal-retry
# budget slot — this is a dedicated auth-recovery
# attempt that shouldn't compete with backoff.
headers["Authorization"] = f"Bearer {self.access_token}"
logger.info("Codex auth refreshed after auth error; retrying request once")
continue
except CodexRefreshExpiredError as refresh_err:
logger.error("Codex refresh_token is permanently invalid; cannot recover from auth error")
raise RuntimeError(
"Codex authentication failed and the refresh_token is no longer valid.\n"
"Run 'codex auth login' to re-authenticate."
) from refresh_err
except Exception as refresh_err:
logger.error(
f"Codex token refresh attempt failed: {type(refresh_err).__name__}: {refresh_err}"
)
# Fall through to the original raise below.
logger.error(f"Codex auth error (HTTP {status_code}): {e.response.text[:200]}")
raise RuntimeError(
"Codex authentication failed. Your OAuth token may have expired.\n"
@@ -349,6 +730,7 @@ class CodexLLM(LLMInterface):
f"Codex HTTP error {status_code} (attempt {attempt + 1}/{max_retries + 1}): {error_detail}"
)
await asyncio.sleep(backoff)
attempt += 1
continue
else:
logger.error(
@@ -362,6 +744,7 @@ class CodexLLM(LLMInterface):
backoff = min(initial_backoff * (2**attempt), max_backoff)
logger.warning(f"Codex connection error (attempt {attempt + 1}/{max_retries + 1}): {e}")
await asyncio.sleep(backoff)
attempt += 1
continue
else:
logger.error(f"Codex connection error after {max_retries + 1} attempts: {e}")
@@ -462,6 +845,11 @@ class CodexLLM(LLMInterface):
"""
start_time = time.time()
# Proactively refresh the OAuth access_token if it's near expiry.
# Same rationale as in ``call()`` — keeps the request from leaving
# the client carrying a token that's already past ``exp``.
await self._ensure_fresh_token()
# Prepare system instructions
system_instruction = ""
user_messages = []
@@ -534,9 +922,39 @@ class CodexLLM(LLMInterface):
# Debug logging for troubleshooting
logger.debug(f"Codex tool call request: url={url}, model={payload['model']}, tools={len(codex_tools)}")
# One reactive refresh attempt on auth failure, mirroring call().
# ``call_with_tools`` doesn't have a retry loop, so we hand-roll a
# single retry after refreshing the token. Any non-auth error still
# surfaces immediately to keep behavior identical for callers.
attempted_refresh_after_auth_error = False
try:
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
if response.status_code in (401, 403) and not attempted_refresh_after_auth_error:
attempted_refresh_after_auth_error = True
try:
await self._refresh_oauth_tokens(
reason=f"reactive (HTTP {response.status_code} from codex backend in call_with_tools)",
force=True,
)
headers["Authorization"] = f"Bearer {self.access_token}"
logger.info("Codex auth refreshed after auth error; retrying tool-call request once")
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
except CodexRefreshExpiredError as refresh_err:
logger.error(
"Codex refresh_token is permanently invalid; cannot recover from auth error in tool-call path"
)
raise RuntimeError(
"Codex authentication failed and the refresh_token is no longer valid.\n"
"Run 'codex auth login' to re-authenticate."
) from refresh_err
except Exception as refresh_err:
logger.error(
f"Codex token refresh attempt failed in tool-call path: {type(refresh_err).__name__}: {refresh_err}"
)
# Fall through to the normal error path below.
# Log response details on error
if response.status_code != 200:
logger.error(f"Codex API error {response.status_code}: {response.text[:500]}")
@@ -103,12 +103,58 @@ class LiteLLMLLM(LLMInterface):
if self.base_url:
kwargs["api_base"] = self.base_url
if max_completion_tokens is not None:
kwargs["max_completion_tokens"] = max_completion_tokens
kwargs["max_completion_tokens"] = self._cap_max_completion_tokens(max_completion_tokens)
if temperature is not None:
kwargs["temperature"] = temperature
return kwargs
# ── per-model output-tokens cap (shared with Router subclass) ────────────
# Hindsight's defaults (e.g. retain_max_completion_tokens=64000) target
# high-capacity models. When a configured deployment supports fewer
# completion tokens (e.g. gpt-4.1-nano caps at 32768), the call would
# otherwise be rejected. Cap pre-emptively using LiteLLM's per-model
# registry so things work out of the box across the supported model set.
def _cap_max_completion_tokens(self, value: int) -> int:
cap = self._get_model_output_cap()
if cap and value > cap:
logger.debug("capping max_completion_tokens %d -> %d for model %s", value, cap, self.model)
return cap
return value
def _get_model_output_cap(self) -> int | None:
"""Return the configured model's max output tokens, per LiteLLM's registry."""
try:
cap = self._litellm.get_max_tokens(self.model)
return int(cap) if cap else None
except Exception:
return None
# ── hooks for Router-style subclasses ────────────────────────────────────
# The retry+parse loop in call() / call_with_tools() is shared by every
# LiteLLM-backed provider. Subclasses override the small surface below to
# swap the completion fn (direct vs Router) and rename the deployment that
# actually answered the request.
@property
def _stage_label(self) -> str:
"""Stage breadcrumb label — overridden by subclasses (e.g. ``litellmrouter``)."""
return "litellm"
async def _acompletion(self, **kwargs: Any) -> Any:
"""Issue a chat completion. Subclasses override to route via ``litellm.Router``."""
return await self._litellm.acompletion(**kwargs)
def _resolve_completion_model(self, response: Any) -> str:
"""
Return the model name to record in metrics/tracing.
For Router-backed providers this can differ from ``self.model`` — the Router
may pick a different deployment than the primary. Default: ``self.model``.
"""
return self.model
async def call(
self,
messages: list[dict[str, str]],
@@ -143,12 +189,13 @@ class LiteLLMLLM(LLMInterface):
for attempt in range(max_retries + 1):
if attempt > 0:
set_stage(f"llm.litellm.{scope}.attempt={attempt + 1}/{max_retries + 1}")
set_stage(f"llm.{self._stage_label}.{scope}.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await self._litellm.acompletion(**call_kwargs)
response = await self._acompletion(**call_kwargs)
content = response.choices[0].message.content or ""
finish_reason = response.choices[0].finish_reason
model_name = self._resolve_completion_model(response)
# Check for length-limited output
if finish_reason == "length":
@@ -184,7 +231,7 @@ class LiteLLMLLM(LLMInterface):
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
model=model_name,
scope=scope,
duration=duration,
input_tokens=input_tokens,
@@ -198,7 +245,7 @@ class LiteLLMLLM(LLMInterface):
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
model=model_name,
scope=scope,
messages=messages,
response_content=_serialize_for_span(result),
@@ -211,7 +258,7 @@ class LiteLLMLLM(LLMInterface):
if duration > 10.0:
logger.info(
f"slow llm call: scope={scope}, model={self.provider}/{self.model}, "
f"slow llm call: scope={scope}, model={self.provider}/{model_name}, "
f"input_tokens={input_tokens}, output_tokens={output_tokens}, "
f"time={duration:.3f}s"
)
@@ -287,13 +334,14 @@ class LiteLLMLLM(LLMInterface):
last_exception = None
for attempt in range(max_retries + 1):
if attempt > 0:
set_stage(f"llm.litellm.tools.attempt={attempt + 1}/{max_retries + 1}")
set_stage(f"llm.{self._stage_label}.tools.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await self._litellm.acompletion(**call_kwargs)
response = await self._acompletion(**call_kwargs)
message = response.choices[0].message
content = message.content
finish_reason = response.choices[0].finish_reason
model_name = self._resolve_completion_model(response)
# Extract tool calls
tool_calls: list[LLMToolCall] = []
@@ -319,7 +367,7 @@ class LiteLLMLLM(LLMInterface):
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
model=model_name,
scope=scope,
duration=duration,
input_tokens=input_tokens,
@@ -338,7 +386,7 @@ class LiteLLMLLM(LLMInterface):
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
model=model_name,
scope=scope,
messages=messages,
response_content=content,
@@ -0,0 +1,167 @@
"""
LiteLLM Router LLM provider — pure pass-through to ``litellm.Router``.
The full configuration object is forwarded verbatim. We do not translate model
names, infer fallbacks, validate shape, or introspect Router internals:
whatever the user puts in ``HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG`` becomes
``Router(**config)``. If the shape is wrong, LiteLLM Router raises.
The only Hindsight-imposed convention is that one entry in ``model_list``
must have ``model_name: "default"`` — that's the entrypoint we issue
completions against. Everything else (ordering, fallbacks, load-balancing,
weighted picks, rate limits, retries, cooldowns) is whatever the user
configures via LiteLLM's own keys.
See https://docs.litellm.ai/docs/routing for the supported keys (``model_list``,
``fallbacks``, ``context_window_fallbacks``, ``num_retries``, ``cooldown_time``,
``routing_strategy``, ``allowed_fails``, …).
The retry/parse/metrics loop is shared with ``LiteLLMLLM`` via inheritance:
this class only overrides the completion fn, the call kwargs, and the model
name reported in metrics.
Example ``HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG``::
{
"model_list": [
{"model_name": "default", "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-..."}},
{"model_name": "fallback", "litellm_params": {"model": "anthropic/claude-sonnet-4", "api_key": "sk-ant-..."}}
],
"fallbacks": [{"default": ["fallback"]}],
"num_retries": 0,
"cooldown_time": 60
}
"""
import logging
from typing import Any
from hindsight_api.engine.providers.litellm_llm import LiteLLMLLM
logger = logging.getLogger(__name__)
# Hindsight always issues completions against this ``model_name``. Users must
# include at least one entry with ``model_name: "default"`` in their config's
# ``model_list``; that entry is the entrypoint, and any other entries become
# fallback / load-balance / weighted-pool members per the user's own
# ``fallbacks`` / ``routing_strategy`` settings.
_ENTRYPOINT_MODEL_NAME = "default"
class LiteLLMRouterLLM(LiteLLMLLM):
"""
LLM provider backed by ``litellm.Router``.
The full Router config is supplied by the caller. We pass it verbatim to
``Router(**config)`` and route requests against the first ``model_list``
entry's ``model_name``. Inherits the retry/parse/metrics loop from
``LiteLLMLLM``; only the completion fn and the call kwargs differ.
"""
def __init__(
self,
provider: str,
api_key: str,
base_url: str,
model: str,
config: dict[str, Any],
reasoning_effort: str = "low",
timeout: float = 300.0,
**kwargs: Any,
):
super().__init__(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort=reasoning_effort,
timeout=timeout,
**kwargs,
)
self.config = config
from litellm import Router
logging.getLogger("LiteLLM Router").setLevel(logging.WARNING)
# Pure pass-through: whatever the user gave goes straight to LiteLLM Router.
# If the shape is invalid, Router raises its own error — we don't pre-validate
# or introspect Router internals.
self._router = Router(**config)
# Pre-compute the most conservative output-tokens cap across every configured
# deployment so a single max_completion_tokens value works no matter which
# deployment Router picks. Uses LiteLLM's own per-model registry; unknown
# models contribute no cap. See LiteLLMLLM._cap_max_completion_tokens.
self._router_output_cap = self._compute_router_output_cap(config)
logger.info("LiteLLM Router initialized; entrypoint model_name=%r", _ENTRYPOINT_MODEL_NAME)
def _compute_router_output_cap(self, config: dict[str, Any]) -> int | None:
caps: list[int] = []
for deployment in (config.get("model_list") or []) if isinstance(config, dict) else []:
if not isinstance(deployment, dict):
continue
params = deployment.get("litellm_params") or {}
model_str = params.get("model") if isinstance(params, dict) else None
if not model_str:
continue
try:
cap = self._litellm.get_max_tokens(model_str)
except Exception:
cap = None
if cap:
caps.append(int(cap))
return min(caps) if caps else None
# ── overrides for the shared retry/parse loop ───────────────────────────
@property
def _stage_label(self) -> str:
return "litellmrouter"
async def _acompletion(self, **kwargs: Any) -> Any:
return await self._router.acompletion(**kwargs)
def _resolve_completion_model(self, response: Any) -> str:
hidden = getattr(response, "_hidden_params", None) or {}
return hidden.get("model") or _ENTRYPOINT_MODEL_NAME
def _get_model_output_cap(self) -> int | None:
return self._router_output_cap
def _build_common_kwargs(
self,
messages: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
) -> dict[str, Any]:
# Always issue against the entrypoint group; Router handles deployment selection,
# cross-group fallbacks, retries, cooldowns — whatever the user configured.
kwargs: dict[str, Any] = {
"model": _ENTRYPOINT_MODEL_NAME,
"messages": messages,
}
if max_completion_tokens is not None:
kwargs["max_completion_tokens"] = self._cap_max_completion_tokens(max_completion_tokens)
if temperature is not None:
kwargs["temperature"] = temperature
return kwargs
async def verify_connection(self) -> None:
from hindsight_api.engine.llm_interface import OutputTooLongError
try:
await self.call(
messages=[{"role": "user", "content": "test"}],
max_completion_tokens=50,
temperature=0.0,
scope="verification",
max_retries=0,
)
logger.info("LiteLLM Router connection verified successfully")
except OutputTooLongError:
logger.info("LiteLLM Router connection verified successfully (response truncated)")
except Exception as e:
logger.error(f"LiteLLM Router connection verification failed: {e}")
raise RuntimeError(f"Failed to verify LiteLLM Router connection: {e}") from e
@@ -1,5 +1,6 @@
"""
OpenAI-compatible LLM provider supporting OpenAI, Groq, Ollama, LMStudio, MiniMax, and DeepSeek.
OpenAI-compatible LLM provider supporting OpenAI, Groq, Ollama, LMStudio, MiniMax, DeepSeek,
and Opencode Go.
This provider handles all OpenAI API-compatible models including:
- OpenAI: GPT-4, GPT-4o, GPT-5, o1, o3 (reasoning models)
@@ -8,6 +9,7 @@ This provider handles all OpenAI API-compatible models including:
- LMStudio: Local models with OpenAI-compatible API
- MiniMax: MiniMax-M2.7 models with 1M context window
- DeepSeek: deepseek-v4-flash / deepseek-v4-pro / deepseek-chat / deepseek-reasoner via api.deepseek.com
- Opencode Go: deepseek-v4-flash via https://opencode.ai/zen/go/v1
Features:
- Reasoning models with extended thinking (o1, o3, GPT-5 families)
@@ -232,6 +234,7 @@ class OpenAICompatibleLLM(LLMInterface):
- LMStudio: Local models with OpenAI-compatible API
- MiniMax: MiniMax-M2.7 models via OpenAI-compatible API (https://api.minimax.io/v1)
- DeepSeek: deepseek-v4-flash / deepseek-v4-pro / deepseek-chat / deepseek-reasoner via https://api.deepseek.com
- opencode-go: deepseek-v4-flash via https://opencode.ai/zen/go/v1
"""
def __init__(
@@ -250,7 +253,7 @@ class OpenAICompatibleLLM(LLMInterface):
Initialize OpenAI-compatible LLM provider.
Args:
provider: Provider name ("openai", "groq", "ollama", "lmstudio").
provider: Provider name ("openai", "groq", "ollama", "lmstudio", "opencode-go", etc.).
api_key: API key (optional for ollama/lmstudio).
base_url: Base URL for the API (uses defaults for groq/ollama/lmstudio if empty).
model: Model name.
@@ -273,6 +276,8 @@ class OpenAICompatibleLLM(LLMInterface):
"deepseek",
"volcano",
"openrouter",
"zai",
"opencode-go",
]
if self.provider not in valid_providers:
raise ValueError(f"OpenAICompatibleLLM only supports: {', '.join(valid_providers)}. Got: {self.provider}")
@@ -291,13 +296,29 @@ class OpenAICompatibleLLM(LLMInterface):
self.base_url = "https://api.deepseek.com"
elif self.provider == "openrouter":
self.base_url = "https://openrouter.ai/api/v1"
elif self.provider == "zai":
self.base_url = "https://api.z.ai/api/coding/paas/v4"
elif self.provider == "opencode-go":
self.base_url = "https://opencode.ai/zen/go/v1"
# For ollama/lmstudio, use dummy key if not provided
if self.provider in ("ollama", "lmstudio") and not self.api_key:
self.api_key = "local"
# Validate API key for cloud providers
if self.provider in ("openai", "groq", "minimax", "deepseek", "openrouter") and not self.api_key:
if (
self.provider
in (
"openai",
"groq",
"minimax",
"deepseek",
"openrouter",
"zai",
"opencode-go",
)
and not self.api_key
):
raise ValueError(f"API key is required for {self.provider}")
# Service tier configuration (from config, not env vars)
@@ -558,10 +579,11 @@ class OpenAICompatibleLLM(LLMInterface):
)
# Strip reasoning model thinking tags
# Supports: <think>, <thinking>, <reasoning>, |startthink|/|endthink|
# Supports: <think>, <thinking>, <thought>, <reasoning>, |startthink|/|endthink|
original_len = len(content)
content = re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL)
content = re.sub(r"<thinking>.*?</thinking>", "", content, flags=re.DOTALL)
content = re.sub(r"<thought>.*?</thought>", "", content, flags=re.DOTALL)
content = re.sub(r"<reasoning>.*?</reasoning>", "", content, flags=re.DOTALL)
content = re.sub(r"\|startthink\|.*?\|endthink\|", "", content, flags=re.DOTALL)
content = content.strip()
@@ -14,8 +14,6 @@ import re
import time
from typing import TYPE_CHECKING, Any, Awaitable, Callable
import tiktoken
from .models import DirectiveInfo, LLMCall, ReflectAgentResult, TokenUsageSummary, ToolCall
from .prompts import (
_extract_directive_rules,
@@ -23,6 +21,7 @@ from .prompts import (
build_final_system_prompt,
build_system_prompt_for_tools,
)
from .tokenization import count_cl100k_tokens
from .tools_schema import get_reflect_tools
@@ -266,25 +265,22 @@ OUTPUT:"""
return None, 0, 0
_TIKTOKEN_ENCODING = tiktoken.get_encoding("cl100k_base")
def _count_messages_tokens(messages: list[dict[str, Any]]) -> int:
"""Estimate the token count of the messages list using cl100k_base encoding."""
total = 0
for msg in messages:
content = msg.get("content") or ""
if isinstance(content, str):
total += len(_TIKTOKEN_ENCODING.encode(content))
total += count_cl100k_tokens(content)
elif isinstance(content, list):
for part in content:
if isinstance(part, dict) and isinstance(part.get("text"), str):
total += len(_TIKTOKEN_ENCODING.encode(part["text"]))
total += count_cl100k_tokens(part["text"])
# Tool call arguments and results also count
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict):
func = tc.get("function", {})
total += len(_TIKTOKEN_ENCODING.encode(func.get("arguments", "")))
total += count_cl100k_tokens(func.get("arguments", ""))
return total
@@ -672,7 +668,7 @@ async def run_reflect_agent(
# 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:
if max_tokens is not None and count_cl100k_tokens(answer) > max_tokens:
rewrite_start = time.time()
rewritten, rewrite_usage = await llm_config.call(
messages=[
@@ -10,9 +10,7 @@ The reflect agent uses hierarchical retrieval:
import json
from typing import Any
import tiktoken
_TIKTOKEN_ENCODING = tiktoken.get_encoding("cl100k_base")
from .tokenization import count_cl100k_tokens
# Fraction of max_context_tokens reserved for tool results in the final synthesis prompt.
# The remainder covers the system prompt, question, bank context, and output tokens.
@@ -453,7 +451,7 @@ def build_final_prompt(
except (TypeError, ValueError):
output_str = str(output)
block = f"\n### From {tool}:\n```json\n{output_str}\n```"
block_tokens = len(_TIKTOKEN_ENCODING.encode(block))
block_tokens = count_cl100k_tokens(block)
if block_tokens > token_budget:
truncated = True
break
@@ -0,0 +1,17 @@
"""Token counting helpers for reflect prompts and agent control flow."""
from functools import lru_cache
import tiktoken
@lru_cache(maxsize=1)
def _get_cl100k_base_encoding() -> tiktoken.Encoding:
# tiktoken downloads this encoding on first lookup when it is not cached.
# Keep the lookup lazy so importing hindsight_api does not depend on network access.
return tiktoken.get_encoding("cl100k_base")
def count_cl100k_tokens(text: str) -> int:
"""Return the number of cl100k_base tokens in text."""
return len(_get_cl100k_base_encoding().encode(text))
@@ -7,6 +7,7 @@ Implements hierarchical retrieval:
3. recall - Raw facts as ground truth
"""
import json
import logging
import uuid
from dataclasses import replace
@@ -22,6 +23,21 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def _document_metadata_from_retain_params(retain_params: Any) -> dict[str, Any] | None:
"""Return document metadata stored under retain_params.metadata."""
if isinstance(retain_params, str):
try:
retain_params = json.loads(retain_params)
except json.JSONDecodeError:
return None
if not isinstance(retain_params, dict):
return None
metadata = retain_params.get("metadata")
return metadata if isinstance(metadata, dict) else None
async def tool_search_mental_models(
memory_engine: "MemoryEngine",
conn: "Connection",
@@ -350,7 +366,7 @@ async def tool_expand(
if all_doc_ids:
docs = await conn.fetch(
f"""
SELECT id, original_text, metadata, retain_params
SELECT id, original_text, retain_params
FROM {fq_table("documents")}
WHERE id = ANY($1) AND bank_id = $2
""",
@@ -396,7 +412,7 @@ async def tool_expand(
item["document"] = {
"id": doc["id"],
"full_text": doc["original_text"],
"metadata": doc["metadata"],
"metadata": _document_metadata_from_retain_params(doc["retain_params"]),
"retain_params": doc["retain_params"],
}
elif memory["document_id"] and depth == "document" and memory["document_id"] in doc_map:
@@ -405,7 +421,7 @@ async def tool_expand(
item["document"] = {
"id": doc["id"],
"full_text": doc["original_text"],
"metadata": doc["metadata"],
"metadata": _document_metadata_from_retain_params(doc["retain_params"]),
"retain_params": doc["retain_params"],
}
@@ -1382,14 +1382,9 @@ async def _extract_facts_from_chunk(
f" (current value: {config.retain_max_completion_tokens}, must be > RETAIN_CHUNK_SIZE={config.retain_chunk_size})"
) from e
if "json_validate_failed" in str(e):
logger.warning(
f" [1.3.{chunk_index + 1}] Attempt {attempt + 1}/{llm_max_retries} failed with JSON validation error: {e}"
)
if attempt < llm_max_retries - 1:
logger.info(f" [1.3.{chunk_index + 1}] Retrying...")
continue
# If it's not a JSON validation error or we're out of retries, re-raise
# Don't retry json_validate_failed here — the inner provider
# loop already retried the 400 error. Re-entering the LLM call
# with the same input just multiplies wasted calls.
raise
# If we exhausted all retries, raise the last error or a descriptive fallback
@@ -1562,47 +1557,25 @@ async def extract_facts_from_text(
f"chunk_size={config.retain_chunk_size:,}) - starting parallel LLM extraction"
)
# Per-chunk retry wrapper: each chunk gets up to MAX_CHUNK_RETRIES attempts.
# This handles transient LLM failures (timeouts, rate limits, malformed responses)
# without discarding the entire batch. If a chunk still fails after all retries,
# the ENTIRE retain fails — we do not accept partial extraction.
MAX_CHUNK_RETRIES = 3
CHUNK_RETRY_BASE_DELAY = 2.0 # seconds, doubles each retry
async def _extract_chunk_with_retry(chunk: str, chunk_index: int) -> tuple:
"""Extract facts from a single chunk with retries on failure."""
last_exception = None
for attempt in range(MAX_CHUNK_RETRIES):
try:
return await _extract_facts_with_auto_split(
chunk=chunk,
chunk_index=chunk_index,
total_chunks=len(chunks),
event_date=event_date,
context=context,
llm_config=llm_config,
config=config,
agent_name=agent_name,
metadata=metadata,
)
except Exception as e:
last_exception = e
if attempt < MAX_CHUNK_RETRIES - 1:
delay = CHUNK_RETRY_BASE_DELAY * (2**attempt)
logger.warning(
f"Chunk {chunk_index}/{len(chunks)} extraction failed "
f"(attempt {attempt + 1}/{MAX_CHUNK_RETRIES}): "
f"{type(e).__name__}. Retrying in {delay:.0f}s..."
)
await asyncio.sleep(delay)
else:
logger.error(
f"Chunk {chunk_index}/{len(chunks)} extraction failed after "
f"{MAX_CHUNK_RETRIES} attempts: {type(e).__name__}: {e}"
)
raise last_exception
tasks = [_extract_chunk_with_retry(chunk, i) for i, chunk in enumerate(chunks)]
# Transient LLM failures (timeouts, rate limits) are already retried inside
# the provider's inner loop. Content-quality retries (malformed facts) are
# handled by the middle loop in _extract_facts_from_chunk. Adding a third
# retry layer here would multiply wasted calls on deterministic failures
# (see https://github.com/vectorize-io/hindsight/issues/1412).
tasks = [
_extract_facts_with_auto_split(
chunk=chunk,
chunk_index=i,
total_chunks=len(chunks),
event_date=event_date,
context=context,
llm_config=llm_config,
config=config,
agent_name=agent_name,
metadata=metadata,
)
for i, chunk in enumerate(chunks)
]
# return_exceptions=True so we can collect all results even if some chunks
# exhausted their retries. We check for failures below and fail the retain
@@ -1628,8 +1601,8 @@ async def extract_facts_from_text(
# hasn't committed yet. The worker poller will retry the entire task.
failed_summary = ", ".join(f"chunk {idx}: {type(err).__name__}" for idx, err in failed_chunks[:5])
raise RuntimeError(
f"Fact extraction failed: {len(failed_chunks)}/{len(chunks)} chunks failed "
f"after {MAX_CHUNK_RETRIES} retries each. First failures: {failed_summary}"
f"Fact extraction failed: {len(failed_chunks)}/{len(chunks)} chunks failed. "
f"First failures: {failed_summary}"
)
return all_facts, chunk_metadata, total_usage
@@ -191,21 +191,21 @@ class TagGroupLeaf(BaseModel):
class TagGroupAnd(BaseModel):
"""Compound AND group: all child filters must match."""
model_config = ConfigDict(populate_by_name=True)
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
filters: list[TagGroup] = Field(alias="and")
class TagGroupOr(BaseModel):
"""Compound OR group: at least one child filter must match."""
model_config = ConfigDict(populate_by_name=True)
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
filters: list[TagGroup] = Field(alias="or")
class TagGroupNot(BaseModel):
"""Compound NOT group: child filter must NOT match."""
model_config = ConfigDict(populate_by_name=True)
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
filter: TagGroup = Field(alias="not")
+17 -8
View File
@@ -28,6 +28,7 @@ from .config import DEFAULT_WORKERS, ENV_HOST, ENV_WORKERS, HindsightConfig, _ge
from .daemon import (
DEFAULT_DAEMON_PORT,
DEFAULT_IDLE_TIMEOUT,
ENV_DAEMON_CHILD,
IdleTimeoutMiddleware,
daemonize,
)
@@ -150,8 +151,15 @@ def main():
args = parser.parse_args()
# Daemon mode handling
if args.daemon:
# Daemon mode handling.
# is_daemon_child is True when we are the re-exec'd child spawned by
# daemonize() or by hindsight-embed's DaemonEmbedManager. The child
# does not have --daemon in its argv, but must still behave as a daemon
# (resolve host/port, enable idle timeout, suppress banner, etc.).
is_daemon_child = os.environ.get(ENV_DAEMON_CHILD) == "1"
is_daemon = args.daemon or is_daemon_child
if is_daemon:
args.host, args.port = resolve_daemon_host_port(
args_host=args.host,
args_port=args.port,
@@ -159,12 +167,13 @@ def main():
config_port=config.port,
)
# Fork into background
# No lockfile needed - port binding prevents duplicate daemons
# Detach into background (parent re-execs and exits; child redirects
# stdio to log file). No lockfile needed port binding prevents
# duplicate daemons.
daemonize()
# Print banner (not in daemon mode)
if not args.daemon:
if not is_daemon:
print()
print_banner()
@@ -173,7 +182,7 @@ def main():
if args.log_level != config.log_level:
config = dataclasses.replace(config, host=args.host, port=args.port, log_level=args.log_level)
config.configure_logging()
if not args.daemon:
if not is_daemon:
config.log_config()
# Register cleanup handlers
@@ -222,7 +231,7 @@ def main():
# Wrap with idle timeout middleware in daemon mode
idle_middleware = None
if args.daemon:
if is_daemon:
idle_middleware = IdleTimeoutMiddleware(app, idle_timeout=args.idle_timeout)
app = idle_middleware
@@ -277,7 +286,7 @@ def main():
uvicorn_config["ssl_certfile"] = args.ssl_certfile
# Print startup info (not in daemon mode)
if not args.daemon:
if not is_daemon:
from .banner import print_startup_info
print_startup_info(
@@ -478,6 +478,9 @@ def _migrate_table_embedding_dimension(
logger.info(f"Altering {table_name}.embedding column dimension from {current_dim} to {required_dimension}")
# Drop existing vector index (works for HNSW, DiskANN, vchordrq, and ScaNN)
# The EXCEPTION block handles 'could not open relation with OID' errors that
# occur when concurrent sessions drop schemas (e.g. pytest-xdist workers),
# invalidating pg_indexes OID references mid-cursor-iteration.
conn.execute(
text(f"""
DO $$
@@ -492,6 +495,9 @@ def _migrate_table_embedding_dimension(
LOOP
EXECUTE 'DROP INDEX IF EXISTS {schema_name}.' || idx_name;
END LOOP;
EXCEPTION WHEN internal_error THEN
-- Stale OID from concurrent schema drop; nothing to drop anyway
NULL;
END $$;
""")
)
@@ -14,7 +14,8 @@ import json
import logging
import time
import traceback
from collections.abc import Awaitable, Callable
from collections import Counter
from collections.abc import Awaitable, Callable, Iterable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
@@ -37,6 +38,36 @@ STUCK_STACK_INITIAL_THRESHOLD_S = 300
STUCK_STACK_MAX_THRESHOLD_S = 3600 * 6 # cap doubling at 6h
def _summarise_child_error_messages(siblings: "Iterable[Any]") -> str:
"""Pick a representative error message for a parent whose children failed.
Used when a batch_retain parent transitions to 'failed' because at least
one child sub-batch failed. Without this, the parent gets a generic
"One or more sub-batches failed" string and any consumer that reasons
about errors via error_message (dashboards, alert filters, log
aggregators) loses the actual cause -- a class of failures that all
share the same root reason at the child level becomes indistinguishable
at the parent level.
Strategy: pick the most common non-empty error_message among failed
siblings. If they all failed for the same reason (the common case), the
parent inherits that reason verbatim. If they vary, the most-common one
is still a useful representative. Falls back to the legacy generic
string when no failed sibling carries an error_message at all.
"""
failed_errors: list[str] = []
for s in siblings:
if s["status"] != "failed":
continue
msg = (s["error_message"] or "").strip()
if msg:
failed_errors.append(msg)
if not failed_errors:
return "One or more sub-batches failed"
most_common, _count = Counter(failed_errors).most_common(1)[0]
return most_common
@dataclass
class ActiveTaskInfo:
"""Tracking info for an in-flight worker task.
@@ -495,10 +526,14 @@ class WorkerPoller:
if not parent_row:
return
# Check whether all siblings are done
# Check whether all siblings are done. Pull error_message too so a
# parent that fails can inherit a representative child reason --
# otherwise the parent's error_message is generic ("One or more
# sub-batches failed") and downstream consumers (dashboards, alerts,
# filters) lose the actual cause once a batch has children.
siblings = await conn.fetch(
f"""
SELECT status FROM {table}
SELECT status, error_message FROM {table}
WHERE bank_id = $1
AND result_metadata::jsonb @> $2::jsonb
""",
@@ -517,7 +552,7 @@ class WorkerPoller:
WHERE operation_id = $1
""",
uuid.UUID(parent_operation_id),
"One or more sub-batches failed",
_summarise_child_error_messages(siblings),
)
else:
await conn.execute(
@@ -940,15 +975,30 @@ class WorkerPoller:
if len(processing_info) > 10:
processing_str += f" +{len(processing_info) - 10} more"
# Get global stats from DB
# Get global stats from DB — scope the heavy COUNT/GROUP BY
# queries to schemas that actually have work. With N tenants the
# full fanout is 2*N queries every PROGRESS_LOG_INTERVAL; scoping
# via the routine (or per-schema EXISTS fallback) reduces this to
# 2*active_schemas which is typically << N.
schemas = await self._get_schemas()
total_schema_count = len(schemas)
# Schemas with pending async_operations (uses server-side
# routine when installed, falls back to per-schema EXISTS).
schemas_with_pending = await self._scan_active_schemas(schemas)
# Also include schemas that have in-flight tasks on this worker
# so the "processing" worker_id GROUP BY still reports correctly.
schemas_with_active_tasks = {info.schema for info in active_tasks.values()}
schemas_to_query = schemas_with_pending | schemas_with_active_tasks
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._backend.acquire() as conn:
for schema in schemas:
for schema in schemas_to_query:
table = fq_table("async_operations", schema)
# Bucket pending rows by the same predicates the claim query
@@ -957,20 +1007,24 @@ class WorkerPoller:
# retry backoff, etc.).
# Use SUM(CASE WHEN ...) instead of COUNT(*) FILTER (WHERE ...)
# for Oracle compatibility — FILTER is PG-specific.
breakdown_rows = await conn.fetch(
f"""
SELECT
operation_type,
COUNT(*) AS total,
SUM(CASE WHEN task_payload IS NULL THEN 1 ELSE 0 END) AS payload_null,
SUM(CASE WHEN next_retry_at IS NOT NULL AND next_retry_at > now()
THEN 1 ELSE 0 END) AS retry_blocked,
SUM(CASE WHEN worker_id IS NOT NULL THEN 1 ELSE 0 END) AS assigned
FROM {table}
WHERE status = 'pending'
GROUP BY operation_type
"""
)
try:
breakdown_rows = await conn.fetch(
f"""
SELECT
operation_type,
COUNT(*) AS total,
SUM(CASE WHEN task_payload IS NULL THEN 1 ELSE 0 END) AS payload_null,
SUM(CASE WHEN next_retry_at IS NOT NULL AND next_retry_at > now()
THEN 1 ELSE 0 END) AS retry_blocked,
SUM(CASE WHEN worker_id IS NOT NULL THEN 1 ELSE 0 END) AS assigned
FROM {table}
WHERE status = 'pending'
GROUP BY operation_type
"""
)
except Exception:
# Schema may be partially provisioned (table missing).
breakdown_rows = []
for br in breakdown_rows:
op_type = br["operation_type"] or "unknown"
bucket = pending_breakdown.setdefault(
@@ -982,14 +1036,17 @@ class WorkerPoller:
bucket["assigned"] += br["assigned"]
global_pending += br["total"]
worker_rows = await conn.fetch(
f"""
SELECT worker_id, COUNT(*) as count
FROM {table}
WHERE status = 'processing'
GROUP BY worker_id
"""
)
try:
worker_rows = await conn.fetch(
f"""
SELECT worker_id, COUNT(*) as count
FROM {table}
WHERE status = 'processing'
GROUP BY worker_id
"""
)
except Exception:
worker_rows = []
for wr in worker_rows:
wid = wr["worker_id"] or "unknown"
all_worker_counts[wid] = all_worker_counts.get(wid, 0) + wr["count"]
@@ -1005,14 +1062,19 @@ class WorkerPoller:
pool_str = self._format_pool_stats()
proc_str = self._format_proc_stats()
# Display None as "default" in logs
schemas_str = ", ".join(s if s else "default" for s in schemas)
queried_count = len(schemas_to_query)
# Display queried schemas (cap at 20 for readability)
queried_list = sorted(s if s else "default" for s in schemas_to_query)
schemas_str = ", ".join(queried_list[:20])
if len(queried_list) > 20:
schemas_str += f" +{len(queried_list) - 20} more"
logger.info(
f"[WORKER_STATS] worker={self._worker_id} "
f"slots={in_flight}/{self._max_slots} | "
f"reserved: [{reserved_str}] | "
f"shared={tasks_in_shared}/{shared_pool_size}(avail={shared_available}) | "
f"global: pending={global_pending} (schemas: {schemas_str}) | "
f"global: pending={global_pending} "
f"(queried={queried_count}/{total_schema_count} schemas: {schemas_str}) | "
f"others: {others_str} | "
f"pool: {pool_str} | "
f"proc: {proc_str} | "
+3 -3
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api-slim"
version = "0.6.0"
version = "0.6.2"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
@@ -47,14 +47,14 @@ dependencies = [
"anthropic>=0.40.0",
"typer>=0.9.0",
"cohere>=5.0.0",
"litellm>=1.83.0", # 1.82.7/1.82.8 had a supply chain compromise (yanked); 1.83.0+ also fixes GHSA-jjhc-v7c2-5hh6 / GHSA-53mr-6c8q-9789
"litellm>=1.83.14", # 1.82.7/1.82.8 had a supply chain compromise (yanked); 1.83.0+ also fixes GHSA-jjhc-v7c2-5hh6 / GHSA-53mr-6c8q-9789 / GHSA-pq44-5pcq-4r5g / GHSA-8cjq-wjmh-q42r
"markitdown[pdf,docx,pptx,xlsx,xls]>=0.1.4", # File to markdown conversion
"obstore>=0.4.0", # S3/GCS/Azure object storage client (Rust-backed)
"winloop>=0.1.0; sys_platform == 'win32'",
"uvloop>=0.22.1; sys_platform != 'win32'",
# Transitive dependency security fixes
"pyasn1>=0.6.3", # DoS vulnerability fix
"urllib3>=2.6.3", # Decompression-bomb safeguards bypass fix
"urllib3>=2.7.0", # Decompression-bomb safeguards bypass + sensitive header forwarding fixes
"langchain-core>=1.2.22", # Path traversal in legacy load_prompt functions fix
"langsmith>=0.6.3", # SSRF via tracing header injection fix
"protobuf>=6.33.5", # JSON recursion depth bypass fix
@@ -0,0 +1,535 @@
"""Tests for Codex OAuth token refresh (issue #1637).
The Codex provider was originally a startup-only credential loader: it read
``~/.codex/auth.json`` once and used the cached access_token forever. These
tests pin the new automatic-refresh behavior:
- ``refresh_token`` is now actually loaded from auth.json.
- The provider proactively refreshes ~60s before the JWT ``exp`` claim.
- It reactively refreshes once on a 401/403 from the Codex backend.
- The OAuth refresh request shape mirrors the canonical ``@openai/codex``
CLI (POST https://auth.openai.com/oauth/token, JSON body with hardcoded
client_id, grant_type=refresh_token).
- Terminal error codes (refresh_token_expired/reused/invalidated) raise a
permanent error and do not loop.
- Concurrent callers serialize through a single-flight lock.
- ``auth.json`` is persisted atomically via tempfile+rename with mode 0600.
Tests construct ``CodexLLM`` with ``_load_codex_auth`` mocked, then drive
JWT exp / network / persistence paths through targeted patches.
"""
from __future__ import annotations
import asyncio
import base64
import json
import os
import stat
import sys
import time
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from hindsight_api.engine.providers.codex_llm import (
_CODEX_CLIENT_ID,
_CODEX_REFRESH_TOKEN_URL,
CodexLLM,
CodexRefreshExpiredError,
)
def _make_jwt(exp_unixtime: int | None) -> str:
"""Build a minimal JWT-shaped token with the given ``exp`` claim.
Signature segment is a placeholder — we don't verify, we only decode
the payload to read ``exp``.
"""
header = base64.urlsafe_b64encode(json.dumps({"alg": "none"}).encode()).rstrip(b"=").decode()
payload_dict: dict[str, object] = {}
if exp_unixtime is not None:
payload_dict["exp"] = exp_unixtime
payload = base64.urlsafe_b64encode(json.dumps(payload_dict).encode()).rstrip(b"=").decode()
signature = "sig"
return f"{header}.{payload}.{signature}"
def _build_llm(refresh_token: str | None = "rt-initial", access_token: str | None = None) -> CodexLLM:
"""Construct a CodexLLM with patched auth-file reads."""
if access_token is None:
access_token = _make_jwt(int(time.time()) + 3600) # fresh by default
with (
patch.object(CodexLLM, "_load_codex_auth", return_value=(access_token, "acct-123")),
patch.object(CodexLLM, "_load_codex_refresh_token", return_value=refresh_token),
):
return CodexLLM(
provider="openai-codex",
api_key="ignored",
base_url="https://chatgpt.com/backend-api",
model="gpt-5.4-mini",
)
# ---------------------------------------------------------------------------
# JWT exp decode
# ---------------------------------------------------------------------------
def test_jwt_exp_decode_returns_int_for_valid_token():
token = _make_jwt(1_800_000_000)
assert CodexLLM._decode_jwt_exp_unixtime(token) == 1_800_000_000
def test_jwt_exp_decode_returns_none_when_exp_missing():
token = _make_jwt(None)
assert CodexLLM._decode_jwt_exp_unixtime(token) is None
def test_jwt_exp_decode_returns_none_for_malformed_token():
assert CodexLLM._decode_jwt_exp_unixtime("not.a.real.jwt") is None
assert CodexLLM._decode_jwt_exp_unixtime("only-one-segment") is None
assert CodexLLM._decode_jwt_exp_unixtime("a.!!notbase64!!.c") is None
# ---------------------------------------------------------------------------
# Staleness
# ---------------------------------------------------------------------------
def test_token_is_stale_true_when_expired():
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(access_token=expired)
assert llm._token_is_stale() is True
def test_token_is_stale_true_within_skew_window():
# 30s before expiry, default skew is 60s → should be considered stale.
soon = _make_jwt(int(time.time()) + 30)
llm = _build_llm(access_token=soon)
assert llm._token_is_stale() is True
def test_token_is_stale_false_when_far_from_expiry():
far = _make_jwt(int(time.time()) + 3600)
llm = _build_llm(access_token=far)
assert llm._token_is_stale() is False
def test_token_is_stale_false_when_exp_unparseable():
# When we can't decide, we'd rather use a possibly-expired token and
# recover via the reactive 401 path than refresh aggressively.
llm = _build_llm(access_token="opaque-token-no-jwt-structure")
assert llm._token_is_stale() is False
# ---------------------------------------------------------------------------
# refresh_token loading
# ---------------------------------------------------------------------------
def test_refresh_token_loaded_from_auth_file(tmp_path: Path):
auth_file = tmp_path / "auth.json"
auth_file.write_text(
json.dumps(
{
"auth_mode": "chatgpt",
"tokens": {
"access_token": "at",
"refresh_token": "rt-from-disk",
"account_id": "acct",
},
}
)
)
with patch.object(CodexLLM, "_load_codex_auth", return_value=("at", "acct")):
llm = CodexLLM(
provider="openai-codex",
api_key="ignored",
base_url="https://chatgpt.com/backend-api",
model="gpt-5.4-mini",
)
# Now point the auth_file at our tmp file and reload.
llm._auth_file = auth_file
assert llm._load_codex_refresh_token() == "rt-from-disk"
def test_refresh_token_returns_none_when_field_absent(tmp_path: Path):
auth_file = tmp_path / "auth.json"
auth_file.write_text(json.dumps({"auth_mode": "chatgpt", "tokens": {"access_token": "at"}}))
llm = _build_llm()
llm._auth_file = auth_file
assert llm._load_codex_refresh_token() is None
def test_refresh_token_returns_none_when_file_missing(tmp_path: Path):
llm = _build_llm()
llm._auth_file = tmp_path / "definitely-not-here.json"
assert llm._load_codex_refresh_token() is None
# ---------------------------------------------------------------------------
# Atomic persistence
# ---------------------------------------------------------------------------
def test_persist_auth_atomic_writes_mode_0600_and_preserves_fields(tmp_path: Path):
auth_file = tmp_path / "auth.json"
auth_file.write_text(
json.dumps(
{
"OPENAI_API_KEY": None,
"auth_mode": "chatgpt",
"tokens": {
"access_token": "old",
"refresh_token": "rt-old",
"account_id": "acct-keep",
"id_token": {"email": "[email protected]"},
},
"last_refresh": "2026-01-01T00:00:00Z",
}
)
)
llm = _build_llm()
llm._auth_file = auth_file
llm._persist_auth_atomic({"access_token": "new", "refresh_token": "rt-new"})
written = json.loads(auth_file.read_text())
assert written["tokens"]["access_token"] == "new"
assert written["tokens"]["refresh_token"] == "rt-new"
# Untouched fields are preserved (account_id, id_token, auth_mode).
assert written["tokens"]["account_id"] == "acct-keep"
assert written["tokens"]["id_token"] == {"email": "[email protected]"}
assert written["auth_mode"] == "chatgpt"
# last_refresh got bumped to a new ISO-8601 UTC timestamp.
assert written["last_refresh"] != "2026-01-01T00:00:00Z"
assert written["last_refresh"].endswith("Z")
if sys.platform != "win32":
mode = stat.S_IMODE(auth_file.stat().st_mode)
assert mode == 0o600, f"expected 0600, got {oct(mode)}"
def test_persist_auth_atomic_does_not_leak_tempfile_on_success(tmp_path: Path):
auth_file = tmp_path / "auth.json"
auth_file.write_text(json.dumps({"tokens": {"access_token": "old"}}))
llm = _build_llm()
llm._auth_file = auth_file
llm._persist_auth_atomic({"access_token": "new"})
# No sibling tempfile should remain — atomic rename consumed it.
siblings = [p.name for p in tmp_path.iterdir()]
assert siblings == ["auth.json"], f"unexpected leftover files: {siblings}"
# ---------------------------------------------------------------------------
# _refresh_oauth_tokens — request shape, in-memory update, rotation
# ---------------------------------------------------------------------------
def _refresh_response(status_code: int, body: dict | str) -> MagicMock:
response = MagicMock()
response.status_code = status_code
if isinstance(body, dict):
response.json.return_value = body
response.text = json.dumps(body)
else:
response.json.side_effect = json.JSONDecodeError("nope", body, 0)
response.text = body
return response
@pytest.mark.asyncio
async def test_refresh_sends_canonical_request_shape(tmp_path: Path):
"""POST JSON body with client_id + grant_type=refresh_token + refresh_token."""
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(refresh_token="rt-current", access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt-current"}}))
fresh_access = _make_jwt(int(time.time()) + 3600)
refresh_resp = _refresh_response(200, {"access_token": fresh_access, "refresh_token": "rt-rotated"})
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=refresh_resp) as mock_post:
await llm._refresh_oauth_tokens()
call_args = mock_post.call_args
assert call_args.args[0] == _CODEX_REFRESH_TOKEN_URL
assert call_args.kwargs["headers"]["Content-Type"] == "application/json"
assert call_args.kwargs["json"] == {
"client_id": _CODEX_CLIENT_ID,
"grant_type": "refresh_token",
"refresh_token": "rt-current",
}
@pytest.mark.asyncio
async def test_refresh_updates_in_memory_credentials(tmp_path: Path):
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(refresh_token="rt-old", access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt-old"}}))
new_access = _make_jwt(int(time.time()) + 3600)
refresh_resp = _refresh_response(200, {"access_token": new_access, "refresh_token": "rt-new"})
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=refresh_resp):
await llm._refresh_oauth_tokens()
assert llm.access_token == new_access
assert llm.refresh_token == "rt-new"
@pytest.mark.asyncio
async def test_refresh_keeps_existing_refresh_token_when_server_omits_one(tmp_path: Path):
"""If the OAuth response has no ``refresh_token`` field, keep the one we have."""
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(refresh_token="rt-keep", access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt-keep"}}))
new_access = _make_jwt(int(time.time()) + 3600)
refresh_resp = _refresh_response(200, {"access_token": new_access})
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=refresh_resp):
await llm._refresh_oauth_tokens()
assert llm.refresh_token == "rt-keep"
@pytest.mark.asyncio
async def test_refresh_raises_permanent_error_on_terminal_oauth_code(tmp_path: Path):
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(refresh_token="rt-stale", access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt-stale"}}))
bad_resp = _refresh_response(401, {"error": {"code": "refresh_token_expired"}})
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=bad_resp):
with pytest.raises(CodexRefreshExpiredError):
await llm._refresh_oauth_tokens()
@pytest.mark.asyncio
async def test_refresh_raises_permanent_error_on_unknown_401(tmp_path: Path):
"""Any 401 from the refresh endpoint is treated as permanent — matches upstream Rust classification."""
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(refresh_token="rt-stale", access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt-stale"}}))
bad_resp = _refresh_response(401, {"error": "something_else"})
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=bad_resp):
with pytest.raises(CodexRefreshExpiredError):
await llm._refresh_oauth_tokens()
@pytest.mark.asyncio
async def test_refresh_raises_runtime_error_on_5xx(tmp_path: Path):
"""5xx is transient from the caller's perspective — surface as RuntimeError, not CodexRefreshExpiredError."""
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(refresh_token="rt-current", access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt-current"}}))
bad_resp = _refresh_response(503, "service unavailable")
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=bad_resp):
with pytest.raises(RuntimeError) as exc_info:
await llm._refresh_oauth_tokens()
assert not isinstance(exc_info.value, CodexRefreshExpiredError)
@pytest.mark.asyncio
async def test_refresh_does_not_log_token_values(tmp_path: Path, caplog):
expired = _make_jwt(int(time.time()) - 60)
secret_rt = "rt-DO-NOT-LEAK-THIS"
llm = _build_llm(refresh_token=secret_rt, access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": secret_rt}}))
new_access = _make_jwt(int(time.time()) + 3600)
refresh_resp = _refresh_response(200, {"access_token": new_access, "refresh_token": "rt-also-secret"})
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=refresh_resp):
with caplog.at_level("DEBUG"):
await llm._refresh_oauth_tokens()
log_text = "\n".join(record.getMessage() for record in caplog.records)
assert secret_rt not in log_text
assert new_access not in log_text
assert "rt-also-secret" not in log_text
# ---------------------------------------------------------------------------
# Single-flight under concurrent callers
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_concurrent_ensure_fresh_token_calls_produce_one_refresh(tmp_path: Path):
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(refresh_token="rt", access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt"}}))
new_access = _make_jwt(int(time.time()) + 3600)
call_count = 0
async def fake_post(*args, **kwargs):
nonlocal call_count
call_count += 1
# Simulate non-zero refresh latency so concurrent callers actually queue.
await asyncio.sleep(0.01)
return _refresh_response(200, {"access_token": new_access, "refresh_token": "rt-new"})
with patch.object(llm._client, "post", new=fake_post):
await asyncio.gather(*(llm._ensure_fresh_token() for _ in range(10)))
assert call_count == 1, f"expected 1 network refresh under contention, got {call_count}"
# ---------------------------------------------------------------------------
# Reactive 401 retry on the request path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_call_reactively_refreshes_on_401_and_retries(tmp_path: Path):
"""A backend 401 triggers one refresh + retry instead of immediately raising."""
fresh = _make_jwt(int(time.time()) + 3600) # not stale; the 401 is the trigger
llm = _build_llm(refresh_token="rt", access_token=fresh)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": fresh, "refresh_token": "rt"}}))
new_access = _make_jwt(int(time.time()) + 3600)
# First post → 401 (backend rejects the token). After refresh, second post → 200.
success_resp = MagicMock()
success_resp.status_code = 200
success_resp.raise_for_status.return_value = None
fail_response = MagicMock()
fail_response.status_code = 401
fail_response.text = "unauthorized"
fail_exc = httpx.HTTPStatusError("401", request=MagicMock(), response=fail_response)
success_resp.raise_for_status = MagicMock(return_value=None)
post_responses = [fail_exc, success_resp]
async def fake_post(*args, **kwargs):
item = post_responses.pop(0)
if isinstance(item, Exception):
raise item
return item
refresh_resp = _refresh_response(200, {"access_token": new_access, "refresh_token": "rt-new"})
call_count = {"refresh": 0, "post": 0}
async def counting_post(url, **kwargs):
if url == _CODEX_REFRESH_TOKEN_URL:
call_count["refresh"] += 1
return refresh_resp
call_count["post"] += 1
# First backend call fails with 401 wrapped in an HTTPStatusError-style response,
# second succeeds.
if call_count["post"] == 1:
raise httpx.HTTPStatusError("401", request=MagicMock(), response=fail_response)
return success_resp
with (
patch.object(llm._client, "post", new=counting_post),
patch.object(llm, "_parse_sse_stream", new_callable=AsyncMock, return_value="ok"),
):
result = await llm.call(
messages=[{"role": "user", "content": "ping"}],
max_retries=0,
initial_backoff=0.0,
max_backoff=0.0,
)
assert result == "ok"
assert call_count["refresh"] == 1
assert call_count["post"] == 2 # one 401, one success after refresh
assert llm.access_token == new_access
@pytest.mark.asyncio
async def test_call_proactively_refreshes_when_token_is_stale(tmp_path: Path):
"""A near-expiry token triggers refresh BEFORE the request is sent."""
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(refresh_token="rt", access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": expired, "refresh_token": "rt"}}))
new_access = _make_jwt(int(time.time()) + 3600)
refresh_resp = _refresh_response(200, {"access_token": new_access, "refresh_token": "rt-new"})
success_resp = MagicMock()
success_resp.status_code = 200
success_resp.raise_for_status.return_value = None
call_order: list[str] = []
async def fake_post(url, **kwargs):
if url == _CODEX_REFRESH_TOKEN_URL:
call_order.append("refresh")
return refresh_resp
call_order.append("backend")
# Assert that by the time the backend is called, the new token is in use.
assert kwargs["headers"]["Authorization"] == f"Bearer {new_access}"
return success_resp
with (
patch.object(llm._client, "post", new=fake_post),
patch.object(llm, "_parse_sse_stream", new_callable=AsyncMock, return_value="ok"),
):
await llm.call(
messages=[{"role": "user", "content": "ping"}],
max_retries=0,
initial_backoff=0.0,
max_backoff=0.0,
)
assert call_order == ["refresh", "backend"], "expected proactive refresh BEFORE the backend call"
@pytest.mark.asyncio
async def test_call_does_not_refresh_when_token_is_fresh(tmp_path: Path):
fresh = _make_jwt(int(time.time()) + 3600)
llm = _build_llm(refresh_token="rt", access_token=fresh)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": fresh, "refresh_token": "rt"}}))
success_resp = MagicMock()
success_resp.status_code = 200
success_resp.raise_for_status.return_value = None
call_count = {"refresh": 0, "backend": 0}
async def fake_post(url, **kwargs):
if url == _CODEX_REFRESH_TOKEN_URL:
call_count["refresh"] += 1
raise AssertionError("refresh endpoint should not be hit for a fresh token")
call_count["backend"] += 1
return success_resp
with (
patch.object(llm._client, "post", new=fake_post),
patch.object(llm, "_parse_sse_stream", new_callable=AsyncMock, return_value="ok"),
):
await llm.call(
messages=[{"role": "user", "content": "ping"}],
max_retries=0,
initial_backoff=0.0,
max_backoff=0.0,
)
assert call_count == {"refresh": 0, "backend": 1}
@@ -112,6 +112,35 @@ def _ensure_embedding_dimension_with_retry(db_url: str, dimension: int, schema:
raise
def _assert_raises_runtime_error_with_retry(
db_url: str,
dimension: int,
schema: str,
expected_messages: list[str],
):
"""Assert ensure_embedding_dimension raises RuntimeError, retrying on transient OID errors.
Concurrent xdist workers can cause 'could not open relation with OID' errors
that mask the expected RuntimeError. This retries to give the system a chance to
reach the actual dimension-mismatch check.
"""
import time
for attempt in range(3):
try:
ensure_embedding_dimension(db_url, dimension, schema=schema)
raise AssertionError("Expected RuntimeError but ensure_embedding_dimension succeeded")
except RuntimeError as e:
for msg in expected_messages:
assert msg in str(e), f"Expected '{msg}' in error message, got: {e}"
return
except Exception as e:
if "could not open relation with OID" in str(e) and attempt < 2:
time.sleep(0.5)
continue
raise
def get_column_dimension(db_url: str, schema: str = "public", table: str = "memory_units") -> int | None:
"""Get the current embedding column dimension from the database."""
engine = create_engine(db_url)
@@ -255,12 +284,12 @@ class TestEmbeddingDimension:
insert_test_embedding(db_url, schema, 384)
assert get_row_count(db_url, schema) == 1
# Try to change dimension - should raise error
with pytest.raises(RuntimeError) as exc_info:
ensure_embedding_dimension(db_url, 768, schema=schema)
assert "Cannot change embedding dimension" in str(exc_info.value)
assert "1 rows with embeddings" in str(exc_info.value)
# Try to change dimension - should raise RuntimeError.
# Retry on transient OID errors from concurrent xdist schema drops.
_assert_raises_runtime_error_with_retry(
db_url, 768, schema,
expected_messages=["Cannot change embedding dimension", "1 rows with embeddings"],
)
# Dimension should be unchanged
assert get_column_dimension(db_url, schema) == 384
@@ -300,11 +329,12 @@ class TestEmbeddingDimension:
clear_mental_model_embeddings(db_url, schema)
insert_test_mental_model_embedding(db_url, schema, 384)
with pytest.raises(RuntimeError) as exc_info:
ensure_embedding_dimension(db_url, 768, schema=schema)
assert "Cannot change embedding dimension" in str(exc_info.value)
assert "mental_models" in str(exc_info.value)
# Try to change dimension - should raise RuntimeError.
# Retry on transient OID errors from concurrent xdist schema drops.
_assert_raises_runtime_error_with_retry(
db_url, 768, schema,
expected_messages=["Cannot change embedding dimension", "mental_models"],
)
assert get_column_dimension(db_url, schema, table="mental_models") == 384
@@ -0,0 +1,88 @@
"""Tests for daemonize() — subprocess.Popen re-exec instead of os.fork()."""
import sys
from unittest.mock import MagicMock, patch
import pytest
def test_daemonize_parent_reexecs_via_popen(monkeypatch, tmp_path):
"""Parent path: daemonize() must spawn a child via subprocess.Popen and exit."""
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.delenv("_HINDSIGHT_DAEMON_CHILD", raising=False)
monkeypatch.setattr(sys, "argv", ["hindsight-api", "--daemon", "--port", "9999"])
log_path = tmp_path / "daemon.log"
monkeypatch.setattr("hindsight_api.daemon.DAEMON_LOG_PATH", log_path)
captured: dict = {}
def fake_popen(cmd, **kwargs):
captured["cmd"] = cmd
captured["kwargs"] = kwargs
proc = MagicMock()
proc.pid = 99999
return proc
with (
patch("hindsight_api.daemon.subprocess.Popen", side_effect=fake_popen),
pytest.raises(SystemExit) as exc_info,
):
from hindsight_api.daemon import daemonize
daemonize()
assert exc_info.value.code == 0
# Verify child command does NOT contain --daemon
assert "--daemon" not in captured["cmd"]
# Verify it uses the module entry point
assert "-m" in captured["cmd"]
assert "hindsight_api.main" in captured["cmd"]
# Verify remaining args are preserved
assert "--port" in captured["cmd"]
assert "9999" in captured["cmd"]
# Verify env has the daemon child marker
env = captured["kwargs"]["env"]
assert env["_HINDSIGHT_DAEMON_CHILD"] == "1"
# Verify detach kwargs
kwargs = captured["kwargs"]
assert kwargs.get("start_new_session") is True
def test_daemonize_child_does_not_reexec(monkeypatch, tmp_path):
"""Child path: when _HINDSIGHT_DAEMON_CHILD=1, daemonize() does NOT call
Popen — it only redirects stdio."""
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.setenv("_HINDSIGHT_DAEMON_CHILD", "1")
log_path = tmp_path / "daemon.log"
monkeypatch.setattr("hindsight_api.daemon.DAEMON_LOG_PATH", log_path)
with (
patch("hindsight_api.daemon.subprocess.Popen") as mock_popen,
patch("hindsight_api.daemon._redirect_stdio_to_log") as mock_redirect,
):
from hindsight_api.daemon import daemonize
daemonize()
mock_popen.assert_not_called()
mock_redirect.assert_called_once()
def test_daemonize_windows_noop(monkeypatch, tmp_path):
"""On Windows, daemonize() just creates the log directory."""
monkeypatch.setattr(sys, "platform", "win32")
log_path = tmp_path / "subdir" / "daemon.log"
monkeypatch.setattr("hindsight_api.daemon.DAEMON_LOG_PATH", log_path)
with patch("hindsight_api.daemon.subprocess.Popen") as mock_popen:
from hindsight_api.daemon import daemonize
daemonize()
mock_popen.assert_not_called()
assert log_path.parent.exists()
@@ -337,7 +337,21 @@ I prefer presenting in person rather than virtually because I can read the room
has_emotional = any(term in all_facts_text for term in [
"thrilled", "positive feedback", "positive", "feedback", "enthusiastic"
])
assert has_emotional, "Should preserve emotional dimension"
# Check preference - should capture the in-person vs virtual preference
has_preference = any(term in all_facts_text for term in [
"prefer", "rather than", "in person", "in-person", "virtually",
"read the room", "face-to-face", "face to face", "remote",
])
# MAT bar: at least one of emotional or preferential must be preserved.
# Smaller models (e.g. nova-2-lite) may compress both sentences into a
# single fact that only captures one dimension — that's acceptable for
# a minimum-acceptance test.
assert has_emotional or has_preference, (
f"Should preserve at least one of emotional or preferential dimension. "
f"Extracted facts: {all_facts_text}"
)
# Check no vague temporal terms
prohibited_terms = ["recently", "soon", "lately"]
@@ -345,12 +359,6 @@ I prefer presenting in person rather than virtually because I can read the room
assert len(found_prohibited) == 0, \
f"Should NOT use vague temporal terms. Found: {found_prohibited}"
# Check preference - should capture the in-person vs virtual preference
has_preference = any(term in all_facts_text for term in [
"prefer", "rather than", "in person", "virtually", "read the room"
])
assert has_preference, "Should preserve preferential dimension"
# =============================================================================
# TEMPORAL CONVERSION TESTS
@@ -0,0 +1,345 @@
"""
Tests for the LiteLLM Router LLM provider — config parsing, factory dispatch,
and the Router-backed call paths (plain text, structured output, tool calls,
retry on transient failure).
The provider is a thin pass-through to ``litellm.Router``. The chain config
shape mirrors LiteLLM's API; we don't translate model names or impose
fallbacks. See https://docs.litellm.ai/docs/routing.
"""
import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import BaseModel
from hindsight_api.config import (
ENV_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG,
ENV_LLM_LITELLMROUTER_CONFIG,
ENV_LLM_PROVIDER,
ENV_REFLECT_LLM_LITELLMROUTER_CONFIG,
ENV_RETAIN_LLM_LITELLMROUTER_CONFIG,
HindsightConfig,
_parse_llm_router_config,
)
from hindsight_api.engine.llm_wrapper import create_llm_provider
from hindsight_api.engine.providers.litellm_router_llm import LiteLLMRouterLLM
@pytest.fixture
def two_step_config() -> dict[str, Any]:
"""Raw LiteLLM Router config: two deployments wired for ordered fallback.
Hindsight always issues completions against ``model_name="default"``;
additional groups become fallback / load-balance pool members per the
user's ``fallbacks`` / ``routing_strategy`` settings.
"""
return {
"model_list": [
{
"model_name": "default",
"litellm_params": {
"model": "openai/MiniMax-M2.7",
"api_key": "sk-primary",
"api_base": "https://api.minimax.io/v1",
},
},
{
"model_name": "fallback",
"litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-fallback"},
},
],
"fallbacks": [{"default": ["fallback"]}],
"num_retries": 0,
}
@pytest.fixture
def mock_router_response() -> MagicMock:
response = MagicMock()
choice = MagicMock()
choice.message.content = "ok"
choice.message.tool_calls = None
choice.finish_reason = "stop"
response.choices = [choice]
response.usage.prompt_tokens = 12
response.usage.completion_tokens = 3
response._hidden_params = {"model": "openai/gpt-4o-mini"}
return response
# --- config parsing ----------------------------------------------------------
class TestParseRouterConfig:
def test_unset_returns_none(self, monkeypatch):
monkeypatch.delenv(ENV_LLM_LITELLMROUTER_CONFIG, raising=False)
assert _parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG) is None
def test_empty_string_returns_none(self, monkeypatch):
monkeypatch.setenv(ENV_LLM_LITELLMROUTER_CONFIG, " ")
assert _parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG) is None
def test_valid_config_passes_through(self, monkeypatch, two_step_config):
"""Whatever the user provides round-trips verbatim — no translation."""
monkeypatch.setenv(ENV_LLM_LITELLMROUTER_CONFIG, json.dumps(two_step_config))
assert _parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG) == two_step_config
def test_invalid_json(self, monkeypatch):
monkeypatch.setenv(ENV_LLM_LITELLMROUTER_CONFIG, "{not json")
with pytest.raises(ValueError, match="invalid JSON"):
_parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG)
def test_no_shape_validation(self, monkeypatch):
"""We don't validate the shape — anything that parses as JSON gets passed through.
LiteLLM Router is authoritative for shape errors; we let them surface at
Router construction time rather than pre-validating.
"""
# A list, a string, an object with junk keys — all accepted by the parser.
monkeypatch.setenv(ENV_LLM_LITELLMROUTER_CONFIG, json.dumps([{"hello": "world"}]))
assert _parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG) == [{"hello": "world"}]
monkeypatch.setenv(ENV_LLM_LITELLMROUTER_CONFIG, json.dumps({"only": "garbage"}))
assert _parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG) == {"only": "garbage"}
class TestFromEnvLoadsConfig:
def test_loaded_when_provider_is_litellmrouter(self, monkeypatch, two_step_config):
monkeypatch.setenv(ENV_LLM_PROVIDER, "litellmrouter")
monkeypatch.setenv(ENV_LLM_LITELLMROUTER_CONFIG, json.dumps(two_step_config))
cfg = HindsightConfig.from_env()
assert cfg.llm_provider == "litellmrouter"
assert cfg.llm_litellmrouter_config == two_step_config
def test_unset_keeps_default_provider(self, monkeypatch):
monkeypatch.setenv(ENV_LLM_PROVIDER, "openai")
monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "sk-primary")
monkeypatch.delenv(ENV_LLM_LITELLMROUTER_CONFIG, raising=False)
cfg = HindsightConfig.from_env()
assert cfg.llm_provider == "openai"
assert cfg.llm_litellmrouter_config is None
def test_per_op_configs_independent(self, monkeypatch):
"""Per-op env vars populate per-op fields without touching the default."""
retain_config = {
"model_list": [{"model_name": "r", "litellm_params": {"model": "openai/retain", "api_key": "rk"}}]
}
reflect_config = {
"model_list": [{"model_name": "f", "litellm_params": {"model": "anthropic/claude", "api_key": "ak"}}]
}
consol_config = {
"model_list": [{"model_name": "c", "litellm_params": {"model": "openai/consol", "api_key": "ck"}}]
}
monkeypatch.setenv(ENV_LLM_PROVIDER, "openai")
monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "sk-primary")
monkeypatch.setenv(ENV_RETAIN_LLM_LITELLMROUTER_CONFIG, json.dumps(retain_config))
monkeypatch.setenv(ENV_REFLECT_LLM_LITELLMROUTER_CONFIG, json.dumps(reflect_config))
monkeypatch.setenv(ENV_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG, json.dumps(consol_config))
cfg = HindsightConfig.from_env()
assert cfg.llm_litellmrouter_config is None
assert cfg.retain_llm_litellmrouter_config == retain_config
assert cfg.reflect_llm_litellmrouter_config == reflect_config
assert cfg.consolidation_llm_litellmrouter_config == consol_config
# --- factory dispatch --------------------------------------------------------
class TestFactoryDispatch:
def test_router_provider_requires_config(self):
with pytest.raises(ValueError, match="config object"):
create_llm_provider(
provider="litellmrouter",
api_key="",
base_url="",
model="unused",
reasoning_effort="low",
litellmrouter_config=None,
)
def test_router_provider_returns_router_impl(self, two_step_config):
with patch.dict("sys.modules", {"litellm": MagicMock()}):
with patch(
"hindsight_api.engine.providers.litellm_router_llm.LiteLLMRouterLLM.__init__",
return_value=None,
) as mock_init:
impl = create_llm_provider(
provider="litellmrouter",
api_key="",
base_url="",
model="unused",
reasoning_effort="low",
litellmrouter_config=two_step_config,
)
assert isinstance(impl, LiteLLMRouterLLM)
_, kwargs = mock_init.call_args
assert kwargs["config"] == two_step_config
# --- Router-backed call paths ------------------------------------------------
def _make_router_provider(config: dict[str, Any], mock_router: Any) -> LiteLLMRouterLLM:
"""Construct a LiteLLMRouterLLM with the inner Router replaced by a mock."""
fake_litellm = MagicMock()
fake_litellm.Router = MagicMock(return_value=mock_router)
with patch.dict("sys.modules", {"litellm": fake_litellm}):
# Bypass the heavy ctor chain by injecting state directly.
provider = LiteLLMRouterLLM.__new__(LiteLLMRouterLLM)
provider.provider = "litellmrouter"
provider.api_key = ""
provider.base_url = ""
provider.model = "unused"
provider.reasoning_effort = "low"
provider.timeout = 300.0
provider.config = config
provider._litellm = fake_litellm
provider._router = mock_router
provider._router_output_cap = None # tests that exercise the cap override this directly
return provider
class TestRouterCall:
@pytest.mark.asyncio
async def test_plain_text_call_targets_default_entrypoint(self, two_step_config, mock_router_response):
mock_router = MagicMock()
mock_router.acompletion = AsyncMock(return_value=mock_router_response)
provider = _make_router_provider(two_step_config, mock_router)
result = await provider.call(
messages=[{"role": "user", "content": "hi"}],
max_completion_tokens=50,
max_retries=0,
)
assert result == "ok"
# Hindsight always issues against model_name="default"; Router handles fallback,
# load-balancing, and routing strategy from there.
kwargs = mock_router.acompletion.await_args.kwargs
assert kwargs["model"] == "default"
@pytest.mark.asyncio
async def test_structured_output(self, two_step_config):
class MySchema(BaseModel):
answer: str
response = MagicMock()
choice = MagicMock()
choice.message.content = '{"answer": "42"}'
choice.message.tool_calls = None
choice.finish_reason = "stop"
response.choices = [choice]
response.usage.prompt_tokens = 5
response.usage.completion_tokens = 5
response._hidden_params = {"model": "openai/gpt-4o-mini"}
mock_router = MagicMock()
mock_router.acompletion = AsyncMock(return_value=response)
provider = _make_router_provider(two_step_config, mock_router)
result = await provider.call(
messages=[{"role": "user", "content": "q"}],
response_format=MySchema,
max_retries=0,
)
assert isinstance(result, MySchema)
assert result.answer == "42"
@pytest.mark.asyncio
async def test_retry_on_transient_then_success(self, two_step_config, mock_router_response):
mock_router = MagicMock()
# First call raises a 503-style error, second call returns ok.
mock_router.acompletion = AsyncMock(side_effect=[Exception("503 Service Unavailable"), mock_router_response])
provider = _make_router_provider(two_step_config, mock_router)
result = await provider.call(
messages=[{"role": "user", "content": "hi"}],
max_retries=2,
initial_backoff=0.0,
max_backoff=0.0,
)
assert result == "ok"
assert mock_router.acompletion.await_count == 2
@pytest.mark.asyncio
async def test_auth_error_does_not_retry(self, two_step_config):
mock_router = MagicMock()
mock_router.acompletion = AsyncMock(side_effect=Exception("401 Unauthorized: bad key"))
provider = _make_router_provider(two_step_config, mock_router)
with pytest.raises(Exception, match="401"):
await provider.call(
messages=[{"role": "user", "content": "hi"}],
max_retries=5,
initial_backoff=0.0,
)
assert mock_router.acompletion.await_count == 1
@pytest.mark.asyncio
async def test_caps_max_completion_tokens_to_litellm_registry(self, two_step_config, mock_router_response):
"""Cap max_completion_tokens to the most conservative deployment limit.
Hindsight's defaults (e.g. retain_max_completion_tokens=64000) target
high-capacity models. When a configured deployment has a smaller cap
(gpt-4.1-nano = 32768), the call would otherwise be rejected — apply
the cap silently using LiteLLM's per-model registry.
"""
mock_router = MagicMock()
mock_router.acompletion = AsyncMock(return_value=mock_router_response)
provider = _make_router_provider(two_step_config, mock_router)
provider._router_output_cap = 32768 # what _compute_router_output_cap would yield
await provider.call(
messages=[{"role": "user", "content": "hi"}],
max_completion_tokens=64000, # over the cap
max_retries=0,
)
kwargs = mock_router.acompletion.await_args.kwargs
assert kwargs["max_completion_tokens"] == 32768
@pytest.mark.asyncio
async def test_no_cap_when_litellm_registry_has_no_data(self, two_step_config, mock_router_response):
"""If LiteLLM doesn't know any of the deployment models, pass the requested value through."""
mock_router = MagicMock()
mock_router.acompletion = AsyncMock(return_value=mock_router_response)
provider = _make_router_provider(two_step_config, mock_router)
provider._router_output_cap = None
await provider.call(
messages=[{"role": "user", "content": "hi"}],
max_completion_tokens=64000,
max_retries=0,
)
kwargs = mock_router.acompletion.await_args.kwargs
assert kwargs["max_completion_tokens"] == 64000
@pytest.mark.asyncio
async def test_call_with_tools(self, two_step_config):
response = MagicMock()
choice = MagicMock()
choice.message.content = None
tool_call = MagicMock()
tool_call.id = "call_1"
tool_call.function.name = "lookup"
tool_call.function.arguments = '{"q": "x"}'
choice.message.tool_calls = [tool_call]
choice.finish_reason = "tool_calls"
response.choices = [choice]
response.usage.prompt_tokens = 5
response.usage.completion_tokens = 2
response._hidden_params = {"model": "openai/gpt-4o-mini"}
mock_router = MagicMock()
mock_router.acompletion = AsyncMock(return_value=response)
provider = _make_router_provider(two_step_config, mock_router)
result = await provider.call_with_tools(
messages=[{"role": "user", "content": "use tool"}],
tools=[{"type": "function", "function": {"name": "lookup", "parameters": {}}}],
max_retries=0,
)
assert result.content is None
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "lookup"
assert result.tool_calls[0].arguments == {"q": "x"}
@@ -0,0 +1,48 @@
from hindsight_api.api.http import MentalModelTrigger
from hindsight_api.engine.search.tags import TagGroupOr
def test_mental_model_trigger_model_dump_preserves_or_tag_group():
trigger = MentalModelTrigger.model_validate(
{
"tag_groups": [
{
"or": [
{"tags": ["ns:a"], "match": "all_strict"},
{"tags": ["ns:b"], "match": "all_strict"},
]
}
]
}
)
dumped = trigger.model_dump()
assert dumped["tag_groups"] == [
{
"or": [
{"tags": ["ns:a"], "match": "all_strict"},
{"tags": ["ns:b"], "match": "all_strict"},
]
}
]
def test_mental_model_trigger_or_tag_group_survives_storage_round_trip():
trigger = MentalModelTrigger.model_validate(
{
"tag_groups": [
{
"or": [
{"tags": ["ns:a"], "match": "all_strict"},
{"tags": ["ns:b"], "match": "all_strict"},
]
}
]
}
)
round_tripped = MentalModelTrigger.model_validate(trigger.model_dump())
assert isinstance(round_tripped.tag_groups[0], TagGroupOr)
assert round_tripped.model_dump()["tag_groups"] == trigger.model_dump()["tag_groups"]
@@ -1,5 +1,5 @@
from unittest.mock import AsyncMock, MagicMock, patch
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import BaseModel
@@ -52,6 +52,24 @@ async def test_json_object_call_adds_json_hint_to_user_message():
assert sent_messages[0]["content"].startswith("Return valid json only.")
@pytest.mark.asyncio
async def test_json_object_call_strips_gemma_thought_tags_before_parsing():
llm = _llm()
create = AsyncMock(
return_value=_response(content='<thought>\nI should return a compact JSON object.\n</thought>\n{"ok": true}')
)
llm._client.chat.completions.create = create
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
result = await llm.call(
messages=[{"role": "user", "content": "Return whether this worked."}],
response_format=SimpleJsonResponse,
max_retries=0,
)
assert result.ok is True
@pytest.mark.asyncio
async def test_error_payload_with_no_choices_raises_clear_provider_error_without_retry():
llm = _llm()
@@ -0,0 +1,80 @@
"""Tests for the opencode-go OpenAI-compatible LLM provider."""
import pytest
def test_opencode_go_config_has_expected_default_model(monkeypatch):
"""HindsightConfig should default opencode-go to the DeepSeek v4 flash model."""
from hindsight_api.config import PROVIDER_DEFAULT_MODELS, HindsightConfig, clear_config_cache
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "opencode-go")
monkeypatch.delenv("HINDSIGHT_API_LLM_MODEL", raising=False)
clear_config_cache()
try:
assert PROVIDER_DEFAULT_MODELS["opencode-go"] == "deepseek-v4-flash"
config = HindsightConfig.from_env()
assert config.llm_provider == "opencode-go"
assert config.llm_model == "deepseek-v4-flash"
finally:
clear_config_cache()
def test_opencode_go_llm_provider_from_env_has_expected_default_model(monkeypatch):
"""LLMProvider.from_env should use the opencode-go provider default model."""
from hindsight_api.config import clear_config_cache
from hindsight_api.engine.llm_wrapper import LLMProvider
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "opencode-go")
monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "test-key")
monkeypatch.delenv("HINDSIGHT_API_LLM_MODEL", raising=False)
monkeypatch.delenv("HINDSIGHT_API_LLM_BASE_URL", raising=False)
clear_config_cache()
try:
llm = LLMProvider.from_env()
assert llm.provider == "opencode-go"
assert llm.model == "deepseek-v4-flash"
assert llm.base_url == "https://opencode.ai/zen/go/v1"
finally:
clear_config_cache()
def test_opencode_go_requires_api_key_like_zai():
"""opencode-go is a cloud provider and should require an API key."""
from hindsight_api.engine.llm_wrapper import requires_api_key
assert requires_api_key("opencode-go") is True
def test_opencode_go_uses_openai_compatible_provider_with_default_base_url():
"""The provider factory should route opencode-go to OpenAICompatibleLLM."""
from hindsight_api.engine.llm_wrapper import LLMProvider
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
llm = LLMProvider(
provider="opencode-go",
api_key="test-key",
base_url="",
model="deepseek-v4-flash",
)
assert llm.provider == "opencode-go"
assert llm.model == "deepseek-v4-flash"
assert llm.base_url == "https://opencode.ai/zen/go/v1"
assert not llm.base_url.endswith("/")
assert isinstance(llm._provider_impl, OpenAICompatibleLLM)
assert llm._provider_impl.base_url == "https://opencode.ai/zen/go/v1"
def test_opencode_go_rejects_missing_api_key():
"""opencode-go should fail fast without an API key, matching zai behavior."""
from hindsight_api.engine.llm_wrapper import LLMProvider
with pytest.raises(ValueError, match="API key is required for opencode-go"):
LLMProvider(
provider="opencode-go",
api_key="",
base_url="",
model="deepseek-v4-flash",
)
@@ -0,0 +1,41 @@
import importlib
import sys
from unittest.mock import MagicMock, patch
def _drop_reflect_modules() -> None:
for name in list(sys.modules):
if name == "hindsight_api.engine.reflect" or name.startswith("hindsight_api.engine.reflect."):
sys.modules.pop(name)
def test_reflect_import_does_not_load_tiktoken_encoding():
_drop_reflect_modules()
with patch("tiktoken.get_encoding") as get_encoding:
reflect = importlib.import_module("hindsight_api.engine.reflect")
get_encoding.assert_not_called()
assert reflect.run_reflect_agent is not None
def test_reflect_token_counting_loads_tiktoken_encoding_when_used():
_drop_reflect_modules()
fake_encoding = MagicMock()
fake_encoding.encode.side_effect = lambda text: text.split()
with patch("tiktoken.get_encoding", return_value=fake_encoding) as get_encoding:
agent = importlib.import_module("hindsight_api.engine.reflect.agent")
prompts = importlib.import_module("hindsight_api.engine.reflect.prompts")
count = agent._count_messages_tokens([{"role": "user", "content": "one two"}])
final_prompt = prompts.build_final_prompt(
query="What happened?",
context_history=[{"tool": "recall", "output": {"answer": "three four"}}],
bank_profile={"name": "test"},
max_context_tokens=1000,
)
assert count == 2
assert "three four" in final_prompt
get_encoding.assert_called_once_with("cl100k_base")
@@ -0,0 +1,120 @@
"""Regression tests for reflect tool helpers."""
import re
import uuid
import pytest
from hindsight_api.engine.reflect.tools import _document_metadata_from_retain_params, tool_expand
class _FakeReflectConnection:
"""Tiny asyncpg-like connection for tool_expand query behavior."""
def __init__(self, bank_id: str, memory_id: uuid.UUID, document_id: str, chunk_id: str | None) -> None:
self.bank_id = bank_id
self.memory_id = memory_id
self.document_id = document_id
self.chunk_id = chunk_id
async def fetch(self, query: str, *args):
normalized_query = re.sub(r"\s+", " ", query).strip()
if "FROM public.memory_units" in normalized_query:
return [
{
"id": self.memory_id,
"text": "The user prefers test-first bug fixes.",
"chunk_id": self.chunk_id,
"document_id": self.document_id,
"fact_type": "experience",
"context": "preference",
}
]
if "FROM public.chunks" in normalized_query:
if self.chunk_id is None:
return []
return [
{
"chunk_id": self.chunk_id,
"chunk_text": "The user prefers test-first bug fixes.",
"chunk_index": 0,
"document_id": self.document_id,
}
]
if "FROM public.documents" in normalized_query:
select_clause = normalized_query.split(" FROM ", 1)[0]
assert " metadata," not in f" {select_clause},", (
"tool_expand must not query documents.metadata; that column was removed and "
"document metadata now lives in retain_params.metadata"
)
return [
{
"id": self.document_id,
"original_text": "The user prefers test-first bug fixes.",
"retain_params": {"metadata": {"source": "regression-test"}},
}
]
raise AssertionError(f"Unexpected query: {normalized_query}")
@pytest.mark.asyncio
async def test_tool_expand_document_depth_reads_metadata_from_retain_params() -> None:
"""Document expansion must work after documents.metadata has been dropped."""
bank_id = "test-reflect-expand-retain-params-metadata"
memory_id = uuid.uuid4()
document_id = "doc-reflect-expand"
chunk_id = "chunk-reflect-expand"
conn = _FakeReflectConnection(bank_id, memory_id, document_id, chunk_id)
result = await tool_expand(
conn=conn,
bank_id=bank_id,
memory_ids=[str(memory_id)],
depth="document",
)
assert result["count"] == 1
document = result["results"][0]["document"]
assert document["metadata"] == {"source": "regression-test"}
assert document["retain_params"] == {"metadata": {"source": "regression-test"}}
@pytest.mark.asyncio
async def test_tool_expand_document_depth_without_chunk_reads_metadata_from_retain_params() -> None:
"""Direct document expansion follows the same metadata source contract."""
bank_id = "test-reflect-expand-direct-retain-params-metadata"
memory_id = uuid.uuid4()
document_id = "doc-reflect-expand-direct"
conn = _FakeReflectConnection(bank_id, memory_id, document_id, chunk_id=None)
result = await tool_expand(
conn=conn,
bank_id=bank_id,
memory_ids=[str(memory_id)],
depth="document",
)
assert result["count"] == 1
document = result["results"][0]["document"]
assert document["metadata"] == {"source": "regression-test"}
assert document["retain_params"] == {"metadata": {"source": "regression-test"}}
def test_document_metadata_from_retain_params_accepts_json_strings() -> None:
"""asyncpg JSONB codecs may return retain_params as a dict or JSON string."""
retain_params = '{"metadata": {"source": "json-string"}}'
assert _document_metadata_from_retain_params(retain_params) == {"source": "json-string"}
@pytest.mark.parametrize(
"retain_params",
[None, [], "not json", {"metadata": ["not", "a", "dict"]}],
)
def test_document_metadata_from_retain_params_ignores_invalid_values(retain_params) -> None:
"""Malformed retain_params should not break reflect expansion."""
assert _document_metadata_from_retain_params(retain_params) is None
+81 -1
View File
@@ -2394,6 +2394,75 @@ async def test_pending_breakdown_explains_unclaimable_rows(pool, backend, clean_
assert buckets["consolidation"]["claimable"] >= 1
class TestSummariseChildErrorMessages:
"""Pure unit tests for the _summarise_child_error_messages helper.
The helper picks a representative error message for a parent whose
children failed. The integration tests above exercise the full path
through _mark_failed; these tests focus on the choice itself.
"""
def _sib(self, status: str, error_message: str | None = None) -> dict:
return {"status": status, "error_message": error_message}
def test_all_failed_with_same_message_inherits_that_message(self):
from hindsight_api.worker.poller import _summarise_child_error_messages
siblings = [
self._sib("failed", "boom"),
self._sib("failed", "boom"),
self._sib("failed", "boom"),
]
assert _summarise_child_error_messages(siblings) == "boom"
def test_mixed_failed_messages_picks_most_common(self):
from hindsight_api.worker.poller import _summarise_child_error_messages
siblings = [
self._sib("failed", "common cause"),
self._sib("failed", "common cause"),
self._sib("failed", "rare cause"),
]
assert _summarise_child_error_messages(siblings) == "common cause"
def test_completed_siblings_ignored(self):
from hindsight_api.worker.poller import _summarise_child_error_messages
siblings = [
self._sib("completed", None),
self._sib("completed", None),
self._sib("failed", "the one real failure"),
]
assert _summarise_child_error_messages(siblings) == "the one real failure"
def test_no_failed_siblings_falls_back_to_generic(self):
from hindsight_api.worker.poller import _summarise_child_error_messages
siblings = [
self._sib("completed", None),
self._sib("completed", None),
]
assert _summarise_child_error_messages(siblings) == "One or more sub-batches failed"
def test_failed_siblings_with_no_error_message_falls_back_to_generic(self):
from hindsight_api.worker.poller import _summarise_child_error_messages
siblings = [
self._sib("failed", None),
self._sib("failed", ""),
]
assert _summarise_child_error_messages(siblings) == "One or more sub-batches failed"
def test_whitespace_only_messages_treated_as_empty(self):
from hindsight_api.worker.poller import _summarise_child_error_messages
siblings = [
self._sib("failed", " "),
self._sib("failed", "actual error"),
]
assert _summarise_child_error_messages(siblings) == "actual error"
class TestMarkFailedParentPropagation:
"""Tests for _mark_failed parent propagation in WorkerPoller.
@@ -2473,10 +2542,21 @@ class TestMarkFailedParentPropagation:
assert "DB constraint violation" in child2_row["error_message"]
# parent must now be failed (all siblings done, at least one failed)
parent_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", parent_id)
parent_row = await pool.fetchrow(
"SELECT status, error_message FROM async_operations WHERE operation_id = $1",
parent_id,
)
assert parent_row["status"] == "failed", (
f"Parent should be 'failed' when last sibling fails, got '{parent_row['status']}'"
)
# Parent error_message must propagate the child's actual error reason,
# not the legacy generic "One or more sub-batches failed". Without this,
# downstream filters that classify failures by error_message lose all
# signal once a batch has children.
assert "DB constraint violation" in (parent_row["error_message"] or ""), (
f"Parent error_message should inherit child's reason, "
f"got: {parent_row['error_message']!r}"
)
@pytest.mark.asyncio
async def test_mark_failed_finalises_parent_when_last_sibling_is_sole_child(self, pool, backend, clean_operations):
+2 -2
View File
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-api"
version = "0.6.0"
version = "0.6.2"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim[all]==0.6.0",
"hindsight-api-slim[all]==0.6.2",
]
[tool.uv.sources]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hindsight-cli"
version = "0.6.0"
version = "0.6.2"
edition = "2021"
authors = ["Hindsight Team"]
description = "A beautiful CLI for Hindsight - semantic memory system"
+2 -2
View File
@@ -1262,8 +1262,8 @@ impl ApiClient {
// Re-export types from the generated client for use in commands
pub use types::{
BankProfileResponse, MemoryItem, RecallRequest, RecallResponse, RecallResult, ReflectRequest,
ReflectResponse, RetainRequest,
BankProfileResponse, MemoryItem, MemoryItemTimestamp, RecallRequest, RecallResponse,
RecallResult, ReflectRequest, ReflectResponse, RetainRequest,
};
#[cfg(test)]
+14 -2
View File
@@ -3,7 +3,9 @@ use std::fs;
use std::path::PathBuf;
use walkdir::WalkDir;
use crate::api::{ApiClient, MemoryItem, RecallRequest, ReflectRequest, RetainRequest};
use crate::api::{
ApiClient, MemoryItem, MemoryItemTimestamp, RecallRequest, ReflectRequest, RetainRequest,
};
use crate::config;
use crate::output::{self, OutputFormat};
use crate::ui;
@@ -438,6 +440,7 @@ pub fn retain(
content: String,
doc_id: Option<String>,
context: Option<String>,
timestamp: Option<String>,
r#async: bool,
document_tags: Option<Vec<String>>,
verbose: bool,
@@ -451,11 +454,20 @@ pub fn retain(
None
};
// MemoryItem.timestamp is a progenitor anyOf enum; round-trip through JSON to pick the matching variant.
let timestamp = match timestamp {
Some(s) => Some(
serde_json::from_value::<MemoryItemTimestamp>(serde_json::Value::String(s.clone()))
.with_context(|| format!("invalid --timestamp value: {:?}", s))?,
),
None => None,
};
let item = MemoryItem {
content: content.clone(),
context,
metadata: None,
timestamp: None,
timestamp,
document_id: Some(doc_id.clone()),
entities: None,
tags: None,
+8
View File
@@ -591,6 +591,12 @@ enum MemoryCommands {
#[arg(short = 'c', long)]
context: Option<String>,
/// When the content occurred (ISO 8601 datetime, e.g. 2024-01-15T10:30:00Z
/// or 2024-01-15). Pass "unset" to store without a timestamp.
/// Omit to default to now.
#[arg(short = 't', long)]
timestamp: Option<String>,
/// Queue for background processing
#[arg(long)]
r#async: bool,
@@ -1434,6 +1440,7 @@ fn run() -> Result<()> {
content,
doc_id,
context,
timestamp,
r#async,
document_tags,
} => commands::memory::retain(
@@ -1442,6 +1449,7 @@ fn run() -> Result<()> {
content,
doc_id,
context,
timestamp,
r#async,
document_tags,
verbose,
+19
View File
@@ -91,6 +91,25 @@ fn test_ui_command_with_config() {
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_memory_retain_exposes_timestamp_flag() {
// Regression: `hindsight memory retain` historically had no way to set the
// memory's event date even though the SDKs do. The flag must appear in
// --help so users (and docs) can discover it.
let output = Command::new("cargo")
.args(["run", "--", "memory", "retain", "--help"])
.output()
.expect("Failed to execute command");
assert!(output.status.success(), "retain --help failed");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("--timestamp") && stdout.contains("-t"),
"expected --timestamp/-t flag in retain --help, got: {}",
stdout
);
}
#[test]
fn test_configure_command() {
// Test that configure command creates/updates config
+1 -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.6.0
version: 0.6.2
servers:
- url: /
paths:
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
// APIClient manages communication with the Hindsight HTTP API API v0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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.6.0
API version: 0.6.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