Compare commits

...
Author SHA1 Message Date
Ben cfd4063c6a docs: regenerate skill mirror for FAIL_ON_EXTRACTION_ERRORS config row 2026-07-15 10:47:02 -04:00
Ben 7a5ad72417 Merge remote-tracking branch 'origin/main' into fix/fail-on-extraction-errors-2700 2026-07-15 10:45:52 -04:00
Ben d0bbfa1015 fix(gemini): grammar-enforce batch structured output regardless of strict flag (#2719)
The Gemini batch request builder only set the native response_schema
(responseJsonSchema) when json_schema.strict was truthy, but
HINDSIGHT_API_LLM_STRICT_SCHEMA defaults to False. The interactive Gemini
path always grammar-enforces via response_schema regardless of strict
(strict is an OpenAI concept, meaningless to Gemini). At default config
batch requests therefore got only responseMimeType + a textual schema hint
and intermittently emitted malformed JSON, dropping every fact in the chunk.

Set responseJsonSchema whenever a schema is present so batch mirrors
interactive. Update the batch translation unit test accordingly.

Fixes #2699
2026-07-15 10:33:39 -04:00
Ben 043a68fa44 fix(retain): recover batch extraction JSON via parse_llm_json (#2720) 2026-07-15 10:32:21 -04:00
Ben 7542035e44 fix(test): update Oracle session-schema tests for #2708 reset behavior (#2722)
#2708 changed OracleBackend._set_session_schema to always reset CURRENT_SCHEMA
to the connection's default (SESSION_USER) schema — including for the public
schema — because Oracle pooled sessions retain CURRENT_SCHEMA across checkouts.
That intentional change left two #2613 unit tests asserting the old
'public = noop, no cursor' contract, and their mock cursor lacked the fetchone()
now used to look up SESSION_USER, so both failed on main.

Update the tests to the new contract: public now resets to the default schema
via ALTER SESSION, and the mock cursor provides fetchone(). The synchronous
cursor.close()-not-awaited assertion is preserved.
2026-07-15 10:30:05 -04:00
Ben bee6f5d114 fix(control-plane): show bank name (fallback bank_id) in bank selector (#2693)
The bank selector rendered bank_id for both the dropdown items and the
selected-bank trigger, ignoring the bank's friendly name even though it's
already available on BankInfo (name). Admins who rename banks via
PATCH /v1/default/banks/{bank_id} saw only the immutable bank_id in the UI.

Display name || bank_id in the dropdown items and look up the selected
bank's name for the trigger, falling back to bank_id (then the 'select'
placeholder) so there's no regression before the bank list loads or when a
bank has no name. bank_id remains the key/value/clipboard identifier.

Fixes #2686
2026-07-15 10:17:32 -04:00
Ben e1ab382bc8 feat(retain): optional fail-on-extraction-errors to avoid silent fact loss 2026-07-15 10:08:42 -04:00
Liam Zhang e20b1815fc [verified] docs(embed): expose local CPU workarounds (#2707) 2026-07-15 10:03:50 -04:00
Ben 395823f7b6 release(claude-code): v0.7.5 2026-07-14 14:19:40 -04:00
Nick Old a910fd8a0b fix(claude-code): retain session deltas (#2648)
* fix(claude-code): retain session deltas

* fix(claude-code): commit retain checkpoint after success
2026-07-14 14:18:26 -04:00
Parafee41 64ee029a18 Avoid slim embedded daemon startup without local ML deps (#2676)
* fix(embed): avoid slim daemon without local ML deps

* test(embed): pin slim binary preconditions
2026-07-14 14:18:22 -04:00
Elan Hasson 25df91ca53 fix(claude-code): surface the CLI's real error text on is_error results (#2703)
ClaudeCodeLLM's streaming loops ignored ResultMessage entirely. When the CLI reports quota exhaustion with is_error=true and subtype="success", the SDK's fallback produced the misleading 'error result: success'. Add _result_error_detail() that prefers message.result over subtype, wired into both loops. 4/4 regression tests pass.

Fixes #2702
2026-07-14 10:10:02 -04:00
Parafee41 8987fb8267 fix(codex): share OAuth refresh per auth-file path (#2706)
Multiple CodexLLM instances (default/retain/reflect/consolidation configs) each created their own CodexAuthManager with an instance-local lock, so refresh was only single-flight within one manager. Concurrent refreshes from sibling managers hit refresh_token_reused. Add a path-scoped in-process lock and fcntl advisory file lock so all managers for the same CODEX_HOME coordinate as one refresh domain; pre-read auth.json under the lock to adopt credentials rotated by a sibling before making a network call.

27 Codex OAuth tests pass. CI green.

Fixes #2704
2026-07-14 10:08:36 -04:00
Voscko 86ff344c93 fix(worker): bound terminal operation history (#2708)
Add configurable TTL (default 30 days, 0=keep-forever) for terminal async_operations rows. Expired completed/failed/cancelled rows are pruned in bounded batches (1000/cycle) by a background task that never touches pending/processing work. Batch children are protected until their parent is pruned; cancelled-child cleanup atomically cancels a pending parent first. PG uses FOR UPDATE SKIP LOCKED; both PG and Oracle re-check eligibility under the row lock before deleting. Includes indexes, docs, and regenerated SDKs.

184 retention/worker/operation-status tests pass locally. All CI green.

Fixes #2705
2026-07-14 10:04:26 -04:00
DK09876andClaude Opus 4.8 b6c7b2a2e9 feat(devin-desktop): two-tier bank scoping + visible memory use (v0.2.0) (#2692)
* feat(devin-desktop): two-tier bank scoping + visible memory use (v0.2.0)

Reworks the Devin Desktop integration from a single hardcoded `devin-desktop`
bank (all projects share one memory pool) to per-project isolation plus a
shared cross-project bank, and makes Hindsight usage visible in chat.

Scoping (multi-bank mode):
- Connect to the multi-bank `/mcp/` endpoint (was `/mcp/<bank>/`); the model
  routes `bank_id` per call, guided by the committed rule.
- Global bank `devin-desktop` (user prefs/style) named in global_rules.md;
  per-project bank `devin-desktop-<slug>` derived from the git remote (stable
  across machines/teammates) named in the committed .devin/rules/hindsight.md.
- `X-Bank-Id: <global>` header as the fallback bank when the model omits it.
- Verified against live Cloud: bank_id routing + full isolation (no cross-bank
  leak) + read-after-write via sync_retain.

Visibility (no sound, per product decision):
- Rule now tells the agent to briefly acknowledge memory use in chat
  (reverses the prior "do not mention" line) and to use `reflect`/`sync_retain`.

Audit fixes:
- Write both documented MCP config locations (`~/.codeium/windsurf/` and
  `~/.codeium/`) since Devin's own docs disagree on the path.
- Explicit "press Refresh in the MCP panel" step (config doesn't hot-reload).

New modules: project.py (git-derivation), global_rules.py (global_rules.md
managed block). Backward-compatible: legacy `bankId` config maps to the global
bank. 55 tests pass; ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* feat(devin-desktop): also wire the Devin Local agent (not just Cascade)

Devin Desktop ships two agents with separate config, and the prior version only
wired Cascade — so a user on Devin Local (the successor agent) got no memory.
`init` now configures both:

Cascade (unchanged): ~/.codeium/windsurf/mcp_config.json (serverUrl),
.devin/rules/hindsight.md, ~/.codeium/windsurf/memories/global_rules.md.

Devin Local (new):
- ~/.config/devin/config.json — mcpServers.hindsight with `url` + `transport:"http"`
  + `headers` (Devin Local's schema, not Cascade's `serverUrl`); preserves other
  keys (e.g. version).
- permissions.allow += "mcp__hindsight__*" — Devin Local prompts before every MCP
  tool by default; this makes recall/retain run automatically.
- AGENTS.md always-on rules (Devin Local doesn't read .devin/rules/): repo-root
  AGENTS.md (per-project) + ~/.config/devin/AGENTS.md (global), each a fenced
  managed block that preserves user content.

New modules: devin_local.py, managed_block.py (shared block writer, also used by
global_rules.py). Same multi-bank + routing-rule design across both agents.
status/uninstall cover both. README + docstrings updated. 74 tests pass; ruff
clean (ruff 0.14.9 + root config).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* docs(devin-desktop): tell users to click Connect (Devin Local) after init

Devin Local registers the MCP server from config.json but requires an explicit
Connect click in the Devin MCP Marketplace (verified in-app). init output and
README now spell out the per-agent activation step: Cascade = Refresh, Devin
Local = Connect.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* feat(devin-desktop): deterministic auto-recall hook + Windows paths

Two things for 0.2.0:

1. Windows paths: Devin Local config/AGENTS.md now resolve to %APPDATA%\devin on
   Windows (was ~/.config/devin unconditionally, which is wrong there). Cascade's
   ~/.codeium/windsurf is already cross-platform.

2. Deterministic auto-recall (Devin Local only): init adds a SessionStart hook to
   config.json that recalls project + global memory and returns it as
   `additionalContext`, which Devin injects into the agent's context before the
   model acts — so memory loads even if the model forgets to call recall. The
   hook (hindsight_devin_desktop.hook) reads the connection from config.json and
   derives the project bank from DEVIN_PROJECT_DIR; it's dependency-free (stdlib
   urllib MCP call), times out fast, and fails silently so it never breaks a
   session. Opt out with `init --no-hooks`. Cascade gets no hook (its hooks can't
   inject context). Auto-retain is intentionally not added (SessionEnd can't see
   the transcript); retain stays model-driven via the MCP tool.

Verified live against Cloud: the hook recalls a stored fact and emits correct
additionalContext JSON. 89 tests pass; ruff clean. README documents both.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* feat(devin-desktop): retain-nudge hook, no-silent recall, Cascade visibility banner

Round out the hooks/visibility work for 0.2.0:

- Retain-nudge (Devin Local, default on): a `Stop` hook forces one retain pass
  before the agent stops (loop-guarded via stop_hook_active) — deterministic
  *trigger*; the model decides what's durable and calls retain. Devin's hooks
  can't hand a script the transcript, so this is the closest to deterministic
  retain. Opt out with --no-retain-hook; --no-hooks disables both hooks.

- No silent failures (recall hook): the SessionStart hook now ALWAYS reports
  status via additionalContext — loaded N / empty / unavailable(reason) — and
  tells the model to surface it. Never exits non-zero (never breaks a session).

- Cascade visibility banner: init adds a `post_mcp_tool_use` hook to
  ~/.codeium/windsurf/hooks.json with show_output:true that prints
  "🧠 Hindsight: <tool> used" (filtered in-script to the hindsight server, since
  Cascade hooks have no matcher). Makes Cascade's recall/retain visibly obvious.

New module cascade_hooks.py; hook.py gains retain-nudge + banner subcommands.
README documents both hooks, the honest retain limitation, and the banner.
102 tests pass; ruff clean. Recall + retain-nudge output verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* fix(devin-desktop): stop the retain-nudge polluting memory with meta-facts

Real in-app testing showed the Stop retain-nudge caused the model to (a) retain
facts ABOUT the memory system/instructions as 'user preferences', and (b)
re-retain things already saved this session. Tighten both the nudge and the
always-on rule: retain ONLY real facts about the code/project/user's actual
preferences, NEVER facts about Hindsight/memory/hooks/these instructions, and
don't re-retain what's already stored.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* fix(devin-desktop): rule tweak to stop redundant retains

In-app testing showed the model firing sync_retain per-fact (and re-saving),
producing duplicate memories. Reframe the rule: retain (async) is the default;
retain each distinct fact EXACTLY ONCE in a single call (batch same-subject
facts); sync_retain only for same-task read-after-write.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* feat(devin-desktop): hook proof-of-life log (~/.hindsight/devin-hook.log)

Devin Local hooks are silent (no output panel), so it's hard to tell whether a
hook actually fired vs the model just following the always-on rule. Each hook
invocation now appends one line (recall loaded/empty/error, retain-nudge
blocked/skipped, banner shown/skipped) with the resolved banks — proof-of-life
so users (and we) can confirm the hooks run.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* feat(devin-desktop): --no-global-bank opt-out (local-only memory)

Add a local-only mode so users can opt out of the shared cross-project bank:
everything (project facts + the user's preferences) goes to the single project
bank, the global rule files are removed instead of written, and the recall +
retain-nudge hooks run with --local-only (recall only the project bank, nudge
routes everything there). The rule becomes a single-bank variant. Cascade
banner + MCP config unchanged. For people who don't want a shared profile
(e.g. work vs personal machines).

108 tests pass; ruff clean. Verified end-to-end: no global files written, hooks
carry --local-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* fix(devin-desktop): don't let the test suite write the hook log to $HOME

The proof-of-life hook log wrote ~/.hindsight/devin-hook.log unconditionally, so
running the tests (which call the hook functions) polluted the real user log.
Make the path env-overridable (HINDSIGHT_HOOK_LOG, 'off' disables) and add a
conftest that sets it off during tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* feat(devin-desktop): recall hook reports the session-start preload

The SessionStart hook's additionalContext now tells the model to OPEN its reply
by announcing that memory was preloaded (e.g. '🧠 Hindsight preloaded N memories
for this session'), and that it doesn't need to re-call recall for the baseline
— making the deterministic preload visible to the user and cutting redundant
recall calls. Empty/error variants also lead with a user-facing status line.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* docs(devin-desktop): add 'verify it's working', which-agent, and Windows notes

Help new users get started with both agents: a 'Verify it's working' section
(the preload status line / hook log / Cascade banner / status command), a note
on the agent selector, and the Windows config path.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-13 15:52:21 -07:00
BenandClaude Opus 4.8 9efce6a470 Blog: Inside retain() — What Happens When Your Agent Remembers (#2689)
* Blog: inside retain() — what happens when your agent remembers

A feature explainer walking the retain() write path end to end through one
sentence: fact extraction (meaning, not words), entity recognition + resolution,
the knowledge graph (entity/time/meaning/causal), dual temporal grounding, and
async consolidation into evidence-grounded observations. Grounded in the retain
and observations developer docs. Pipeline-diagram cover.

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

* Blog: address review — drop async-only timing claims, note original text is stored

- Remove 'returns almost immediately' / inline-extraction language that only
  holds for one retain mode; frame consolidation as the always-background step
- Add that retain also stores the original text (chunked if long), available
  alongside the extracted memory
- Cover line updated to 'the raw text is kept, and memory is built on top'

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

* Blog: fact-check fixes from full code+docs audit

- Remove 'nickname resolution' (Bob/Robert Chen): code has no nickname/alias
  logic; resolution is fuzzy name match + co-occurrence + temporal proximity
- Temporal: second axis is the mention time, not the DB insert moment; recency
  ranks off event/mention time, not ingestion
- Soften 'source is never lost' -> 'stays available' (original-text storage is
  default-on but operator-configurable)

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

* Blog: editorial cover (cream + serif, teal retain())

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-13 15:25:12 -04:00
Ben 6cc7484b78 fix(migrations): install public maintenance routines on non-public schema runs (#2690)
Install public.banks_needing_consolidation() / public.schemas_with_expired_rows() on every PG run (advisory-lock-guarded) so single-tenant deployments migrated into a non-public schema get the routines the maintenance loop needs. Prior migrations gated on target_schema being falsy/public, leaving non-public deploys logging 'function public.… does not exist' forever.

All real CI tests pass (test-api 1/3+3/3, and 1237/1237 in shard 2/3; test-upgrade, both Oracle suites, verify-generated-files green). Sole failing check is a persistent pg0 'Address already in use' runner-infra error in an unrelated backfill test — not a test failure and not from this diff; recurred on all 3 reruns.

Fixes #2638
2026-07-13 13:29:13 -04:00
DK09876 920afcce20 release(devin-desktop): v0.1.0 2026-07-13 10:00:35 -07:00
Evo 6d82c8a554 fix(consolidation): request JSON dedup decisions (#2663)
The dedup prompt literally told the model to 'respond action="merge"', so weaker models emitted key=value instead of JSON and json.loads crashed every dedup-eligible consolidation into an infinite retry loop. Rewrite the prompt to demand a JSON object (braces escaped for .format()), and add a defensive parser that accepts str/dict/model and defaults to action=keep on invalid output. Fork CI skips pytest (no secrets); test_consolidation_dedup.py verified locally (32/32), ruff+ty clean.

Fixes #2658
2026-07-13 11:03:49 -04:00
Sanderhoff-alt 4b52b10e2e fix(recall): preserve combined graph activation scores (#2679)
Link expansion ranks candidates by an additive entity, semantic,
and causal score, but returned the raw score from one signal as
activation. Cross-fact-type graph merging then re-ranked candidates
using that raw value.

Store the final additive score as activation and add a regression test
for cross-fact-type ordering.
2026-07-13 10:35:20 -04:00
Vilius PuidokasandVilius Puidokas ac06df1ade fix(control-plane): route consolidation-poll tick through a ref so it sees current tag/scope filters (#2680)
Co-authored-by: Vilius Puidokas <[email protected]>
2026-07-13 10:31:04 -04:00
Evoandr266-tech 5f1a867650 fix(search): avoid year-0 crashes in Chinese rolling-window temporal extraction (#2636)
* fix(search): guard Chinese rolling year underflow

* fix(search): complete Chinese year underflow guard

---------

Co-authored-by: r266-tech <[email protected]>
2026-07-13 10:14:28 -04:00
Ben d284119246 fix(control-plane): forward document search q to dataplane (#2687)
The /api/documents proxy route dropped the q search param, so document search-by-ID in the control plane did nothing (all browsers, not just Safari). Forward q to the dataplane's substring-on-ID filter. Adds a vitest route test.

Fixes #2678
2026-07-13 10:11:56 -04:00
Sanderhoff-alt 84b9aa56ce fix(engine): clarify causal link compatibility (#2685)
Clarify that retain creates caused_by only. Storage and recall keep reading
historical causal link types, and transfer import alone restores them.

Correct stale code comments and tests, and preserve legacy edge types and
endpoints during transfer without widening the retain write contract.
2026-07-13 10:07:26 -04:00
Parafee41 f58ecee0b9 fix(retain): preserve append document metadata (#2684) 2026-07-13 10:01:17 -04:00
Sanderhoff-alt b9d16fe86f fix(recall): scope entity fanout cap by fact type (#2681)
Apply each entity fanout cap only after filtering candidates by fact type.
This prevents high-volume fact types from excluding valid target candidates.

Cover the PostgreSQL and Oracle CTE builders with a regression test.
2026-07-13 09:54:22 -04:00
Parafee41 2ccde7a5cd accept top-level fact arrays in retain parsing (#2556) 2026-07-13 09:32:00 -04:00
Parafee41 d2ca26afaf Include cookbook and integration docs in docs skill (#2649)
Extends generate-docs-skill.sh to walk hindsight-docs/src/pages/cookbook/ and docs-integrations/, so the docs skill bundle ships the cookbook recipes/applications and per-integration docs its SKILL.md already advertised. Fixes the ghost-path index described in #2641. Regeneration is drift-free (verify-generated-files passes) and link validation passes; bundle grows from ~85 to 168 files.

Fixes #2641
2026-07-10 16:08:40 -04:00
Cyprian Kowalczyk b52feb305e fix(consolidation): normalize dedup action case/whitespace before validation (#2611) 2026-07-10 16:06:56 -04:00
558b2f8b67 fix(llm): raise OpenRouter Qwen3 verification budget (#2633)
* fix(llm): raise OpenRouter Qwen3 verify budget

* test(llm): apply response hardening lint fixes

* fix(llm): generalize verification token budget

---------

Co-authored-by: r266-tech <[email protected]>
Co-authored-by: Ben <[email protected]>
2026-07-10 15:48:32 -04:00
Evoandr266-tech 383f0caa16 deps(security): require LiteLLM 1.84.0 (#2651)
Co-authored-by: r266-tech <[email protected]>
2026-07-10 15:36:51 -04:00
Ben 16e8c4e216 Blog: one shared memory across Cursor, OpenClaw, and Vapi (#2655)
A first-person, day-in-the-life post on running three AI tools (code, chat,
voice) against a single Hindsight bank: a decision made in Cursor is recalled
by the OpenClaw Slack agent and the Vapi voice agent, because all three point
at the same bank id. Grounded in each integration's actual bank config.
Hub-and-spoke cover.
2026-07-10 15:12:54 -04:00
JiehoonKwak c6a1b507ba Fix Codex OAuth request identity (#2647) 2026-07-10 15:08:15 -04:00
Liam ZhangandBen cb4fe70b63 fix(trace): skip LLM trace writes during daemon shutdown/pre-init races (#2618)
* fix(trace): skip LLM trace writes during daemon shutdown/pre-init races

LLMTraceRecorder._safe_write and _attach_memory_ids produce spurious
WARNING logs during two race windows:

1. Pre-init: MemoryEngine.initialize() runs verify_llm() inside the
   parallel init gather before the DB backend pool is ready. The
   pool_getter returns a backend object that raises RuntimeError on
   acquire.

2. Shutdown: MemoryEngine.close() calls backend.shutdown() (sets
   _pool=None) before setting self._backend=None. Fire-and-forget trace
   tasks see a non-None backend whose internal pool is already closed,
   hitting either RuntimeError('not initialized') or
   InterfaceError('pool is closing').

Both are expected lifecycle states, not actionable errors. Fix:
- Add a getattr(pool, '_pool') None guard before the acquire attempt
- Downgrade 'not initialized' and 'pool is closing' exceptions to DEBUG
  in both _safe_write and _attach_memory_ids
- All other write failures still warn

Supersedes #2562 (closed without merge), which only covered the
pre-init RuntimeError path. This PR additionally covers the shutdown
'pool is closing' race and the _attach_memory_ids write path.

5 regression tests covering: pool=None, backend._pool=None,
pool-is-closing, unexpected error (still warns), and
_attach_memory_ids with _pool=None.

* style: ruff format test_llm_trace.py

---------

Co-authored-by: Ben <[email protected]>
2026-07-10 14:08:49 -04:00
handnewbandBen 408d7c34c8 fix: handle FK violation in observation_history during parallel consolidation (#2620)
* fix: handle FK violation in observation_history during parallel consolidation

Wrap the INSERT into observation_history with a try/except for
ForeignKeyViolationError. Under parallel/batched consolidation, one
batch may delete an observation while another writes its history,
causing a race condition. Instead of failing the entire consolidation
task, log a warning and skip the history entry.

Also adds the missing  needed to catch the specific
exception type.

Closes #2597
Closes #2506

* test: regression for observation_history FK race (#2597, #2506)

---------

Co-authored-by: Ben <[email protected]>
2026-07-10 11:12:04 -04:00
B HicksandClaude Opus 4.7 7f2df54e01 feat(anthropic): implement the batch API interface via Message Batches (50% token discount) (#2628)
The engine's batch path (retain fact extraction, gated on
retain_batch_enabled) has been available to the OpenAI-compatible and Gemini
providers but not Anthropic — AnthropicLLM implemented none of the
LLMInterface batch methods, so supports_batch_api() returned False and the
gate hard-failed.

Implement all four methods against Anthropic's Message Batches API, which
bills every token at 50% of standard price:

- submit_batch translates the engine's OpenAI-JSONL-shaped entries into
  Messages batch requests, mirroring call()'s conversion rules: system
  messages fold into the system param, max_completion_tokens -> max_tokens,
  temperature is dropped (the sync path never sends it either), and
  response_format json_schema becomes a forced tool_use tool when strict
  (native constrained decoding, issue #1002) or a system-prompt schema
  injection otherwise. Operator extra_body params merge directly (batch
  params are the raw Messages body).
- get_batch_status maps processing_status onto the OpenAI vocabulary the
  engine's poll loop speaks: "ended" -> "completed" (per-request failures
  surface in results, matching OpenAI's completed-with-errors semantics),
  non-terminal states pass through; request_counts are aggregated to
  total/completed/failed.
- retrieve_batch_results renders succeeded messages as
  choices[0].message.content (forced-tool JSON re-serialized as the content
  string) with OpenAI-keyed usage, and errored/canceled/expired entries as
  per-result errors.

8 new tests covering translation in both directions, status mapping, and the
not-ended guard; existing batch-path and Anthropic provider suites pass
unchanged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-07-10 10:57:01 -04:00
Ben 1758d8510b release(github-copilot): v0.1.0 2026-07-10 10:49:53 -04:00
B HicksandClaude Opus 4.7 7c3a5619f9 feat(anthropic): prompt caching via inline cache_control markers (#2629)
The Anthropic provider sent no cache_control at all, so every call paid full
input price on content the engine resends verbatim: fact extraction reuses
the same system prompt across every chunk, and the reflect agent loop resends
the entire growing conversation on each of its (up to
HINDSIGHT_API_REFLECT_MAX_ITERATIONS) iterations. Anthropic cache reads bill
at ~10% of base input price.

Implement the "inline-marker provider" strategy that
LLMInterface.get_or_create_cached_prefix already documents for Anthropic —
no engine changes, no new config:

- call() and call_with_tools() render the system prompt as a block list with
  a cache_control breakpoint (a prefix match, so tools + system cache
  together); schema text-injection happens before marking and lands inside
  the cached block.
- call_with_tools() additionally marks the final message content block, so
  each agent-loop request's end-marker becomes the next iteration's cache
  read point. 2 of the 4 allowed breakpoints used.

Marking is safe unconditionally: below the model's minimum cacheable prefix
the marker is silently ignored (no write premium), and cache_read_input_
tokens already flows through _usage_from_anthropic_response into metrics.

One existing assertion updated for the representation change
(test_non_strict_keeps_text_injection_fallback checked a substring on system
as a string; the schema-in-prompt behavior itself is unchanged and still
covered). 5 new tests pin the marker placement on both entry points.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-07-10 10:39:16 -04:00
Ben 2b5b47f97d Blog: Give Aider a Memory That Outlives the Session (#2642)
* Blog: give Aider a persistent, cross-session memory

Add a post on hindsight-aider, the drop-in wrapper that recalls project
memory before an Aider session (via a --read context file) and retains the
transcript after, scoped per git repo. Grounded in the v0.1.1 integration
source. Git-diff cover.

* Blog: use Aider-branded cover (real logo, brand green, VT220 font)
2026-07-09 15:59:35 -04:00
Ben b2508bf2d6 release(claude-code): v0.7.4 2026-07-09 15:54:05 -04:00
Ben b0fb1111ec fix(claude-code): skip primary + duplicate banks in recallAdditionalBanks (#2625)
The additional-banks recall loop recalled every entry with no dedup against
the resolved primary, so bidirectional cross-bank setups (primary listed in
recallAdditionalBanks) re-recalled the primary on every prompt — a wasted
recall call plus duplicate context. Guard the loop with a seen-set seeded with
the primary bank; also dedups repeated entries. Fixes #2604.
2026-07-09 15:48:53 -04:00
Nick Old 4bf126bf52 fix(claude-code): isolate MCP server cwd (#2635) 2026-07-09 15:47:51 -04:00
Evoandr266-tech e4449326e6 fix(reflect): tolerate null-like tool integer limits (#2639)
Co-authored-by: r266-tech <[email protected]>
2026-07-09 15:36:11 -04:00
Sanderhoff-alt 7bab4db28d fix(recall): decouple temporal seed threshold (#2595)
Keep recall min_scores.semantic scoped to the semantic retrieval arm.

Temporal retrieval uses embeddings only to choose time-window entry
points. Reusing the request-level semantic floor there made temporal
recall unexpectedly narrower.

Callers that only wanted to prune weak semantic matches could also
narrow temporal recall. That made the min_scores contract surprising
and inconsistent with graph seed selection.

Use the temporal entry-point default instead. Semantic and BM25 request
floors remain unchanged.
2026-07-09 10:28:20 -04:00
e29ee58603 fix(ollama): make native num_ctx opt-in (#2589)
* fix(ollama): make native num_ctx opt-in

* docs(ollama): add HINDSIGHT_API_LLM_OLLAMA_NUM_CTX to .env.example

* fix(config): keep Ollama num_ctx optional for direct config construction

---------

Co-authored-by: r266-tech <[email protected]>
Co-authored-by: Ben <[email protected]>
2026-07-09 10:25:21 -04:00
Evo 82e67315a4 fix(worker): complete successful operations (#2608) 2026-07-08 14:35:28 -04:00
DK09876andClaude Opus 4.8 2e82edee14 docs(oracle): document the required schema, dimension matching, and start-oracle re-run (#2615)
Verifying the Oracle guide end-to-end surfaced three setup steps that weren't
documented and that block a first-time deployment:

- HINDSIGHT_API_DATABASE_SCHEMA must be set to the Oracle schema user. The
  default `public` is a PostgreSQL notion and makes migrations fail with
  ORA-01435. Added it to the configure step (with a warning), the quick start,
  the config reference table, and troubleshooting.
- Migrations must run with the same embedding dimension as the serving model,
  or retain fails with ORA-51803. Added a warning to the migrate step and a
  troubleshooting row (including the --embedding-dimension resize path).
- The dev quick-start container can report a provisioning error on a cold
  start's first run; noted that re-running the idempotent script succeeds.

Mirrored into versioned_docs/version-0.8 and regenerated the docs skill.


Claude-Session: https://claude.ai/code/session_01PginSDrapXsoDd6gN5Pszo

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 20:35:16 -07:00
DK09876andClaude Opus 4.8 0338ab4d73 fix(oracle): don't await the synchronous cursor.close() in _set_session_schema (#2613)
oracledb's AsyncCursor.close() is not a coroutine, so awaiting it raised
"object NoneType can't be used in 'await' expression" on every acquire()
under a non-public schema. This broke the database health check and all
retain/recall/reflect operations on Oracle whenever a non-public schema was
active — which is the norm on Oracle, since a schema is a user and the
default `public` schema does not exist there.

Drop the erroneous await. Add unit regression tests (no live Oracle needed —
a fake cursor whose close() is synchronous, exactly like oracledb) covering
both the non-public path (previously raised TypeError) and the public no-op
path. These run in the standard test suite, unlike the label-gated Oracle
integration job.


Claude-Session: https://claude.ai/code/session_01PginSDrapXsoDd6gN5Pszo

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 16:49:51 -07:00
DK09876andClaude Opus 4.8 f4106fff55 docs: add Oracle Database setup guide (#2612)
* docs: add Oracle Database setup guide

Hindsight supports Oracle Database 23ai as a storage backend, but the docs
only mentioned it in passing — a one-paragraph note on the Storage page and a
couple of Configuration reference rows, with no `oracle+oracledb://` example
anywhere. This adds a dedicated Oracle Database page under Hosting.

The guide covers requirements (Oracle 23ai, the ASSM-tablespace requirement
for VECTOR columns, Oracle Text / CTXAPP), installing the python-oracledb
driver, a local quick start via scripts/dev/start-oracle.sh, production
provisioning SQL + connection URL + env vars + migrations, a config reference,
the differences from PostgreSQL, and troubleshooting. Content is grounded in
the CI Oracle job, the dev script, and the backend code.

Registered in the sidebar and cross-linked from Storage and Configuration.
Regenerated the docs agent-skill and mirrored the change into
versioned_docs/version-0.8 so it ships on the currently-served version.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01PginSDrapXsoDd6gN5Pszo

* docs(oracle): correct managed-service note, add connection caveat

The connection layer builds the Oracle DSN from the URL as a plain
host:port/service_name descriptor — wallet-based mTLS, TLS/TCPS, and TNS
aliases / full connect descriptors are not wired up. The previous "Least
privilege" note implied Oracle Autonomous Database works via an
ADMIN-provisioned user, which is misleading since ADB defaults to wallet/mTLS.

- Reworded the managed-service note to drop the specific ADB claim while
  keeping the accurate requirement (ASSM tablespace + CTXAPP).
- Added an "Easy Connect only" warning documenting that wallet/mTLS/TLS and
  TNS descriptors are unsupported, and that transport encryption must be
  handled at the network layer.

Applied to the current and version-0.8 copies; regenerated the docs skill.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01PginSDrapXsoDd6gN5Pszo

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 14:58:03 -07:00
BenandClaude Opus 4.8 d4f700ed22 Blog: Give Zed's AI Assistant a Persistent Memory (#2598)
* Blog: persistent memory for the Zed editor (hindsight-zed v0.1.0)

New post on the Zed integration: wires Zed's Agent Panel to the Hindsight MCP
server (recall/retain/reflect) plus a global AGENTS.md rule, so the assistant
remembers decisions and conventions across sessions. Grounded in the v0.1.0
source; em-dash-free. Adds a series-style cover.

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

* Blog: update Zed install to the Node CLI (npx), per #2599

hindsight-zed is now a zero-dependency Node CLI: `npx hindsight-zed init`
(or `npm install -g`). Node.js only, no Python. Mechanism unchanged.

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

* Blog: swap Zed cover to the typographic "Memory for Zed" poster

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 16:31:25 -04:00
Ben 041f0f13f4 release(zed): v0.2.0 2026-07-07 16:02:06 -04:00
9dee1c594b refactor(zed): port setup CLI to Node, drop the Python dependency (#2599)
* refactor(zed): port setup CLI to Node, drop the Python dependency

The Zed integration is configuration-only — it writes the `context_servers`
entry into Zed's settings.json and a recall/retain rule into AGENTS.md — and the
MCP server it configures runs via `npx mcp-remote`, so Node.js was already a hard
requirement. Requiring Python *as well* just to write two config files meant
users needed two runtimes.

Port the `hindsight-zed` CLI to a zero-dependency Node CLI so the integration
needs only Node:

- Node CLI under `src/` + `bin/hindsight-zed.js`, shipped via `package.json`
  (matches the existing TypeScript integrations; release-integration.yml already
  detects package.json for npm publishing).
- Behavior-preserving: same commands (`init`/`status`/`uninstall`), flags,
  `--print-only`, env/file/flag config resolution, JSONC-safe settings edits,
  and fenced AGENTS.md rule block.
- Tests ported to Node's built-in runner (`node --test`) — 21 tests.
- CI (`test.yml`) updated to run `npm test` on Node 22 instead of pytest.
- Removes the Python package (`hindsight_zed/`, `pyproject.toml`, `uv.lock`,
  Python `tests/`).

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

* fix(zed): make the Node package publishable by the release workflow

The release workflow classifies any integration with a package.json as
`type=typescript` and unconditionally runs `npm ci` + `npm run build` in
the integration dir. This zero-dependency, no-build JS package had neither,
so `integrations/zed/v*` would fail at release time (invisible in test CI,
which only runs `npm test`):

- add a no-op `build` script so `npm run build` succeeds
- commit package-lock.json so `npm ci` succeeds (it refuses to run without
  one, even with zero deps); lockfile has no node_modules entries, so
  check-integration-lockfiles.sh passes trivially
- drop the stray settings.json (a local `init` scaffold accidentally
  committed) and gitignore it

Verified locally: node --test (21/21), npm ci, npm run build, and
npm publish --dry-run all pass; tarball ships only bin/src/README.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* docs(zed): update setup to Node/npx (drop pip install)

* refactor(zed): scope npm package as @vectorize-io/hindsight-zed

Match the scoped-name convention of the other TS integrations
(@vectorize-io/hindsight-ai-sdk, -chat, -openclaw). CLI/bin command stays
'hindsight-zed'; npx/global-install references updated to the scoped name.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: DK09876 <[email protected]>
2026-07-07 16:01:33 -04:00
Ben a1ebb2d9c6 feat(llm): recover outer JSON span when fence stripping yields non-JSON (#2610)
Grafts the parse-validated fallback from #2557 onto the line-based fence
stripper merged in #2563: after stripping, if the candidate is not valid
JSON (partial/absent fence, prose-wrapped or truncated output), fall back
to the outermost parseable {..}/[..] span. Never returns a worse candidate
than the raw content.
2026-07-07 15:44:02 -04:00
Parafee41 06ddf041e5 fix(minimax): disable thinking by default (#2558) 2026-07-07 15:27:50 -04:00
poog26andBen d251fcb7d2 Fix _strip_code_fences truncating JSON when content contains inner backticks (#2563)
* Fix _strip_code_fences truncating JSON when content contains inner backticks

The old implementation used content.split('')[0] to strip
markdown code fences from LLM responses. This finds the FIRST occurrence of '''
after the opening fence — so when the extracted JSON itself contains literal
triple-backtick characters (e.g. facts about code fence formatting), the split
matches those inner backticks and truncates the JSON mid-string.

Replace with line-based fence detection that only matches fences at line
boundaries per the markdown spec. Inner backticks inside JSON string values
are preserved since they aren't at line boundaries.

* test(llm): cover inner-backtick fence stripping regression

---------

Co-authored-by: Ben <[email protected]>
2026-07-07 15:25:55 -04:00
Parafee41 e839c65537 fix(cli): set default user agent (#2564) 2026-07-07 15:12:18 -04:00
Evoandr266-tech 0cde79b831 fix(consolidation): default invalid dedup actions to keep (#2565)
Co-authored-by: r266-tech <[email protected]>
2026-07-07 15:04:04 -04:00
Ben 8767a518db docs(skill): sync configuration reference for BM25 term cap (#2609) 2026-07-07 15:01:21 -04:00
Parafee41 10ed288d80 build control plane client dependency (#2566) 2026-07-07 14:48:40 -04:00
7143684a81 feat(recall): add opt-in BM25 query term cap (#2567)
* Add opt-in BM25 query term cap

* docs(config): document HINDSIGHT_API_BM25_MAX_QUERY_TERMS

---------

Co-authored-by: r266-tech <[email protected]>
Co-authored-by: Ben <[email protected]>
2026-07-07 14:41:02 -04:00
Nick Old 11da432db2 Fix chunk delete deadlock ordering (#2570) 2026-07-07 14:28:43 -04:00
ishanmalikandishanmalik 73d3231bbd fix(api): accept verbatim extraction mode in bank manifests (#2576)
Co-authored-by: ishanmalik <[email protected]>
2026-07-07 14:06:35 -04:00
Ben 59d825dfca release(opencode): v0.2.7 2026-07-07 13:50:23 -04:00
Sanderhoff-alt ae7099fd02 fix(opencode): install plugin SDK at runtime (#2574)
The OpenCode plugin imports @opencode-ai/plugin/tool from its
built dist entrypoint, so the package must be present in
OpenCode's isolated plugin cache. Keeping it only as a peer
dependency lets npm skip it during plugin installation, which can
make the plugin fail to load with ERR_MODULE_NOT_FOUND.

Move @opencode-ai/plugin into dependencies and remove the
peer-only declaration. Keep @vectorize-io/hindsight-client as a
runtime dependency, and sync package-lock.json so npm installs the
cache tree needed by OpenCode.

Verified with npm run build, npm pack --json, and a local opencode
plugin install from the generated tarball. The generated cache
contained both runtime dependencies and direct import of the
plugin entrypoint succeeded.
2026-07-07 13:49:47 -04:00
Ben 56db6d7cf6 release(codex): v0.3.1 2026-07-07 13:47:50 -04:00
Ben 0eb52762ae release(claude-code): v0.7.3 2026-07-07 13:47:12 -04:00
ishanmalikandishanmalik e93c560288 feat(integrations): add recall score floors (#2575)
Co-authored-by: ishanmalik <[email protected]>
2026-07-07 13:44:42 -04:00
Evo 1f213d00f7 fix(api): allow clearing memory occurred dates with null (#2607) 2026-07-07 13:42:19 -04:00
Parafee41 8f51f99dde fix(retain): preserve JSON chunks during output retry (#2579) 2026-07-07 11:34:24 -04:00
Sanderhoff-alt 5cc1482a72 fix(memory): return metadata from memory browse endpoints (#2583)
The list and get memory-unit paths selected tags and timing fields
but skipped the memory_units metadata column, so metadata retained
on facts was invisible outside recall.

Select and serialize metadata for live and invalidated memory units,
add a curation regression test for both paths, and update docs plus
OpenAPI examples.
2026-07-07 11:11:04 -04:00
Chris Bartholomew 639d84ad32 fix(parsers): coerce non-bytes buffers before the UTF-8 charset probe in markitdown (#2586)
* fix(parsers): coerce non-bytes buffers before the UTF-8 charset probe

MarkitdownParser._utf8_stream_info() is type-hinted file_data: bytes and calls
file_data.decode('utf-8') to check whether a text file is valid UTF-8 before
handing markitdown an explicit charset hint. But callers may pass a
buffer-protocol object that is not a concrete bytes (a memoryview, or a
native/Rust-backed buffer), which has no .decode — raising
AttributeError: '...' object has no attribute 'decode' and failing every
text-file parse (.txt/.json/.md/.csv/.html/...).

The temp-file write in _convert_sync a few lines up already relies only on the
buffer protocol, so the decode probe was the sole spot assuming concrete bytes.
Coerce via bytes(file_data) before the probe. Adds a regression test with a
buffer-only object (no .decode).

* test(parsers): use memoryview stand-in for the non-bytes buffer case

The previous fixture defined a PEP 688 __buffer__ class, which bytes() only
recognizes on Python 3.12+; on 3.11 the CI shard raised
'TypeError: cannot convert ... object to bytes'. Use a memoryview instead — it
has no .decode and bytes(memoryview) works on every supported version, so the
test stays portable while still exercising the coercion path.
2026-07-07 11:04:22 -04:00
Evoandr266-tech 1c74f795a6 docs(eve): sync assistant reply default (#2588)
Co-authored-by: r266-tech <[email protected]>
2026-07-07 10:34:59 -04:00
Parafee41 b4f9fbe1b5 refresh search vector on memory curation (#2552) 2026-07-07 10:13:17 -04:00
Evoandr266-tech b992ba996d fix(agent-sdk): release recall token fix as 0.1.1 (#2596)
Co-authored-by: r266-tech <[email protected]>
2026-07-07 09:54:00 -04:00
Ben f00d3c7f66 Blog: Eve automatic memory (hindsight-eve v0.2.0) (#2584)
* Blog: Eve automatic memory (hindsight-eve v0.2.0)

New post covering the v0.2.0 rewrite of the Vercel Eve integration: memory
is now automatic (instructions resolver recalls before each turn, hook
retains after) with no model-called memory tool. Supersedes the v0.1 draft
in #2480. Adds cover + three demo screenshots (teach -> observation -> recall).
2026-07-06 14:57:48 -04:00
Ben e97b615547 release(eve): v0.2.1 2026-07-06 14:08:30 -04:00
Ben 29cc1d7fdc feat(eve): retain the assistant reply by default (#2585)
Flip `includeAssistantReply` to default `true` so the auto-retain hook stores
both the user's message and the assistant's reply, not just the user's message.
The assistant's reply is usually where the answer lives (the decision, the
solution, the code), and this matches every other Hindsight integration that
does automatic retain:

- agent-framework (same provider/after_run pattern as eve): include_input +
  include_response both hardcoded true
- opencode: retainMode "full-session" (user + assistant) by default
- claude-code: retainRoles ["user", "assistant"] by default

eve was the only auto-retain integration defaulting to user-only. Set
`includeAssistantReply: false` to keep the old behavior.

Updates JSDoc, README, and repurposes the "user-only by default" tests to
assert the new default (both), with the opt-out (false) still covered.
2026-07-06 14:04:35 -04:00
Ben 016b5f0363 release(eve): v0.2.0 2026-07-03 11:06:47 -04:00
Ben dd7e252452 feat(eve): auto-memory mode (v0.2.0) — no model tool-calling (#2527)
Replace the MCP-connection helper with automatic long-term memory backed by
Hindsight's REST API. Memory no longer depends on the model choosing to call a
tool (which proved unreliable — the model would reach for bash, a subagent, or
just acknowledge a fact without saving it).

Two authored files now give an Eve agent memory that just works:
- agent/instructions/hindsight.ts -> hindsightMemory(): a defineDynamic
  instructions resolver that recalls the user's stored memory and injects it as
  a system message before each turn.
- agent/hooks/hindsight.ts -> hindsightRetainHook(): a defineHook that retains
  the user message + assistant answer after each turn.

Pure core (HindsightRestClient, resolver, turn-pairing, recall formatting) is
split from the eve-importing wrappers and unit-tested with a mocked fetch.
Config via HINDSIGHT_API_KEY / HINDSIGHT_API_URL / HINDSIGHT_BANK_ID. Recall is
profile-based (eve's instruction resolver can't see the live user message).
Feedback-loop guard fences injected context so recalled facts are never
re-retained. Docs + integrations.json updated; bumped to 0.2.0 (breaking).
2026-07-03 11:02:54 -04:00
Ben fda1a77f70 Add architxt + Hindsight community blog post (#2526)
Community-contributed integration post (by Gareth Cooper) on architxt's
Temporal Mosaic: turning fragmented enterprise architecture documents into
a queryable, current-state view backed by Hindsight. Includes 5 product
screenshots + a co-branded cover, and registers the author in authors.yml.
2026-07-03 09:26:01 -04:00
Nicolò Boschi 0accef8e98 test(retain): add missing llm_temperature_retain to _build_request_body mock (#2537)
`_build_request_body` reads `config.llm_temperature_retain` (added by the
per-operation temperature work, #2459), but test_batch_request_body_strict_
follows_config's SimpleNamespace config never set it, so the test raised
`AttributeError: 'SimpleNamespace' object has no attribute
'llm_temperature_retain'`. It only fails on PRs that touch hindsight-api-slim;
main hides it via path-filtering, so it went unnoticed.

Set it to None (temperature omitted) so the test still asserts purely on the
`strict` flag it targets.
2026-07-03 14:05:31 +02:00
Nicolò Boschi c77e2368de feat(control-plane): animate the memories constellation & open memories in a dialog (#2536)
Constellation (memories + entities views):
- Ambient motion so the star map feels alive: slow per-node drift, a size
  pulse and brightness twinkle (each desynchronized by an id-derived phase),
  a calm breathing shimmer across idle links, and twinkling hub halos.
- On hover, a bead of light travels each of the node's links, so connections
  read as live signal paths rather than static lines.
- Re-measure the canvas via ResizeObserver when its container reflows (e.g.
  the Fullscreen toggle / layout changes) — window "resize" alone missed
  container-only changes, so CSS stretched the old bitmap and squeezed text.

Memories (data) view:
- Drop the right-hand control/detail side panel. Clicking a memory node now
  opens the same rich MemoryDetailModal the table/timeline use.
- Move the constellation controls (Color by, Group by scope, Link types) into
  an inline row above the graph, next to the view toggle — giving the star map
  full width.
2026-07-03 11:46:21 +02:00
Nicolò Boschi 38ef0247c2 fix(curation): drop archive search_vector column, recompute on revert (#2503) (#2514)
The curation archive (invalidated_memory_units) is a `LIKE memory_units`
clone with no index. It carried a `search_vector` column purely as a passive
copy in the invalidate/revert row-move — nothing ever reads it (no text-search
index, and recall/list/get/export all exclude it). But its type is fixed at
tsvector by the clone, while `ensure_text_search_extension` reconciles
`memory_units.search_vector` to text/bm25vector on non-native backends
(pgroonga / pg_textsearch / pg_search / vchord). The archive was never
reconciled, so the curation INSERT ... SELECT round-trip failed:

    column "search_vector" is of type tsvector but expression is of type text

This is the exact situation `embedding` was in (#2209): a config-derived,
recall-only column that has no business on the cold archive. Fix it the same
way `embedding` was fixed (d4f6a8c2e1b3):

- Migration e7c3a9f1b2d5 drops search_vector from invalidated_memory_units
  (PG + Oracle), so there is no column left to mismatch.
- The curation move omits search_vector from arch_cols (alongside embedding),
  so invalidate/revert never copy it.
- On revert, search_vector is recomputed from the row's own text/context/
  text_signals using the *current* text-search backend — right next to the
  existing embedding recompute. This is more correct than the old verbatim
  copy, which could restore a stale/wrong-type vector if the backend changed
  while the fact sat archived.

The per-backend search_vector SQL is extracted into pg_search_vector_expr as a
single source of truth shared by insert and revert (also collapses the three
near-identical insert query blocks into one). pgroonga/pg_textsearch/pg_search
index base columns directly and leave search_vector empty, so the expression is
None for them and the column is simply not written.

Tests: extend the curation suite to assert the archive drops search_vector and
that revert repopulates it (native); add fast unit tests for
pg_search_vector_expr and the per-backend insert column shape.
2026-07-03 11:44:13 +02:00
Parafee41 a158b819f3 fix(control-plane): keep memory filters visible on empty results (#2532) 2026-07-03 09:27:41 +02:00
illidanandillidan 381963c28a Fix JSON viewer unicode output display (#2531)
Co-authored-by: illidan <[email protected]>
2026-07-03 09:27:18 +02:00
BenandBen ba158c9cdb Add Devin Desktop blog cover image (#2524)
Co-authored-by: Ben <[email protected]>
2026-07-02 14:45:05 -04:00
Ben 767a2c0061 Blog: Devin Desktop persistent memory (formerly Windsurf) (#2483)
* Add Devin Desktop persistent memory blog post

Integration walkthrough for hindsight-devin-desktop (Devin Desktop, formerly
Windsurf): persistent memory via a remote MCP server plus an always-on
.devin/rules rule. Supersedes the earlier Windsurf post (same product,
renamed by Cognition in June 2026).
2026-07-02 09:47:40 -04:00
Nicolò Boschi 36334f27a1 refactor(control-plane): drop the Graph view from memories (#2517)
* refactor(control-plane): drop the Graph view from memories

Removes the Cytoscape-based "Graph" visualization from the memories views,
leaving Constellation, Table, and Timeline. The Graph view was the only
consumer of cytoscape, cytoscape-fcose, and the slider UI control.

- Delete the Graph2D component (src/components/graph-2d.tsx); move the
  shared graph data model + API-response converter (still used by the
  Constellation and entities views) into src/components/graph-data.ts.
- Remove the "graph" ViewMode, its tab button, render section, and
  graph-only state/effects (showLabels, maxNodes, linkStats) from
  data-view.tsx. The shared /api/graph data source that feeds all views
  is untouched.
- Drop cytoscape, cytoscape-fcose, @types/cytoscape and the now-orphaned
  @radix-ui/react-slider dependency + ui/slider.tsx.
- Remove the dead graph2d i18n namespace and graph-legend dataView keys
  from all locale catalogs (parity + used-keys tests stay green).

* chore: sync docs-skill openapi.json to 0.8.4

Pre-existing drift: the v0.8.4 release did not regenerate the bundled
docs-skill OpenAPI snapshot, leaving verify-generated-files red. Running
generate-docs-skill.sh bumps only the version string (0.8.3 -> 0.8.4).
Unrelated to the graph-view removal but required to make CI green.
2026-07-02 13:47:01 +02:00
Nicolò Boschi 6a479dddb9 fix(codex): implement strict_schema via forced tool call + repair invalid \escape (#2504) (#2513)
strict_schema was a dead no-op in codex_llm: structured output always went
through prompt-injected schema + raw json.loads on the model's free-form text.
Escape-heavy content (code, serial/CLI commands, Windows paths, regexes) makes
weaker models emit invalid \escape sequences, so every parse attempt fails and
retain/consolidation burn all retries and fail (same class as #1002/#2339).

- strict_schema=True now routes structured output through a single forced
  function tool (constrained decoding into the response schema), mirroring the
  Anthropic forced-tool_use fix (#2339). No prompt-injected schema, no
  json.loads on free-form text.
- The non-strict fallback and tool-argument parsing now repair invalid
  \escape sequences before giving up, stopping the deterministic retry storm
  for the default config.
2026-07-02 12:09:17 +02:00
Nicolò Boschi 7058d1aad7 fix(control-plane): show all mental models instead of capping at 100 (#2512)
* fix(control-plane): load all mental models instead of capping at 100

The mental models view fetched without a limit, so the dataplane applied
its default cap of 100. Any bank with more than 100 mental models silently
hid the rest — the dashboard's pagination and the files view both operate
over the full in-memory list, so nothing past the first 100 was reachable.

Thread limit/offset through the client and proxy route, and page through
the API in loadData() until a short page is returned, accumulating every
mental model for the bank.

* fix(control-plane): use page size of 100 for mental models paging
2026-07-02 12:02:38 +02:00
383 changed files with 28540 additions and 3916 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "hindsight",
"version": "0.7.2",
"version": "0.7.5",
"description": "Official Hindsight integrations for Claude Code",
"owner": {
"name": "vectorize-io"
+24
View File
@@ -59,6 +59,15 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# HINDSIGHT_API_LLM_BASE_URL=http://localhost:1234/v1
# HINDSIGHT_API_LLM_MODEL=qwen2.5-32b-instruct
# Example: Ollama local configuration (native provider)
# HINDSIGHT_API_LLM_PROVIDER=ollama
# HINDSIGHT_API_LLM_BASE_URL=http://localhost:11434/v1
# HINDSIGHT_API_LLM_MODEL=gemma3:12b
# Native Ollama context-window override (num_ctx). Leave unset to let Ollama use
# the model Modelfile / server default; set a positive integer only to force a
# specific context size (e.g. 16384 to keep the previous request behavior).
# HINDSIGHT_API_LLM_OLLAMA_NUM_CTX=16384
# Multi-LLM strategies: configure extra LLMs by index alongside the primary above,
# then pick a routing strategy. Unset = single primary LLM (default). Members are
# numbered from 1; indices must be contiguous. Each operation can override with a
@@ -80,6 +89,10 @@ HINDSIGHT_API_LOG_LEVEL=info
# Unset uses HINDSIGHT_API_RETAIN_CHUNK_SIZE as the structured-chunk limit.
# HINDSIGHT_API_RETAIN_STRUCTURED_CHUNK_SIZE=
# When true, a retain operation that hit any fact-extraction errors is marked
# 'failed' (not 'completed'), surfacing silently-dropped facts. Default false.
# HINDSIGHT_API_FAIL_ON_EXTRACTION_ERRORS=false
# Dry-run extraction preview endpoint (POST /memories/dry-run-extract). Enabled by default; it makes
# a real LLM call but stores nothing. Set to false to remove the endpoint (returns 404).
# HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT=true
@@ -96,6 +109,8 @@ HINDSIGHT_API_LOG_LEVEL=info
# HINDSIGHT_API_MIGRATION_DATABASE_URL= # Direct PostgreSQL URL for migrations (bypasses PgBouncer). Falls back to DATABASE_URL.
# HINDSIGHT_API_DATABASE_SCHEMA=public # PostgreSQL schema name (default: public)
# HINDSIGHT_API_MIGRATION_CONCURRENCY=1 # Tenant schemas to migrate concurrently (PG only, each in its own process; per-schema work stays sequential). Each worker has ~1-2s startup cost + uses ~3 DB connections, so it only pays off with many schemas (tens+) or slow migrations; keep concurrency*3 <= spare max_connections. Default: 1 (sequential).
# HINDSIGHT_API_OPERATION_RETENTION_DAYS=30 # Keep terminal operation rows, payloads, and metadata for this many days; 0 disables automatic pruning.
# HINDSIGHT_API_OPERATION_CLEANUP_BATCH_SIZE=1000 # Maximum expired terminal rows deleted per tenant schema in each cleanup cycle; must be positive.
# Vector Extension (Optional - uses pgvector by default)
# Options: "pgvector" (default), "vchord", "pgvectorscale" (DiskANN)
@@ -115,6 +130,11 @@ HINDSIGHT_API_LOG_LEVEL=info
# chinese_lindera/lindera(chinese), japanese_lindera/lindera(japanese),
# korean_lindera/lindera(korean), ngram(min,max), edge_ngram(min,max)
# HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER=
# Optional cap on the number of terms in the native PostgreSQL BM25 tsquery.
# Long queries OR-join every normalized token, which can match too much of a
# large bank. 0 (default) keeps the historical uncapped behavior; a positive
# value bounds only the native backend (other BM25 backends get the raw query).
# HINDSIGHT_API_BM25_MAX_QUERY_TERMS=0
# File Parser (Optional - uses markitdown by default)
# HINDSIGHT_API_FILE_PARSER=markitdown
@@ -133,6 +153,8 @@ HINDSIGHT_API_LOG_LEVEL=info
# HINDSIGHT_API_EMBEDDINGS_PROVIDER=local
# For local provider:
# HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=BAAI/bge-small-en-v1.5
# Force CPU if local embeddings hit MPS/XPC instability on macOS:
# HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU=false
# For ONNX provider (local CPU embeddings without an Ollama/TEI sidecar):
# HINDSIGHT_API_EMBEDDINGS_PROVIDER=onnx
# HINDSIGHT_API_EMBEDDINGS_ONNX_MODEL_ID=intfloat/multilingual-e5-small
@@ -174,6 +196,8 @@ HINDSIGHT_API_LOG_LEVEL=info
# HINDSIGHT_API_RERANKER_PROVIDER=local
# For local provider:
# HINDSIGHT_API_RERANKER_LOCAL_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
# Force CPU if the local reranker hits MPS/XPC instability on macOS:
# HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU=false
# For TEI provider:
# HINDSIGHT_API_RERANKER_TEI_URL=http://localhost:8081
+7 -12
View File
@@ -520,22 +520,17 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Python
uses: actions/setup-python@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
python-version: '3.11'
- name: Install package and pytest
working-directory: ./hindsight-integrations/zed
# Installs the package (incl. the zstandard runtime dep) so the threads.db
# reader tests can decompress Zed's zstd blobs.
run: pip install -e . pytest
node-version: '22'
- name: Run tests
working-directory: ./hindsight-integrations/zed
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: python -m pytest tests/ -v -m "not requires_real_llm"
# Config-only integration with no dependencies — it uses Node's built-in
# test runner. The runtime MCP bridge is `npx mcp-remote` (Node), so this
# integration requires only Node.js (no Python).
run: npm test
test-omo-integration:
needs: [detect-changes]
@@ -0,0 +1,82 @@
"""Add indexes for terminal cleanup and newest-first operation listing.
Revision ID: a8c1e4f7b0d3
Revises: f2a4b6c8d0e2
Create Date: 2026-07-14
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a8c1e4f7b0d3"
down_revision: str | Sequence[str] | None = "f2a4b6c8d0e2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
"""Schema-qualifier for PostgreSQL multi-tenant migration runs."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
# These can be large tables in long-running installations. Concurrent DDL
# keeps operation submission, polling, and status reads available.
with op.get_context().autocommit_block():
op.execute(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_async_operations_terminal_cleanup "
f"ON {schema}async_operations (updated_at, operation_id) "
"WHERE status IN ('completed', 'failed', 'cancelled')"
)
op.execute(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_async_operations_bank_created_desc "
f"ON {schema}async_operations (bank_id, created_at DESC)"
)
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
with op.get_context().autocommit_block():
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_async_operations_bank_created_desc")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_async_operations_terminal_cleanup")
def _oracle_create_index(sql: str) -> None:
"""Create an index idempotently for rerun-safe Oracle migrations."""
block = (
"BEGIN "
"EXECUTE IMMEDIATE :stmt; "
"EXCEPTION WHEN OTHERS THEN "
"IF SQLCODE = -955 THEN NULL; ELSE RAISE; END IF; "
"END;"
)
op.get_bind().exec_driver_sql(block, {"stmt": sql})
def _oracle_upgrade() -> None:
# Oracle migrations run with CURRENT_SCHEMA set to each tenant, so table
# and index names intentionally remain unqualified here.
_oracle_create_index(
"CREATE INDEX idx_async_operations_terminal_cleanup ON async_operations (updated_at, operation_id, status)"
)
_oracle_create_index(
"CREATE INDEX idx_async_operations_bank_created_desc ON async_operations (bank_id, created_at DESC)"
)
def _oracle_downgrade() -> None:
op.execute("DROP INDEX idx_async_operations_bank_created_desc")
op.execute("DROP INDEX idx_async_operations_terminal_cleanup")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -0,0 +1,96 @@
"""Drop the search_vector column from the curation archive (invalidated_memory_units).
The archive is cold storage, never a recall surface, and carries no text-search
index. Like ``embedding`` (dropped in d4f6a8c2e1b3), ``search_vector`` is a
recall-surface column whose type follows the configured text-search backend, so
it has no business living on the archive. Earlier curation code copied the live
row's ``search_vector`` into ``invalidated_memory_units`` on invalidate; the
engine now leaves it out on invalidate and recomputes it on revert, so the
column is dead weight.
Dropping it removes a latent failure mode (#2503): under a non-native backend
(pgroonga / pg_textsearch / pg_search / vchord) ``ensure_text_search_extension``
reconciles ``memory_units.search_vector`` to ``text`` / ``bm25vector`` but never
touched the archive, which the ``LIKE memory_units`` clone (c9a1b2d3e4f5) created
as ``tsvector``. The type mismatch then broke the curation INSERT … SELECT
round-trip:
column "search_vector" is of type tsvector but expression is of type text
With no column at all, there is nothing to mismatch. Unlike ``embedding`` (whose
creation sites already omit it), the ``LIKE`` clone still adds ``search_vector``,
so this migration does real work on both fresh and existing PostgreSQL databases.
DROP COLUMN is a metadata-only operation on both PostgreSQL and Oracle 23ai (no
table rewrite), so it is cheap even across many tenant schemas. The downgrade
re-adds an empty ``tsvector`` column (its original creation type).
Revision ID: e7c3a9f1b2d5
Revises: b57a7c9e0d13
Create Date: 2026-07-02
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "e7c3a9f1b2d5"
down_revision: str | Sequence[str] | None = "b57a7c9e0d13"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"ALTER TABLE {schema}invalidated_memory_units DROP COLUMN IF EXISTS search_vector")
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
# Re-add as the original tsvector creation type; comes back empty regardless.
op.execute(f"ALTER TABLE {schema}invalidated_memory_units ADD COLUMN IF NOT EXISTS search_vector tsvector")
def _oracle_upgrade() -> None:
# Oracle has no `DROP COLUMN IF EXISTS`; swallow ORA-00904 (column does not
# exist) so the migration is idempotent and safe on a schema whose baseline
# may already omit the column.
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units DROP COLUMN search_vector';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -904 THEN RAISE; END IF;
END;
"""
)
def _oracle_downgrade() -> None:
# Swallow ORA-01430 (column already exists) for idempotency. Oracle stores
# search_vector as CLOB (see the Oracle baseline), so re-add it as CLOB.
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units ADD (search_vector CLOB)';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -1430 THEN RAISE; END IF;
END;
"""
)
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -0,0 +1,158 @@
"""Install the shared public.* maintenance routines on every PG run.
``public.banks_needing_consolidation()`` and
``public.schemas_with_expired_rows(...)`` physically live in ``public`` and are
consumed by the background maintenance loop through a hardcoded ``public.``
qualifier (``engine/maintenance.py``). Every prior install/repair
(``e5f6a7b8c9d0`` → ``b2d4f6a8c1e3`` → ``c7e9f1a3b5d2``) gated creation on
``_should_install_public_routines(target_schema)`` — i.e. the base run
(no ``target_schema``) or an explicit ``target_schema='public'`` run.
That leaves a gap for a **single-tenant deployment migrated into a dedicated,
non-``public`` schema** (``HINDSIGHT_API_DATABASE_SCHEMA=<non-public>``). The
runtime migrates only that one schema (``run_migrations_for_schemas`` fans out
per configured schema), so ``target_schema`` is never falsy or ``public`` and
the routines are never created. The maintenance loop then logs, forever::
function public.banks_needing_consolidation() does not exist
function public.schemas_with_expired_rows(...) does not exist
and the revision is stamped applied, so redeploying the same version does not
help (issue #2638; #2056 only fixed the ``public``/base-run case).
Fix: install the routines on **every** PG run regardless of ``target_schema``.
They are schema-agnostic (they enumerate ``pg_class`` and dispatch per schema),
so a non-``public`` run creating them in ``public`` is correct and idempotent
via ``CREATE OR REPLACE``. The reason the earlier migrations gated to a single
run was to avoid ``tuple concurrently updated`` when parallel per-schema
migration processes race on the same ``CREATE OR REPLACE``; we keep that safety
with a transaction-scoped advisory lock so exactly one concurrent run performs
the replace at a time. Function bodies are identical to ``c7e9f1a3b5d2`` (the
vanishing-schema-resilient versions).
Revision ID: f2a4b6c8d0e2
Revises: e7c3a9f1b2d5
Create Date: 2026-07-13
"""
from collections.abc import Sequence
from alembic import op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "f2a4b6c8d0e2"
down_revision: str | Sequence[str] | None = "e7c3a9f1b2d5"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# Stable 64-bit key for the transaction-scoped advisory lock that serializes
# the CREATE OR REPLACE against concurrent per-schema migration processes.
# Arbitrary constant; only needs to be identical across processes.
_ROUTINE_INSTALL_LOCK_KEY = 472638_00000001
def _pg_upgrade() -> None:
# Serialize concurrent per-schema migration processes so only one performs
# the CREATE OR REPLACE at a time (avoids `tuple concurrently updated`).
# Transaction-scoped: released automatically at commit.
op.execute(f"SELECT pg_advisory_xact_lock({_ROUTINE_INSTALL_LOCK_KEY})")
op.execute(
"""
CREATE OR REPLACE FUNCTION public.banks_needing_consolidation()
RETURNS TABLE(schema_name text, bank_id text)
LANGUAGE plpgsql STABLE
AS $fn$
DECLARE
sch text;
BEGIN
FOR sch IN
SELECT n.nspname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'memory_units' AND c.relkind = 'r'
LOOP
BEGIN
RETURN QUERY EXECUTE format($q$
SELECT %1$L::text, m.bank_id
FROM %1$I.memory_units m
JOIN %1$I.banks b ON b.bank_id = m.bank_id
WHERE m.consolidated_at IS NULL
AND m.consolidation_failed_at IS NULL
AND m.fact_type IN ('experience', 'world')
AND COALESCE(b.config -> 'enable_auto_consolidation', 'true'::jsonb) <> 'false'::jsonb
AND NOT EXISTS (
SELECT 1 FROM %1$I.async_operations o
WHERE o.bank_id = m.bank_id
AND o.operation_type = 'consolidation'
AND o.status IN ('pending', 'processing')
)
GROUP BY m.bank_id
$q$, sch);
EXCEPTION
-- Schema or its tables vanished between the pg_class
-- snapshot and this query (tenant dropped or migrating).
WHEN undefined_table OR invalid_schema_name OR undefined_column THEN
CONTINUE;
END;
END LOOP;
END;
$fn$;
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION public.schemas_with_expired_rows(
p_table text, p_ts_col text, p_days int
)
RETURNS SETOF text
LANGUAGE plpgsql STABLE
AS $fn$
DECLARE
sch text;
has_expired boolean;
BEGIN
IF p_days IS NULL OR p_days <= 0 THEN
RETURN;
END IF;
FOR sch IN
SELECT n.nspname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = p_table AND c.relkind = 'r'
LOOP
BEGIN
EXECUTE format(
'SELECT EXISTS (SELECT 1 FROM %I.%I WHERE %I < NOW() - make_interval(days => $1))',
sch, p_table, p_ts_col
) INTO has_expired USING p_days;
EXCEPTION
-- Schema or its table vanished mid-scan; skip it.
WHEN undefined_table OR invalid_schema_name OR undefined_column THEN
CONTINUE;
END;
IF has_expired THEN
RETURN NEXT sch;
END IF;
END LOOP;
END;
$fn$;
"""
)
def _pg_downgrade() -> None:
# No-op: e5f6a7b8c9d0 owns these functions' lifecycle and drops them on its
# own downgrade. This migration only (re)installs them on more runs, so there
# is nothing to undo without racing that migration's DROP.
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
+30 -12
View File
@@ -52,6 +52,7 @@ from fastapi.routing import APIRoute
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from hindsight_api import MemoryEngine
from hindsight_api.config import RETAIN_EXTRACTION_MODES
def _annotation_is_nullable(annotation: Any) -> bool:
@@ -1245,7 +1246,7 @@ class CreateBankRequest(BaseModel):
)
retain_extraction_mode: str | None = Field(
default=None,
description="Fact extraction mode: 'concise' (default), 'verbose', or 'custom'.",
description="Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'.",
)
retain_custom_instructions: str | None = Field(
default=None,
@@ -1433,6 +1434,7 @@ class ListMemoryUnitsResponse(BaseModel):
"date": "2024-01-15T10:30:00Z",
"type": "world",
"entities": "Alice (PERSON), Google (ORGANIZATION)",
"metadata": {"source": "slack", "channel": "engineering"},
}
],
"total": 150,
@@ -1666,8 +1668,8 @@ class UpdateMemoryRequest(BaseModel):
@model_validator(mode="after")
def _require_an_edit(self) -> "UpdateMemoryRequest":
if all(
v is None
has_value_edit = any(
v is not None
for v in (
self.text,
self.context,
@@ -1677,7 +1679,9 @@ class UpdateMemoryRequest(BaseModel):
self.entities,
self.state,
)
):
)
has_date_clear = bool({"occurred_start", "occurred_end"} & self.model_fields_set)
if not has_value_edit and not has_date_clear:
raise ValueError("Provide at least one field to update.")
if self.state is not None and self.state not in ("valid", "invalidated"):
raise ValueError("state must be 'valid' or 'invalidated'.")
@@ -2203,7 +2207,8 @@ class BankTemplateConfig(BaseModel):
reflect_mission: str | None = Field(default=None, description="Mission/context for Reflect operations")
retain_mission: str | None = Field(default=None, description="Steers what gets extracted during retain")
retain_extraction_mode: str | None = Field(
default=None, description="Fact extraction mode: 'concise' (default), 'verbose', or 'custom'"
default=None,
description="Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'",
)
retain_custom_instructions: str | None = Field(
default=None, description="Custom extraction prompt (when mode='custom')"
@@ -2429,10 +2434,10 @@ def validate_bank_template(manifest: "BankTemplateManifest") -> list[str]:
if manifest.bank:
bank = manifest.bank
if bank.retain_extraction_mode is not None:
valid_modes = ("concise", "verbose", "custom", "chunks")
if bank.retain_extraction_mode not in valid_modes:
if bank.retain_extraction_mode not in RETAIN_EXTRACTION_MODES:
errors.append(
f"bank.retain_extraction_mode: must be one of {valid_modes}, got '{bank.retain_extraction_mode}'"
"bank.retain_extraction_mode: "
f"must be one of {RETAIN_EXTRACTION_MODES}, got '{bank.retain_extraction_mode}'"
)
if bank.retain_custom_instructions and bank.retain_extraction_mode != "custom":
errors.append("bank.retain_custom_instructions: requires retain_extraction_mode='custom'")
@@ -3139,6 +3144,8 @@ def create_app(
max_slots=config.worker_max_slots,
slot_reservations=config.worker_slot_reservations,
consolidation_bank_priority=config.worker_consolidation_bank_priority or None,
operation_retention_days=config.operation_retention_days,
operation_cleanup_batch_size=config.operation_cleanup_batch_size,
)
poller_task = asyncio.create_task(poller.run())
logging.info(f"Worker poller started (worker_id={worker_id})")
@@ -3750,13 +3757,23 @@ def _register_routes(app: FastAPI):
):
"""Curate a single memory unit (edit text / invalidate / revert)."""
try:
occurred_start = (
""
if "occurred_start" in request.model_fields_set and request.occurred_start is None
else request.occurred_start
)
occurred_end = (
""
if "occurred_end" in request.model_fields_set and request.occurred_end is None
else request.occurred_end
)
data = await app.state.memory.update_memory_unit(
bank_id=bank_id,
memory_id=memory_id,
text=request.text,
context=request.context,
occurred_start=request.occurred_start,
occurred_end=request.occurred_end,
occurred_start=occurred_start,
occurred_end=occurred_end,
new_fact_type=request.fact_type,
entities=request.entities,
state=request.state,
@@ -5407,8 +5424,9 @@ def _register_routes(app: FastAPI):
"/v1/default/banks/{bank_id}/operations/{operation_id}",
response_model=OperationStatusResponse,
summary="Get operation status",
description="Get the status of a specific async operation. Returns 'pending', 'completed', or 'failed'. "
"Completed operations are removed from storage, so 'completed' means the operation finished successfully.",
description="Get the status of a specific async operation. Returns 'pending', 'processing', 'completed', "
"'failed', or 'cancelled'. Completed operations remain queryable with their payload for the configured "
"retention window and are pruned afterward.",
operation_id="get_operation_status",
tags=["Operations"],
)
@@ -147,6 +147,7 @@ ENV_LLM_EXTRA_BODY = "HINDSIGHT_API_LLM_EXTRA_BODY"
ENV_LLM_DEFAULT_HEADERS = "HINDSIGHT_API_LLM_DEFAULT_HEADERS"
ENV_LLM_STRICT_SCHEMA = "HINDSIGHT_API_LLM_STRICT_SCHEMA"
ENV_LLM_SEND_BANK_AS_USER = "HINDSIGHT_API_LLM_SEND_BANK_AS_USER"
ENV_LLM_OLLAMA_NUM_CTX = "HINDSIGHT_API_LLM_OLLAMA_NUM_CTX"
# Per-operation sampling temperature. Each internal LLM call uses a temperature
# tuned for its task (deterministic extraction vs. creative reflection). These
@@ -598,6 +599,8 @@ ENV_WORKER_MAX_RETRIES = "HINDSIGHT_API_WORKER_MAX_RETRIES"
ENV_WORKER_TASK_RETRY_BACKOFF_SECONDS = "HINDSIGHT_API_WORKER_TASK_RETRY_BACKOFF_SECONDS"
ENV_WORKER_HTTP_PORT = "HINDSIGHT_API_WORKER_HTTP_PORT"
ENV_WORKER_MAX_SLOTS = "HINDSIGHT_API_WORKER_MAX_SLOTS"
ENV_OPERATION_RETENTION_DAYS = "HINDSIGHT_API_OPERATION_RETENTION_DAYS"
ENV_OPERATION_CLEANUP_BATCH_SIZE = "HINDSIGHT_API_OPERATION_CLEANUP_BATCH_SIZE"
# Per-operation-type slot reservations. Each entry maps an operation_type
# (as stored in async_operations.operation_type) to its env var and default.
@@ -638,6 +641,7 @@ ENV_RECALL_BUDGET_MAX = "HINDSIGHT_API_RECALL_BUDGET_MAX"
# Recall candidate gating (per-source cap + BM25 score floor)
ENV_BM25_MIN_SCORE = "HINDSIGHT_API_BM25_MIN_SCORE"
ENV_BM25_MAX_QUERY_TERMS = "HINDSIGHT_API_BM25_MAX_QUERY_TERMS"
ENV_RECALL_MAX_CANDIDATES_PER_SOURCE = "HINDSIGHT_API_RECALL_MAX_CANDIDATES_PER_SOURCE"
# Per-strategy recall boost. Prioritises specific retrieval arms (semantic,
# bm25, graph, temporal) on recall via a human priority level — e.g.
@@ -661,6 +665,9 @@ ENV_AUDIT_LOG_ENABLED = "HINDSIGHT_API_AUDIT_LOG_ENABLED"
ENV_AUDIT_LOG_ACTIONS = "HINDSIGHT_API_AUDIT_LOG_ACTIONS"
ENV_AUDIT_LOG_RETENTION_DAYS = "HINDSIGHT_API_AUDIT_LOG_RETENTION_DAYS"
# Retain reliability settings
ENV_FAIL_ON_EXTRACTION_ERRORS = "HINDSIGHT_API_FAIL_ON_EXTRACTION_ERRORS"
# LLM request tracing settings
ENV_LLM_TRACE_ENABLED = "HINDSIGHT_API_LLM_TRACE_ENABLED"
ENV_LLM_TRACE_SCOPES = "HINDSIGHT_API_LLM_TRACE_SCOPES"
@@ -789,6 +796,9 @@ DEFAULT_SEMANTIC_MIN_SIMILARITY = 0.3
# zero-score (non-matching) rows on backends — notably VectorChord — whose
# operator ranks every document rather than pre-filtering to term matches.
DEFAULT_BM25_MIN_SCORE = 0.0
# Native tsvector BM25 can optionally cap the OR tsquery built from normalized
# query tokens. 0 preserves the historical uncapped behavior.
DEFAULT_BM25_MAX_QUERY_TERMS = 0
# Per-source candidate cap applied to each retrieval arm (semantic, BM25, graph,
# temporal) before RRF, so a single over-expanding backend cannot fill the
# reranker's global candidate budget on its own. 0 disables the cap.
@@ -1050,6 +1060,10 @@ DEFAULT_WORKER_MAX_RETRIES = 3 # Max retries before marking task failed
DEFAULT_WORKER_TASK_RETRY_BACKOFF_SECONDS = 60 # Seconds between retries on transient task failure
DEFAULT_WORKER_HTTP_PORT = 8889 # HTTP port for worker metrics/health
DEFAULT_WORKER_MAX_SLOTS = 10 # Total concurrent tasks per worker
# Terminal rows keep their payload and metadata for one coherent debug/retry TTL.
# Zero retention days disables automatic pruning entirely.
DEFAULT_OPERATION_RETENTION_DAYS = 30
DEFAULT_OPERATION_CLEANUP_BATCH_SIZE = 1000
DEFAULT_RETAIN_MAX_CONCURRENT = 4 # Max concurrent retain DB phases (HNSW reads + writes). Limits I/O contention.
# Reflect agent settings
@@ -1094,6 +1108,11 @@ DEFAULT_AUDIT_LOG_ENABLED = False # Disabled by default
DEFAULT_AUDIT_LOG_ACTIONS = "" # Empty = audit all eligible actions
DEFAULT_AUDIT_LOG_RETENTION_DAYS = -1 # -1 = keep forever
# Retain reliability defaults
DEFAULT_FAIL_ON_EXTRACTION_ERRORS = (
False # Preserve existing behavior: retain completes even if some chunks fail extraction
)
# LLM request tracing defaults
DEFAULT_LLM_TRACE_ENABLED = True # Enabled by default
DEFAULT_LLM_TRACE_SCOPES = "" # Empty = trace all call scopes
@@ -1209,6 +1228,19 @@ def _parse_positive_int(name: str, raw: str | None, default: int) -> int:
return parsed
def _parse_non_negative_int(name: str, raw: str | None, default: int) -> int:
"""Parse an env var that must be an integer >= 0."""
if raw is None or raw == "":
return default
try:
parsed = int(raw)
except ValueError as e:
raise ValueError(f"{name} must be an integer, got {raw!r}") from e
if parsed < 0:
raise ValueError(f"{name} must be >= 0, got {parsed}")
return parsed
def _parse_optional_positive_int(name: str, raw: str | None) -> int | None:
"""Parse an optional env var that must be a positive integer when set."""
if raw is None or raw == "":
@@ -1577,6 +1609,9 @@ class HindsightConfig:
# LiteLLM, Helicone) key attribution on the OpenAI `user` field. Opt-in; never
# overrides a `user` the caller already set.
llm_send_bank_as_user: bool
# Optional native Ollama context window override. Unset lets Ollama use the
# model/server default instead of forcing a Hindsight-wide value.
llm_ollama_num_ctx: int | None = field(default=None, kw_only=True)
# Per-operation sampling temperature. None means the temperature parameter is
# omitted from the call (for models that reject explicit temperatures). See
@@ -1908,6 +1943,8 @@ class HindsightConfig:
worker_max_slots: int
worker_slot_reservations: dict[str, int]
worker_consolidation_bank_priority: dict[str, int]
operation_retention_days: int
operation_cleanup_batch_size: int
retain_max_concurrent: int
# Reflect agent settings
@@ -1929,6 +1966,11 @@ class HindsightConfig:
audit_log_actions: list[str] # Allowlist of action types (empty = all)
audit_log_retention_days: int # -1 = keep forever, >0 = delete after N days
# Retain reliability configuration (static - server-level only)
# When True, a retain operation that accumulated any fact-extraction errors is
# marked 'failed' instead of 'completed', surfacing silent fact loss to clients.
fail_on_extraction_errors: bool
# LLM request tracing configuration (static - server-level only)
llm_trace_enabled: bool # Master switch for per-bank LLM request tracing
llm_trace_scopes: list[str] # Allowlist of call scopes to trace (empty = all)
@@ -1979,6 +2021,7 @@ class HindsightConfig:
reflect_llm_strategy: LLMStrategyConfig | None = None
consolidation_llm_members: list[LLMMemberConfig] = field(default_factory=list)
consolidation_llm_strategy: LLMStrategyConfig | None = None
bm25_max_query_terms: int = DEFAULT_BM25_MAX_QUERY_TERMS
# Class-level sets for configuration categorization
@@ -2179,6 +2222,9 @@ class HindsightConfig:
f"Invalid semantic_min_similarity: {self.semantic_min_similarity}. Must be between 0.0 and 1.0"
)
if self.bm25_max_query_terms < 0:
raise ValueError(f"Invalid bm25_max_query_terms: {self.bm25_max_query_terms}. Must be >= 0")
# Validate bedrock_service_tier
valid_bedrock_tiers = (None, "flex", "priority", "reserved")
if self.llm_bedrock_service_tier not in valid_bedrock_tiers:
@@ -2268,6 +2314,13 @@ class HindsightConfig:
f"Reduce reservations or increase HINDSIGHT_API_WORKER_MAX_SLOTS."
)
if self.operation_retention_days < 0:
raise ValueError(f"{ENV_OPERATION_RETENTION_DAYS} must be >= 0, got {self.operation_retention_days}")
if self.operation_cleanup_batch_size < 1:
raise ValueError(
f"{ENV_OPERATION_CLEANUP_BATCH_SIZE} must be >= 1, got {self.operation_cleanup_batch_size}"
)
@classmethod
def from_env(cls) -> "HindsightConfig":
"""Create configuration from environment variables."""
@@ -2319,6 +2372,10 @@ class HindsightConfig:
llm_strict_schema=os.getenv(ENV_LLM_STRICT_SCHEMA, str(DEFAULT_LLM_STRICT_SCHEMA)).lower() in ("true", "1"),
llm_send_bank_as_user=os.getenv(ENV_LLM_SEND_BANK_AS_USER, str(DEFAULT_LLM_SEND_BANK_AS_USER)).lower()
in ("true", "1"),
llm_ollama_num_ctx=_parse_optional_positive_int(
ENV_LLM_OLLAMA_NUM_CTX,
os.getenv(ENV_LLM_OLLAMA_NUM_CTX),
),
llm_temperature_verification=_resolve_operation_temperature(
ENV_LLM_TEMPERATURE_VERIFICATION, DEFAULT_LLM_TEMPERATURE_VERIFICATION
),
@@ -2608,6 +2665,11 @@ class HindsightConfig:
reranker_max_candidates=int(os.getenv(ENV_RERANKER_MAX_CANDIDATES, str(DEFAULT_RERANKER_MAX_CANDIDATES))),
semantic_min_similarity=float(os.getenv(ENV_SEMANTIC_MIN_SIMILARITY, str(DEFAULT_SEMANTIC_MIN_SIMILARITY))),
bm25_min_score=float(os.getenv(ENV_BM25_MIN_SCORE, str(DEFAULT_BM25_MIN_SCORE))),
bm25_max_query_terms=_parse_non_negative_int(
ENV_BM25_MAX_QUERY_TERMS,
os.getenv(ENV_BM25_MAX_QUERY_TERMS),
DEFAULT_BM25_MAX_QUERY_TERMS,
),
recall_max_candidates_per_source=int(
os.getenv(ENV_RECALL_MAX_CANDIDATES_PER_SOURCE, str(DEFAULT_RECALL_MAX_CANDIDATES_PER_SOURCE))
),
@@ -2924,6 +2986,16 @@ class HindsightConfig:
worker_consolidation_bank_priority=_parse_bank_priority(
os.getenv(ENV_WORKER_CONSOLIDATION_BANK_PRIORITY, "")
),
operation_retention_days=_parse_non_negative_int(
ENV_OPERATION_RETENTION_DAYS,
os.getenv(ENV_OPERATION_RETENTION_DAYS),
DEFAULT_OPERATION_RETENTION_DAYS,
),
operation_cleanup_batch_size=_parse_positive_int(
ENV_OPERATION_CLEANUP_BATCH_SIZE,
os.getenv(ENV_OPERATION_CLEANUP_BATCH_SIZE),
DEFAULT_OPERATION_CLEANUP_BATCH_SIZE,
),
retain_max_concurrent=int(os.getenv(ENV_RETAIN_MAX_CONCURRENT, str(DEFAULT_RETAIN_MAX_CONCURRENT))),
# Reflect agent settings
reflect_max_iterations=int(os.getenv(ENV_REFLECT_MAX_ITERATIONS, str(DEFAULT_REFLECT_MAX_ITERATIONS))),
@@ -2989,6 +3061,11 @@ class HindsightConfig:
audit_log_retention_days=int(
os.getenv(ENV_AUDIT_LOG_RETENTION_DAYS, str(DEFAULT_AUDIT_LOG_RETENTION_DAYS))
),
# Retain reliability configuration (static, server-level only)
fail_on_extraction_errors=os.getenv(
ENV_FAIL_ON_EXTRACTION_ERRORS, str(DEFAULT_FAIL_ON_EXTRACTION_ERRORS)
).lower()
== "true",
# LLM request tracing configuration (static, server-level only)
llm_trace_enabled=os.getenv(ENV_LLM_TRACE_ENABLED, str(DEFAULT_LLM_TRACE_ENABLED)).lower() == "true",
llm_trace_scopes=[
@@ -109,6 +109,11 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
end.replace(hour=23, minute=59, second=59, microsecond=999999),
)
def safe_constraint(start: datetime | None, end: datetime | None) -> DateRange | NoTemporalConstraintSentinel:
if start is None or end is None:
return NO_TEMPORAL_CONSTRAINT
return constraint(start, end)
def subtract_months(months: int) -> datetime:
month_index = reference_date.month - months - 1
year = reference_date.year + month_index // 12
@@ -126,11 +131,21 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
day = min(base_date.day, calendar.monthrange(year, month)[1])
return base_date.replace(year=year, month=month, day=day)
def add_years(base_date: datetime, years: int) -> datetime:
def add_years(base_date: datetime, years: int) -> datetime | None:
year = base_date.year + years
if year < datetime.min.year or year > datetime.max.year:
return None
day = min(base_date.day, calendar.monthrange(year, base_date.month)[1])
return base_date.replace(year=year, day=day)
def add_days(base_date: datetime | None, days: int) -> datetime | None:
if base_date is None:
return None
try:
return base_date + timedelta(days=days)
except OverflowError:
return None
def has_chinese_temporal_context(match: re.Match[str]) -> bool:
if match.end() >= len(query):
return True
@@ -438,6 +453,11 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
return NO_TEMPORAL_CONSTRAINT
return constraint(start, reference_date)
def safe_since_constraint(start: datetime | None) -> DateRange | NoTemporalConstraintSentinel:
if start is None:
return NO_TEMPORAL_CONSTRAINT
return since_constraint(start)
def since_from_period(
period: DateRange | None,
) -> DateRange | NoTemporalConstraintSentinel | None:
@@ -450,7 +470,7 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
return None
return since_constraint(day)
def relative_offset_datetime(amount: int, unit: str, direction: int) -> datetime:
def relative_offset_datetime(amount: int, unit: str, direction: int) -> datetime | None:
if unit in ("", ""):
return reference_date + timedelta(days=direction * amount)
if unit in ("", "星期", "礼拜"):
@@ -459,15 +479,15 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
return add_months(reference_date, direction * amount)
return add_years(reference_date, direction * amount)
def point_constraint_at_offset(amount: int, unit: str, direction: int) -> DateRange:
def point_constraint_at_offset(amount: int, unit: str, direction: int) -> DateRange | NoTemporalConstraintSentinel:
d = relative_offset_datetime(amount, unit, direction)
return constraint(d, d)
return safe_constraint(d, d)
def window_to_reference(amount: int, unit: str) -> DateRange:
return constraint(relative_offset_datetime(amount, unit, -1), reference_date)
def window_to_reference(amount: int, unit: str) -> DateRange | NoTemporalConstraintSentinel:
return safe_constraint(relative_offset_datetime(amount, unit, -1), reference_date)
def window_from_reference(amount: int, unit: str) -> DateRange:
return constraint(reference_date, relative_offset_datetime(amount, unit, 1))
def window_from_reference(amount: int, unit: str) -> DateRange | NoTemporalConstraintSentinel:
return safe_constraint(reference_date, relative_offset_datetime(amount, unit, 1))
# Chinese rule guide
#
@@ -781,8 +801,8 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
if relative_year_fixed_day_since_match:
year = relative_year_number(relative_year_fixed_day_since_match.group(1))
base = add_years(reference_date, year - reference_date.year)
d = base + timedelta(days=fixed_day_offset(relative_year_fixed_day_since_match.group(2)))
return since_constraint(d)
d = add_days(base, fixed_day_offset(relative_year_fixed_day_since_match.group(2)))
return safe_since_constraint(d)
fixed_day_since_match = chinese_search(
rf"(大大后天|大后天|后天|明天|明日|今天|今日|本日|当日|当天|昨天|昨日|大大前天|大前天|前天)"
@@ -799,7 +819,7 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
amount = parse_chinese_number(exact_relative_since_match.group(1))
unit = exact_relative_since_match.group(2)
if amount is not None:
return since_constraint(relative_offset_datetime(amount, unit, -1))
return safe_since_constraint(relative_offset_datetime(amount, unit, -1))
weekend_since_match = chinese_search(
rf"(?<![上下大小每个各隔])"
@@ -899,8 +919,8 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
if relative_year_daypart_since_match:
year = relative_year_number(relative_year_daypart_since_match.group(1))
base = add_years(reference_date, year - reference_date.year)
d = base + timedelta(days=daypart_day_offset(relative_year_daypart_since_match.group(2)))
return since_constraint(d)
d = add_days(base, daypart_day_offset(relative_year_daypart_since_match.group(2)))
return safe_since_constraint(d)
daypart_since_match = chinese_search(
rf"(昨晚|昨夜|前晚|前夜|今晚|今早|今晨|明早|明晚|明夜){chinese_since_suffix_pattern}"
@@ -915,17 +935,17 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
if relative_year_daypart_match:
year = relative_year_number(relative_year_daypart_match.group(1))
base = add_years(reference_date, year - reference_date.year)
d = base + timedelta(days=daypart_day_offset(relative_year_daypart_match.group(2)))
return constraint(d, d)
d = add_days(base, daypart_day_offset(relative_year_daypart_match.group(2)))
return safe_constraint(d, d)
# Day-part abbreviations still resolve only to date granularity.
if chinese_search(r"昨晚|昨夜"):
d = reference_date + timedelta(days=daypart_day_offset("昨晚"))
return constraint(d, d)
d = add_days(reference_date, daypart_day_offset("昨晚"))
return safe_constraint(d, d)
if chinese_search(r"前晚|前夜"):
d = reference_date + timedelta(days=daypart_day_offset("前晚"))
return constraint(d, d)
d = add_days(reference_date, daypart_day_offset("前晚"))
return safe_constraint(d, d)
if chinese_search(r"今晚|今早|今晨"):
return constraint(reference_date, reference_date)
@@ -941,8 +961,8 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
if relative_year_fixed_day_match:
year = relative_year_number(relative_year_fixed_day_match.group(1))
base = add_years(reference_date, year - reference_date.year)
d = base + timedelta(days=fixed_day_offset(relative_year_fixed_day_match.group(2)))
return constraint(d, d)
d = add_days(base, fixed_day_offset(relative_year_fixed_day_match.group(2)))
return safe_constraint(d, d)
if chinese_search(r"昨天|昨日"):
d = reference_date - timedelta(days=1)
@@ -1085,7 +1105,7 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
end_amount = parse_chinese_number(amount_text[-1])
unit = adjacent_fuzzy_future_match.group(2)
if start_amount is not None and end_amount is not None:
return constraint(
return safe_constraint(
relative_offset_datetime(start_amount, unit, 1),
relative_offset_datetime(end_amount, unit, 1),
)
@@ -1093,7 +1113,7 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
few_future_match = chinese_search(rf"[几数]个?(天|日|周|星期|礼拜|月|年){chinese_relative_future_suffix_pattern}")
if few_future_match:
unit = few_future_match.group(1)
return constraint(relative_offset_datetime(2, unit, 1), relative_offset_datetime(5, unit, 1))
return safe_constraint(relative_offset_datetime(2, unit, 1), relative_offset_datetime(5, unit, 1))
exact_future_match = chinese_search(
rf"(?<![{_CHINESE_NUMERAL_PREFIX_CHARS}])([0-9]+|[{_CHINESE_NUMERAL_CHARS}]+)个?(天|日|周|星期|礼拜|月|年){chinese_relative_future_suffix_pattern}"
@@ -1113,7 +1133,7 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
second_amount = parse_chinese_number(adjacent_fuzzy_past_match.group(2))
unit = adjacent_fuzzy_past_match.group(3)
if first_amount is not None and second_amount is not None and second_amount == first_amount + 1:
return constraint(
return safe_constraint(
relative_offset_datetime(second_amount, unit, -1),
relative_offset_datetime(first_amount, unit, -1),
)
@@ -1144,7 +1164,7 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
return constraint(reference_date - timedelta(days=150), reference_date - timedelta(days=60))
if chinese_search(r"一两年前|[两二]三年前|三两年前"):
return constraint(add_years(reference_date, -3), add_years(reference_date, -1))
return safe_constraint(add_years(reference_date, -3), add_years(reference_date, -1))
rolling_this_adjacent_match = chinese_search(
r"这(一两|[两二]三|三两|三四|四五|五六|六七|七八|八九|九十)个?(天|日|周|星期|礼拜|月|年)"
@@ -1154,7 +1174,7 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
end_amount = 3 if amount_text in ("一两", "三两") else parse_chinese_number(amount_text[-1])
unit = rolling_this_adjacent_match.group(2)
if end_amount is not None:
return constraint(relative_offset_datetime(end_amount, unit, -1), reference_date)
return safe_constraint(relative_offset_datetime(end_amount, unit, -1), reference_date)
rolling_this_count_match = chinese_search(rf"这([0-9]+|[{_CHINESE_NUMERAL_CHARS}]+)个?(天|日|周|星期|礼拜|月|年)")
if rolling_this_count_match:
@@ -1191,7 +1211,7 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
end_amount = 3 if amount_text in ("一两", "三两") else parse_chinese_number(amount_text[-1])
unit = rolling_past_adjacent_match.group(3)
if end_amount is not None:
return constraint(relative_offset_datetime(end_amount, unit, -1), reference_date)
return safe_constraint(relative_offset_datetime(end_amount, unit, -1), reference_date)
rolling_past_few_match = chinese_search(r"(过去|近|最近)几个?(天|日|周|星期|礼拜|月|年)")
if rolling_past_few_match:
@@ -28,6 +28,7 @@ from fnmatch import fnmatchcase
from itertools import combinations
from typing import TYPE_CHECKING, Any, Literal
import asyncpg
from pydantic import BaseModel, field_validator
from ...config import get_config
@@ -102,6 +103,29 @@ class _DedupDecision(BaseModel):
text: str = "" # the synthesized merged observation (when action == "merge")
reason: str = ""
@field_validator("action", mode="before")
@classmethod
def _normalize_action(cls, value: object) -> str:
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"merge", "keep"}:
return normalized
logger.warning("Invalid consolidation dedup action %r; defaulting to keep", value)
return "keep"
def _dedup_decision_from_response(raw: Any) -> _DedupDecision:
try:
if isinstance(raw, _DedupDecision):
return raw
if isinstance(raw, str):
return _DedupDecision.model_validate_json(raw)
return _DedupDecision.model_validate(raw)
except ValueError as exc:
logger.warning("Invalid consolidation dedup response %r; defaulting to keep: %s", raw, exc)
return _DedupDecision(action="keep", reason="invalid structured response")
_DEDUP_PROMPT = """You reconcile long-term memory observations. A NEW observation is about to be \
stored, and it is highly similar to an EXISTING one:
@@ -109,9 +133,20 @@ stored, and it is highly similar to an EXISTING one:
[NEW] {new}
[EXISTING] {existing}
If they assert the SAME fact (wording aside), respond action="merge" and provide `text`: a single \
observation that preserves EVERY detail from both. If they differ in ANY important detail — a \
number/quantity, a named entity or language, a negation, or a condition — respond action="keep"."""
Respond with ONLY one valid JSON object matching one of these shapes:
For duplicate facts:
{{"action": "merge", "text": "...", "reason": "..."}}
For distinct facts:
{{"action": "keep", "text": "", "reason": "..."}}
Do NOT use key=value lines, markdown fences, or any text outside the JSON object.
If they assert the SAME fact (wording aside), set "action" to "merge" and provide "text": a \
single observation that preserves EVERY detail from both. If they differ in ANY important detail \
— a number/quantity, a named entity or language, a negation, or a condition — set "action" to \
"keep" and "text" to an empty string."""
def _dedup_active(config: Any) -> bool:
@@ -189,10 +224,12 @@ async def _dedup_adjudicate(
if best_id is None:
return _DedupOutcome(best_id=None, merged_text="", should_merge=False)
decision: _DedupDecision = await dedup_llm_config.call(
messages=[{"role": "user", "content": _DEDUP_PROMPT.format(new=anchor_text, existing=best_text)}],
response_format=_DedupDecision,
scope="consolidation_dedup",
decision = _dedup_decision_from_response(
await dedup_llm_config.call(
messages=[{"role": "user", "content": _DEDUP_PROMPT.format(new=anchor_text, existing=best_text)}],
response_format=_DedupDecision,
scope="consolidation_dedup",
)
)
if decision.action != "merge":
return _DedupOutcome(best_id=best_id, merged_text="", should_merge=False)
@@ -1766,15 +1803,22 @@ async def _append_observation_history(
history from growing without bound.
"""
obs_uuid = uuid.UUID(observation_id)
await conn.execute(
f"""
try:
await conn.execute(
f"""
INSERT INTO {fq_table("observation_history")} (observation_id, bank_id, content, changed_at)
VALUES ($1, $2, $3::jsonb, now())
""",
obs_uuid,
bank_id,
json.dumps(asdict(snapshot)),
)
obs_uuid,
bank_id,
json.dumps(asdict(snapshot)),
)
except asyncpg.exceptions.ForeignKeyViolationError:
logger.warning(
f"FK violation writing observation_history for {observation_id}: "
"observation was removed before history could be written (race with parallel consolidation). Skipping."
)
return
if max_entries and max_entries > 0:
await conn.execute(
f"""
@@ -18,6 +18,7 @@ and mirrors Django's ``DatabaseOperations`` architecture.
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from .base import DatabaseConnection
@@ -484,6 +485,23 @@ class DataAccessOps(ABC):
# -- Task claiming operations ------------------------------------------
@abstractmethod
async def prune_terminal_operations(
self,
conn: DatabaseConnection,
table: str,
cutoff: datetime,
*,
batch_size: int,
) -> int:
"""Delete one deterministic batch of terminal operations older than ``cutoff``.
Implementations must lock candidates without waiting on rows another
worker is pruning, never select pending/processing rows, and return the
number deleted. The caller provides a transaction around this method.
"""
...
@abstractmethod
async def claim_tasks(
self,
@@ -13,6 +13,8 @@ from .base import DatabaseConnection
from .ops import DataAccessOps, TagListingParts
from .result import DictResultRow as ResultRow
ORACLE_IN_LIST_LIMIT = 1000
class OracleOps(DataAccessOps):
"""Oracle-specific data access operations."""
@@ -447,6 +449,14 @@ class OracleOps(DataAccessOps):
FROM {ue_table} ue_target
WHERE ue_target.entity_id = se.entity_id
AND ue_target.unit_id != ALL($1::uuid[])
-- Filter before applying the cap: candidates from other fact
-- types must not consume this entity's bounded fan-out.
AND EXISTS (
SELECT 1
FROM {mu_table} mu_target
WHERE mu_target.id = ue_target.unit_id
AND mu_target.fact_type = $2
)
ORDER BY ue_target.unit_id DESC
FETCH FIRST {per_entity_limit} ROWS ONLY
) t
@@ -459,7 +469,6 @@ class OracleOps(DataAccessOps):
es.score, 'entity' AS source
FROM entity_scores es
JOIN {mu_table} mu ON mu.id = es.unit_id
WHERE mu.fact_type = $2
ORDER BY es.score DESC
FETCH FIRST $3 ROWS ONLY
)"""
@@ -824,6 +833,157 @@ class OracleOps(DataAccessOps):
# -- Task claiming operations ------------------------------------------
async def prune_terminal_operations(
self,
conn: DatabaseConnection,
table: str,
cutoff: datetime,
*,
batch_size: int,
) -> int:
# Oracle rejects a row-limited SELECT ... FOR UPDATE (ORA-02014). Pick
# the deterministic bounded IDs first, then lock only that candidate
# set and re-check eligibility before deleting in the same transaction.
# Clamp to Oracle's 1000-expression IN-list limit because the adapter
# expands the candidate UUID list into individual bind variables.
# Cancelled children cannot complete parent aggregation, so retain the
# parent guard only for completed/failed children. Before removing a
# cancelled child, preserve its signal by cancelling a pending parent
# in this transaction and refreshing the parent's retention window.
# Validate metadata before HEXTORAW: CASE makes malformed UUIDs yield
# NULL while keeping the indexed RAW parent.operation_id key unwrapped.
effective_batch_size = min(batch_size, ORACLE_IN_LIST_LIMIT)
candidates = await conn.fetch(
f"""
SELECT candidate_operation.operation_id
FROM {table} candidate_operation
WHERE candidate_operation.status IN ('completed', 'failed', 'cancelled')
AND candidate_operation.updated_at < $1
AND (
candidate_operation.status = 'cancelled'
OR NOT EXISTS (
SELECT 1
FROM {table} parent
WHERE parent.operation_id = CASE
WHEN REGEXP_LIKE(
JSON_VALUE(
candidate_operation.result_metadata,
'$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR
),
'^[0-9A-Fa-f]{{8}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{12}}$'
)
THEN HEXTORAW(REPLACE(
JSON_VALUE(
candidate_operation.result_metadata,
'$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR
),
'-',
''
))
ELSE NULL
END
AND parent.bank_id = candidate_operation.bank_id
)
)
ORDER BY candidate_operation.updated_at, candidate_operation.operation_id
LIMIT $2
""",
cutoff,
effective_batch_size,
)
if not candidates:
return 0
candidate_ids = [row["operation_id"] for row in candidates]
locked = await conn.fetch(
f"""
SELECT candidate_operation.operation_id
FROM {table} candidate_operation
WHERE candidate_operation.operation_id = ANY($1)
AND candidate_operation.status IN ('completed', 'failed', 'cancelled')
AND candidate_operation.updated_at < $2
AND (
candidate_operation.status = 'cancelled'
OR NOT EXISTS (
SELECT 1
FROM {table} parent
WHERE parent.operation_id = CASE
WHEN REGEXP_LIKE(
JSON_VALUE(
candidate_operation.result_metadata,
'$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR
),
'^[0-9A-Fa-f]{{8}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{12}}$'
)
THEN HEXTORAW(REPLACE(
JSON_VALUE(
candidate_operation.result_metadata,
'$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR
),
'-',
''
))
ELSE NULL
END
AND parent.bank_id = candidate_operation.bank_id
)
)
ORDER BY candidate_operation.updated_at, candidate_operation.operation_id
FOR UPDATE OF candidate_operation.operation_id SKIP LOCKED
""",
candidate_ids,
cutoff,
)
if not locked:
return 0
operation_ids = [row["operation_id"] for row in locked]
await conn.execute(
f"""
UPDATE {table} parent
SET status = 'cancelled',
updated_at = now(),
completed_at = COALESCE(parent.completed_at, now()),
error_message = COALESCE(
parent.error_message,
'Cancelled because a child operation was cancelled'
)
WHERE parent.status = 'pending'
AND EXISTS (
SELECT 1
FROM {table} candidate_operation
WHERE candidate_operation.operation_id = ANY($1)
AND candidate_operation.status = 'cancelled'
AND candidate_operation.updated_at < $2
AND candidate_operation.bank_id = parent.bank_id
AND parent.operation_id = CASE
WHEN REGEXP_LIKE(
JSON_VALUE(
candidate_operation.result_metadata,
'$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR
),
'^[0-9A-Fa-f]{{8}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{12}}$'
)
THEN HEXTORAW(REPLACE(
JSON_VALUE(
candidate_operation.result_metadata,
'$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR
),
'-',
''
))
ELSE NULL
END
)
""",
operation_ids,
cutoff,
)
await conn.execute(
f"DELETE FROM {table} WHERE operation_id = ANY($1)",
operation_ids,
)
return len(operation_ids)
async def _claim_consolidation_tasks(
self,
conn,
@@ -4,11 +4,40 @@ Uses unnest(), LATERAL, DISTINCT ON, and native array operations for
efficient batch operations.
"""
from datetime import datetime
from .base import DatabaseConnection
from .ops import DataAccessOps, TagListingParts
from .result import ResultRow
def pg_search_vector_expr(
config,
*,
text_col: str = "text",
context_col: str = "context",
signals_col: str = "text_signals",
) -> str | None:
"""SQL expression that builds ``search_vector`` for the configured PG text-search backend.
Single source of truth shared by the batch insert (over the ``input_data``
CTE columns) and the curation revert recompute (over a ``memory_units`` row),
so the two can never drift. Returns ``None`` for backends that leave
``search_vector`` unpopulated — pgroonga / pg_textsearch / pg_search index the
base text columns directly and keep only a dummy column, so there is nothing
to build.
``text_search_extension_native_language`` is validated as a PG identifier in
``HindsightConfig.validate()``, so embedding it as a SQL literal is safe.
"""
combined = f"COALESCE({text_col}, '') || ' ' || COALESCE({context_col}, '') || ' ' || COALESCE({signals_col}, '')"
if config.text_search_extension == "vchord":
return f"tokenize({combined}, 'llmlingua2')::bm25_catalog.bm25vector"
if config.text_search_extension == "native":
return f"to_tsvector('{config.text_search_extension_native_language}'::regconfig, {combined})"
return None
class PostgreSQLOps(DataAccessOps):
"""PostgreSQL-specific data access operations using unnest and LATERAL."""
@@ -93,101 +122,39 @@ class PostgreSQLOps(DataAccessOps):
config = get_config()
table = self._get_mu_table()
if config.text_search_extension == "vchord":
query = f"""
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags_json,
observation_scopes_json, text_signals)
)
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags,
observation_scopes, text_signals, search_vector)
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
),
observation_scopes_json,
text_signals,
tokenize(
COALESCE(text, '') || ' ' || COALESCE(context, '') || ' ' || COALESCE(text_signals, ''),
'llmlingua2'
)::bm25_catalog.bm25vector
FROM input_data
RETURNING id
"""
elif config.text_search_extension == "native":
# search_vector is a regular tsvector column populated here using the
# configured native dictionary. It used to be GENERATED ALWAYS with
# a hardcoded 'english', which prevented per-deployment language
# configuration. text_search_extension_native_language is validated
# in HindsightConfig.validate() as a PG identifier, so embedding it
# as a SQL literal is safe.
query = f"""
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags_json,
observation_scopes_json, text_signals)
)
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags,
observation_scopes, text_signals, search_vector)
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
),
observation_scopes_json,
text_signals,
to_tsvector(
'{config.text_search_extension_native_language}'::regconfig,
COALESCE(text, '') || ' ' || COALESCE(context, '') || ' ' || COALESCE(text_signals, '')
)
FROM input_data
RETURNING id
"""
else:
# pg_textsearch, pgroonga, and pg_search: search_vector is a dummy
# TEXT column; the actual full-text index operates on the base text
# columns directly, so we don't populate search_vector at insert time.
query = f"""
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags_json,
observation_scopes_json, text_signals)
)
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags,
observation_scopes, text_signals)
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
),
observation_scopes_json,
text_signals
FROM input_data
RETURNING id
"""
# search_vector is populated inline for backends that store a real vector
# (native tsvector, vchord bm25vector). pgroonga / pg_textsearch / pg_search
# index the base text columns directly and keep only a dummy column, so the
# expression is None and the column is left out of the insert entirely.
# Same expression is reused by curation revert (see pg_search_vector_expr).
sv_expr = pg_search_vector_expr(config)
sv_insert_col = ", search_vector" if sv_expr else ""
sv_select_val = f",\n {sv_expr}" if sv_expr else ""
query = f"""
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags_json,
observation_scopes_json, text_signals)
)
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags,
observation_scopes, text_signals{sv_insert_col})
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
),
observation_scopes_json,
text_signals{sv_select_val}
FROM input_data
RETURNING id
"""
results = await conn.fetch(
query,
@@ -541,11 +508,18 @@ class PostgreSQLOps(DataAccessOps):
FROM {ue_table} ue_target
WHERE ue_target.entity_id = se.entity_id
AND ue_target.unit_id != ALL($1::uuid[])
-- Filter before applying the cap: candidates from other fact
-- types must not consume this entity's bounded fan-out.
AND EXISTS (
SELECT 1
FROM {mu_table} mu_target
WHERE mu_target.id = ue_target.unit_id
AND mu_target.fact_type = $2
)
ORDER BY ue_target.unit_id DESC
LIMIT {per_entity_limit}
) t
JOIN {mu_table} mu ON mu.id = t.unit_id
WHERE mu.fact_type = $2
GROUP BY mu.id
ORDER BY score DESC
LIMIT $3
@@ -894,6 +868,93 @@ class PostgreSQLOps(DataAccessOps):
# -- Task claiming operations ------------------------------------------
async def prune_terminal_operations(
self,
conn: DatabaseConnection,
table: str,
cutoff: datetime,
*,
batch_size: int,
) -> int:
# Lock only the bounded candidate set. SKIP LOCKED lets multiple
# workers prune disjoint batches without waiting or double-deleting.
# Cancelled children cannot complete parent aggregation, so retain the
# parent guard only for completed/failed children. Before removing a
# cancelled child, preserve its signal by cancelling a pending parent
# in this transaction and refreshing the parent's retention window.
candidates = await conn.fetch(
f"""
SELECT candidate_operation.operation_id
FROM {table} candidate_operation
WHERE candidate_operation.status IN ('completed', 'failed', 'cancelled')
AND candidate_operation.updated_at < $1
AND (
candidate_operation.status = 'cancelled'
OR NOT EXISTS (
SELECT 1
FROM {table} parent
WHERE parent.operation_id = CASE
WHEN candidate_operation.result_metadata->>'parent_operation_id'
~* '^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$'
THEN (candidate_operation.result_metadata->>'parent_operation_id')::uuid
ELSE NULL
END
AND parent.bank_id = candidate_operation.bank_id
)
)
ORDER BY candidate_operation.updated_at, candidate_operation.operation_id
LIMIT $2
FOR UPDATE OF candidate_operation SKIP LOCKED
""",
cutoff,
batch_size,
)
if not candidates:
return 0
candidate_ids = [row["operation_id"] for row in candidates]
await conn.execute(
f"""
UPDATE {table} parent
SET status = 'cancelled',
updated_at = now(),
completed_at = COALESCE(parent.completed_at, now()),
error_message = COALESCE(
parent.error_message,
'Cancelled because a child operation was cancelled'
)
WHERE parent.status = 'pending'
AND EXISTS (
SELECT 1
FROM {table} candidate_operation
WHERE candidate_operation.operation_id = ANY($1)
AND candidate_operation.status = 'cancelled'
AND candidate_operation.updated_at < $2
AND candidate_operation.bank_id = parent.bank_id
AND parent.operation_id = CASE
WHEN candidate_operation.result_metadata->>'parent_operation_id'
~* '^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$'
THEN (candidate_operation.result_metadata->>'parent_operation_id')::uuid
ELSE NULL
END
)
""",
candidate_ids,
cutoff,
)
rows = await conn.fetch(
f"""
DELETE FROM {table}
WHERE operation_id = ANY($1)
AND status IN ('completed', 'failed', 'cancelled')
AND updated_at < $2
RETURNING operation_id
""",
candidate_ids,
cutoff,
)
return len(rows)
async def _claim_consolidation_tasks(
self,
conn,
@@ -1242,6 +1242,10 @@ class OracleBackend(DatabaseBackend):
def __init__(self) -> None:
self._pool: Any = None
self._oracledb: Any = None
# Oracle pooled sessions retain CURRENT_SCHEMA across checkouts. Cache
# SESSION_USER so default-schema acquisitions can explicitly reset a
# connection that was previously used for a tenant schema.
self._default_schema: str | None = None
async def initialize(
self,
@@ -1294,10 +1298,23 @@ class OracleBackend(DatabaseBackend):
from ..memory_engine import get_current_schema
schema = get_current_schema()
if schema and schema != "public":
cursor = conn.cursor()
await cursor.execute(f'ALTER SESSION SET CURRENT_SCHEMA = "{schema}"')
await cursor.close()
cursor = conn.cursor()
try:
if self._default_schema is None:
await cursor.execute("SELECT SYS_CONTEXT('USERENV', 'SESSION_USER') FROM DUAL")
row = await cursor.fetchone()
if not row or not row[0]:
raise RuntimeError("Oracle did not return SESSION_USER while resetting CURRENT_SCHEMA")
self._default_schema = str(row[0])
target_schema = self._default_schema if not schema or schema == "public" else schema
safe_schema = target_schema.replace('"', '""')
await cursor.execute(f'ALTER SESSION SET CURRENT_SCHEMA = "{safe_schema}"')
finally:
# oracledb's AsyncCursor.close() is synchronous (not a coroutine);
# awaiting it raises "object NoneType can't be used in 'await'
# expression" and aborts every acquire().
cursor.close()
@asynccontextmanager
async def acquire(self) -> AsyncIterator[OracleConnection]:
@@ -477,6 +477,12 @@ class LLMTraceRecorder:
if pool is None:
logger.debug("LLM trace skipped: pool not available")
return
# Guard against the backend existing but its internal asyncpg pool being
# None (during/after shutdown — close() calls backend.shutdown() which
# sets _pool=None before the backend object itself is dereferenced).
if getattr(pool, "_pool", "missing") is None:
logger.debug("LLM trace skipped: backend pool not initialized (shutdown in progress?)")
return
try:
schema = self._schema_getter()
table = f"{schema}.llm_requests"
@@ -518,7 +524,13 @@ class LLMTraceRecorder:
_safe_json(record.metadata, self._max_chars) or "{}",
)
except Exception as e:
logger.warning(f"LLM trace write failed for scope={record.scope}: {e}")
err_str = str(e)
# Downgrade known shutdown-related errors to DEBUG — these are
# expected races during daemon close()/restart and are not actionable.
if "pool is closing" in err_str or "not initialized" in err_str:
logger.debug("LLM trace write skipped (shutdown race) for scope=%s: %s", record.scope, e)
else:
logger.warning(f"LLM trace write failed for scope={record.scope}: {e}")
async def _flush_pending(self, trace_id: str) -> None:
"""Await this trace's in-flight writes so its rows exist before an UPDATE."""
@@ -582,4 +594,8 @@ class LLMTraceRecorder:
json.dumps(patch),
)
except Exception as e:
logger.warning(f"LLM trace memory_id attach failed for trace={trace_id}: {e}")
err_str = str(e)
if "pool is closing" in err_str or "not initialized" in err_str:
logger.debug("LLM trace memory_id attach skipped (shutdown race) for trace=%s: %s", trace_id, e)
else:
logger.warning(f"LLM trace memory_id attach failed for trace={trace_id}: {e}")
@@ -235,6 +235,17 @@ def requires_api_key(provider: str) -> bool:
return provider.lower() not in _PROVIDERS_WITHOUT_API_KEY
def _validate_ollama_num_ctx(value: Any) -> int | None:
"""Validate a native Ollama context-window override."""
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"ollama_num_ctx must be a positive integer, got {value!r}")
if value < 1:
raise ValueError(f"ollama_num_ctx must be >= 1, got {value}")
return value
def create_llm_provider(
provider: str,
api_key: str,
@@ -254,6 +265,7 @@ def create_llm_provider(
litellmrouter_config: dict[str, Any] | None = None,
gemini_service_tier: str | None = None,
timeout: float | None = None,
ollama_num_ctx: int | None = None,
) -> Any: # Returns LLMInterface
"""
Factory function to create the appropriate LLM provider implementation.
@@ -268,6 +280,8 @@ def create_llm_provider(
openai_service_tier: OpenAI service tier (for OpenAI provider) - None (default) or "flex" (50% cheaper).
bedrock_service_tier: Bedrock service tier (for Bedrock provider) - None (default), "flex", "priority", or "reserved".
gemini_service_tier: Gemini service tier (for Gemini provider) - None (default) or "flex" (50% cheaper).
ollama_num_ctx: Native Ollama context window override. None lets Ollama use the
model/server default.
extra_body: Extra request-body params merged into the provider's native
call. Threaded into OpenAI-compatible, Fireworks, Anthropic, Gemini/
VertexAI and LiteLLM providers (each merges them in its own parameter
@@ -291,6 +305,8 @@ def create_llm_provider(
Returns:
LLMInterface implementation for the specified provider.
"""
ollama_num_ctx = _validate_ollama_num_ctx(ollama_num_ctx)
from .providers import (
AnthropicLLM,
ClaudeCodeLLM,
@@ -494,6 +510,7 @@ def create_llm_provider(
groq_service_tier=groq_service_tier,
openai_service_tier=openai_service_tier,
extra_body=extra_body,
ollama_num_ctx=ollama_num_ctx,
timeout=timeout,
)
@@ -531,6 +548,7 @@ class LLMProvider:
max_retries: int | None = None,
initial_backoff: float | None = None,
max_backoff: float | None = None,
ollama_num_ctx: int | None = None,
):
"""
Initialize LLM provider.
@@ -545,6 +563,8 @@ class LLMProvider:
openai_service_tier: OpenAI service tier (None or "flex") - from config.
bedrock_service_tier: Bedrock service tier (None, "flex", "priority", "reserved") - from config.
gemini_service_tier: Gemini service tier (None or "flex") - from config.
ollama_num_ctx: Native Ollama context window override. ``None`` lets Ollama
use the model/server default.
gemini_safety_settings: Safety settings for Gemini/VertexAI providers.
extra_body: Extra request-body params merged into the provider's native call
(OpenAI-compatible, Fireworks, Anthropic, Gemini/VertexAI, LiteLLM).
@@ -598,6 +618,7 @@ class LLMProvider:
self.openai_service_tier = openai_service_tier
self.bedrock_service_tier = bedrock_service_tier
self.gemini_service_tier = gemini_service_tier
self.ollama_num_ctx = _validate_ollama_num_ctx(ollama_num_ctx)
# Gemini safety settings (instance default; can be overridden per-request via context var)
self.gemini_safety_settings = gemini_safety_settings
# Gemini prompt caching: when True, retain extraction (and any future
@@ -742,6 +763,7 @@ class LLMProvider:
gemini_safety_settings=self.gemini_safety_settings,
prompt_cache_enabled=self.prompt_cache_enabled,
litellmrouter_config=router_config,
ollama_num_ctx=self.ollama_num_ctx,
timeout=self.timeout,
)
@@ -1260,6 +1282,7 @@ class LLMProvider:
ENV_LLM_GROQ_SERVICE_TIER,
ENV_LLM_LITELLMROUTER_CONFIG,
ENV_LLM_MODEL,
ENV_LLM_OLLAMA_NUM_CTX,
ENV_LLM_OPENAI_SERVICE_TIER,
ENV_LLM_PROMPT_CACHE_ENABLED,
ENV_LLM_PROVIDER,
@@ -1270,6 +1293,7 @@ class LLMProvider:
ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY,
_get_default_model_for_provider,
_parse_llm_router_config,
_parse_optional_positive_int,
parse_gemini_service_tier,
)
@@ -1314,6 +1338,7 @@ class LLMProvider:
),
gemini_safety_settings=json.loads(os.getenv(ENV_LLM_GEMINI_SAFETY_SETTINGS, "null")),
prompt_cache_enabled=prompt_cache_enabled,
ollama_num_ctx=_parse_optional_positive_int(ENV_LLM_OLLAMA_NUM_CTX, os.getenv(ENV_LLM_OLLAMA_NUM_CTX)),
litellmrouter_config=_parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG),
vertexai_project_id=os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID) or None,
vertexai_region=os.getenv(ENV_LLM_VERTEXAI_REGION) or None,
@@ -438,6 +438,7 @@ def _member_to_llm(member: "LLMMemberConfig", config: HindsightConfig, defaults:
reasoning_effort=member.reasoning_effort or config.llm_reasoning_effort,
extra_body=member.extra_body,
default_headers=member.default_headers or config.llm_default_headers,
ollama_num_ctx=config.llm_ollama_num_ctx,
bedrock_service_tier=member.bedrock_service_tier,
gemini_service_tier=member.gemini_service_tier or config.llm_gemini_service_tier,
gemini_safety_settings=_get_raw_config().llm_gemini_safety_settings,
@@ -1085,6 +1086,7 @@ class MemoryEngine(MemoryEngineInterface):
reasoning_effort=config.llm_reasoning_effort,
extra_body=config.llm_extra_body,
default_headers=config.llm_default_headers,
ollama_num_ctx=config.llm_ollama_num_ctx,
litellmrouter_config=config.llm_litellmrouter_config,
bedrock_service_tier=config.llm_bedrock_service_tier,
gemini_service_tier=config.llm_gemini_service_tier,
@@ -1129,6 +1131,7 @@ class MemoryEngine(MemoryEngineInterface):
reasoning_effort=config.llm_reasoning_effort,
extra_body=config.llm_extra_body,
default_headers=config.llm_default_headers,
ollama_num_ctx=config.llm_ollama_num_ctx,
litellmrouter_config=config.retain_llm_litellmrouter_config or config.llm_litellmrouter_config,
bedrock_service_tier=config.llm_bedrock_service_tier,
gemini_service_tier=config.llm_gemini_service_tier,
@@ -1167,6 +1170,7 @@ class MemoryEngine(MemoryEngineInterface):
reasoning_effort=config.llm_reasoning_effort,
extra_body=config.llm_extra_body,
default_headers=config.llm_default_headers,
ollama_num_ctx=config.llm_ollama_num_ctx,
litellmrouter_config=config.reflect_llm_litellmrouter_config or config.llm_litellmrouter_config,
bedrock_service_tier=config.llm_bedrock_service_tier,
gemini_service_tier=config.llm_gemini_service_tier,
@@ -1205,6 +1209,7 @@ class MemoryEngine(MemoryEngineInterface):
reasoning_effort=config.llm_reasoning_effort,
extra_body=config.llm_extra_body,
default_headers=config.llm_default_headers,
ollama_num_ctx=config.llm_ollama_num_ctx,
litellmrouter_config=config.consolidation_llm_litellmrouter_config or config.llm_litellmrouter_config,
bedrock_service_tier=config.llm_bedrock_service_tier,
gemini_service_tier=config.llm_gemini_service_tier,
@@ -2369,11 +2374,57 @@ class MemoryEngine(MemoryEngineInterface):
Also checks if this is a child operation and updates the parent if all siblings are done.
Uses a single transaction to avoid race conditions when multiple children complete simultaneously.
Opt-in escape hatch: when ``HINDSIGHT_API_FAIL_ON_EXTRACTION_ERRORS`` is set and the
operation's ``result_metadata`` recorded a non-zero ``extraction_errors_count`` (written by
``_write_retain_outcome_metadata`` before this call), the operation is marked ``failed``
instead of ``completed``. This surfaces silently-dropped facts as a hard failure rather
than a clean success. Default is off, so existing behavior is unchanged (see issue #2700).
"""
try:
backend = await self._get_backend()
async with acquire_with_retry(backend) as conn:
async with conn.transaction():
# Read the accumulated extraction-error count (persisted by
# _write_retain_outcome_metadata) to decide the terminal status.
meta_row = await conn.fetchrow(
f"SELECT result_metadata FROM {fq_table('async_operations')} WHERE operation_id = $1",
uuid.UUID(operation_id),
)
extraction_errors_count = 0
if meta_row is not None:
metadata = conn.parse_json(meta_row["result_metadata"]) or {}
extraction_errors_count = int(metadata.get("extraction_errors_count") or 0)
fail_on_errors = get_config().fail_on_extraction_errors
if fail_on_errors and extraction_errors_count > 0:
error_message = (
f"Retain completed with {extraction_errors_count} fact extraction error(s); "
"marked failed because HINDSIGHT_API_FAIL_ON_EXTRACTION_ERRORS is enabled. "
"See result_metadata.extraction_errors_sample for details."
)
row = await conn.fetchrow(
f"""
UPDATE {fq_table("async_operations")}
SET status = 'failed', error_message = $2, updated_at = NOW(), completed_at = NOW()
WHERE operation_id = $1
RETURNING operation_id
""",
uuid.UUID(operation_id),
error_message,
)
if row is None:
logger.info(
f"Operation {operation_id} no longer exists (bank deleted), skipping mark-failed"
)
return
logger.warning(
f"Marked async operation as failed due to {extraction_errors_count} "
f"extraction error(s): {operation_id}"
)
await self._maybe_update_parent_operation(operation_id, conn)
return
# Mark this operation as completed
row = await conn.fetchrow(
f"""
@@ -6570,13 +6621,18 @@ class MemoryEngine(MemoryEngineInterface):
)
collist = await self._memory_unit_columns(conn)
# The archive is cold storage, never a recall surface, so the schema gives it
# no `embedding` column at all (dropped in d4f6a8c2e1b3). The move in/out is
# therefore over every memory_units column EXCEPT embedding; on revert the
# embedding is recomputed from the unit's text/dates/entities below. This makes
# a model switch (which re-dimensions memory_units) structurally unable to trip
# a vector-dimension mismatch on the INSERT … SELECT round-trip (#2209).
arch_cols = ", ".join(c for c in (s.strip() for s in collist.split(",")) if c != '"embedding"')
# The archive is cold storage, never a recall surface and carries no index,
# so the schema gives it neither the `embedding` (dropped in d4f6a8c2e1b3)
# nor the `search_vector` column (dropped in e7c3a9f1b2d5). Both are
# recall-surface columns whose type/shape follows server
# config, so the move in/out is over every memory_units column EXCEPT those
# two; on revert each is recomputed from the unit's text/dates/entities below.
# This makes a model switch (which re-dimensions memory_units) structurally
# unable to trip a vector-dimension mismatch (#2209), and a text-search backend
# switch unable to trip a search_vector type mismatch (#2503), on the
# INSERT … SELECT round-trip.
_archive_omitted = ('"embedding"', '"search_vector"')
arch_cols = ", ".join(c for c in (s.strip() for s in collist.split(",")) if c not in _archive_omitted)
# --- Edit fields (live rows only): text / context / dates / fact_type / entities ---
doing_edit = any(
@@ -6634,6 +6690,17 @@ class MemoryEngine(MemoryEngineInterface):
mentioned_at=live["mentioned_at"],
entities=[r["canonical_name"] for r in ent_rows],
)
# Keep the stored text-search vector in sync with curated
# text/context edits. Use the incoming parameters here:
# PostgreSQL evaluates UPDATE RHS expressions before the
# sibling SET assignments take effect, so column references
# would see the pre-edit text/context.
from .db.ops_postgresql import pg_search_vector_expr
sv_expr = pg_search_vector_expr(get_config(), text_col="$3", context_col="$4")
search_vector_clause = (
f",\n search_vector = {sv_expr}" if sv_expr else ""
)
await enqueue_relink_victims(conn, bank_id, [memory_id], ops=backend.ops)
await conn.execute(
f"""
@@ -6641,7 +6708,7 @@ class MemoryEngine(MemoryEngineInterface):
SET text = $3, context = $4, fact_type = $5, occurred_start = $6,
occurred_end = $7, event_date = $8, embedding = $9::vector,
consolidated_at = NULL, consolidation_failed_at = NULL,
edited_at = now(), updated_at = now()
edited_at = now(), updated_at = now(){search_vector_clause}
WHERE id = $1 AND bank_id = $2
""",
str(memory_uuid),
@@ -6695,14 +6762,29 @@ class MemoryEngine(MemoryEngineInterface):
arch_row = await conn.fetchrow(
f"SELECT entity_ids FROM {arch} WHERE id = $1 AND bank_id = $2", str(memory_uuid), bank_id
)
# The archive has no embedding column (see arch_cols above), so the live
# row's embedding defaults to NULL on the way back and is recomputed below
# once entities are restored.
# The archive keeps neither embedding nor search_vector (see arch_cols
# above), so both default to NULL on the way back and are recomputed here:
# the embedding below once entities are restored, the search_vector now
# from the row's own text/context/text_signals.
await conn.execute(
f"INSERT INTO {mu} ({arch_cols}) SELECT {arch_cols} FROM {arch} WHERE id = $1 AND bank_id = $2",
str(memory_uuid),
bank_id,
)
# Rebuild search_vector using the *current* text-search backend, so the
# reverted unit is keyword-searchable again (more correct than carrying a
# verbatim copy that could be stale/wrong-type if the backend changed while
# the fact sat archived). None = pgroonga/pg_textsearch/pg_search, which
# index base columns directly and leave search_vector empty (#2503).
from .db.ops_postgresql import pg_search_vector_expr
sv_expr = pg_search_vector_expr(get_config())
if sv_expr is not None:
await conn.execute(
f"UPDATE {mu} SET search_vector = {sv_expr} WHERE id = $1 AND bank_id = $2",
str(memory_uuid),
bank_id,
)
# Re-consolidate from scratch; links are rebuilt by graph maintenance.
await conn.execute(
f"UPDATE {mu} SET consolidated_at = NULL, consolidation_failed_at = NULL, updated_at = now() "
@@ -7446,7 +7528,7 @@ class MemoryEngine(MemoryEngineInterface):
f"""
SELECT id, text, event_date, context, fact_type, document_id,
mentioned_at, occurred_start, occurred_end, chunk_id, proof_count,
tags, consolidated_at, consolidation_failed_at, edited_at, {curation_cols}
tags, metadata, consolidated_at, consolidation_failed_at, edited_at, {curation_cols}
FROM {source_table}
{where_clause}
ORDER BY mentioned_at DESC NULLS LAST, created_at DESC
@@ -7501,6 +7583,7 @@ class MemoryEngine(MemoryEngineInterface):
"chunk_id": row["chunk_id"] if row["chunk_id"] else None,
"proof_count": row["proof_count"] if row["proof_count"] is not None else 1,
"tags": list(row["tags"]) if row["tags"] else [],
"metadata": conn.parse_json(row["metadata"]) if row["metadata"] is not None else {},
"consolidated_at": row["consolidated_at"].isoformat() if row["consolidated_at"] else None,
"consolidation_failed_at": (
row["consolidation_failed_at"].isoformat() if row["consolidation_failed_at"] else None
@@ -7553,7 +7636,7 @@ class MemoryEngine(MemoryEngineInterface):
# back to the archive (with its invalidation bookkeeping) on a miss.
select_cols = (
"id, text, context, event_date, occurred_start, occurred_end, "
"mentioned_at, fact_type, document_id, chunk_id, tags, source_memory_ids, "
"mentioned_at, fact_type, document_id, chunk_id, tags, metadata, source_memory_ids, "
"observation_scopes, edited_at"
)
row = await conn.fetchrow(
@@ -7597,6 +7680,7 @@ class MemoryEngine(MemoryEngineInterface):
"document_id": row["document_id"] if row["document_id"] else None,
"chunk_id": str(row["chunk_id"]) if row["chunk_id"] else None,
"tags": row["tags"] if row["tags"] else [],
"metadata": conn.parse_json(row["metadata"]) if row["metadata"] is not None else {},
"observation_scopes": row["observation_scopes"] if row["observation_scopes"] else None,
"state": unit_state,
"invalidation_reason": row["invalidation_reason"],
@@ -11901,22 +11985,11 @@ class MemoryEngine(MemoryEngineInterface):
op_uuid = uuid.UUID(operation_id)
async with acquire_with_retry(backend) as conn:
row = await conn.fetchrow(
f"SELECT bank_id, status FROM {fq_table('async_operations')} WHERE operation_id = $1 AND bank_id = $2",
op_uuid,
bank_id,
)
if not row:
raise ValueError(f"Operation {operation_id} not found for bank {bank_id}")
if row["status"] not in ("failed", "cancelled"):
raise OperationValidationError(
f"Operation {operation_id} cannot be retried: status is '{row['status']}', expected 'failed' or 'cancelled'",
409,
)
await conn.execute(
# Make the retry transition a single conditional write. This
# coordinates with retention cleanup's row locks: either retry wins
# and the row becomes nonterminal, or pruning wins and this call
# returns not-found instead of falsely acknowledging a vanished job.
updated = await conn.fetchrow(
f"""
UPDATE {fq_table("async_operations")}
SET status = 'pending',
@@ -11928,10 +12001,27 @@ class MemoryEngine(MemoryEngineInterface):
retry_count = 0,
updated_at = NOW()
WHERE operation_id = $1
AND bank_id = $2
AND status IN ('failed', 'cancelled')
RETURNING operation_id
""",
op_uuid,
bank_id,
)
if updated is None:
row = await conn.fetchrow(
f"SELECT status FROM {fq_table('async_operations')} WHERE operation_id = $1 AND bank_id = $2",
op_uuid,
bank_id,
)
if not row:
raise ValueError(f"Operation {operation_id} not found for bank {bank_id}")
raise OperationValidationError(
f"Operation {operation_id} cannot be retried: status is '{row['status']}', expected 'failed' or 'cancelled'",
409,
)
return {
"success": True,
"message": f"Operation {operation_id} queued for retry",
@@ -186,7 +186,11 @@ class MarkitdownParser(FileParser):
if Path(filename).suffix.lower() not in _TEXT_EXTENSIONS:
return None
try:
file_data.decode("utf-8")
# file_data may arrive as a non-``bytes`` buffer (e.g. a memoryview or
# a native/Rust-backed buffer object) that has no ``.decode``; coerce
# through the buffer protocol before the UTF-8 probe. The ``tmp.write``
# in the caller already relies only on the same buffer protocol.
bytes(file_data).decode("utf-8")
except UnicodeDecodeError:
return None
from markitdown import StreamInfo
@@ -34,6 +34,43 @@ def _usage_from_anthropic_response(response: Any) -> LLMResponseUsage:
)
_EPHEMERAL_CACHE = {"type": "ephemeral"}
def _cached_system_blocks(system_prompt: str) -> list[dict[str, Any]]:
"""Render the system prompt as a block list with a cache_control marker.
Anthropic prompt caching is a prefix match: marking the (single) system
block caches tools + system together. The system prompt is stable per
scope — fact extraction reuses it across every chunk, reflect and
consolidation keep their stable instructions there — so repeat calls read
it at ~10% of the base input price. Markers below the model's minimum
cacheable prefix are silently ignored (no write premium), so marking is
safe unconditionally. This is the "inline-marker provider" strategy that
``LLMInterface.get_or_create_cached_prefix`` documents for Anthropic.
"""
return [{"type": "text", "text": system_prompt, "cache_control": _EPHEMERAL_CACHE}]
def _mark_last_message_for_caching(messages: list[dict[str, Any]]) -> None:
"""Add a cache_control marker to the final content block, in place.
Used on the multi-turn (tool-calling) path: the reflect agent loop resends
the entire growing conversation each iteration, so this request's
end-marker becomes the next iteration's cache read point. Together with
the system marker this uses 2 of the 4 allowed breakpoints.
"""
if not messages:
return
last = messages[-1]
content = last.get("content")
if isinstance(content, str):
if content.strip(): # the API rejects empty text blocks
last["content"] = [{"type": "text", "text": content, "cache_control": _EPHEMERAL_CACHE}]
elif isinstance(content, list) and content and isinstance(content[-1], dict):
content[-1]["cache_control"] = _EPHEMERAL_CACHE
class AnthropicLLM(LLMInterface):
"""
LLM provider using Anthropic's Claude models.
@@ -206,7 +243,9 @@ class AnthropicLLM(LLMInterface):
}
if system_prompt:
call_params["system"] = system_prompt
# One-shot calls share only the system prompt with each other, so
# that is the sole cache breakpoint on this path.
call_params["system"] = _cached_system_blocks(system_prompt)
if use_forced_tool:
# Single tool whose input_schema IS the response schema; force the model to
@@ -450,6 +489,11 @@ class AnthropicLLM(LLMInterface):
else:
anthropic_messages.append({"role": role, "content": content})
# Multi-turn tool loop: cache the stable prefix (tools + system) via
# the system marker, and the growing conversation via an end-marker
# that the next iteration reads back.
_mark_last_message_for_caching(anthropic_messages)
call_params: dict[str, Any] = {
"model": self.model,
"messages": anthropic_messages,
@@ -457,7 +501,7 @@ class AnthropicLLM(LLMInterface):
"max_tokens": max_completion_tokens or 4096,
}
if system_prompt:
call_params["system"] = system_prompt
call_params["system"] = _cached_system_blocks(system_prompt)
if self._extra_body:
call_params["extra_body"] = self._extra_body
@@ -543,6 +587,211 @@ class AnthropicLLM(LLMInterface):
raise last_exception
raise RuntimeError("Anthropic tool call failed")
# ── Message Batches API (50% token discount) ─────────────────────────────
_BATCH_TOOL_NAME = "structured_response"
async def supports_batch_api(self) -> bool:
"""Anthropic supports batch operations via the Message Batches API."""
return True
@staticmethod
def _map_batch_status(processing_status: str) -> str:
"""Map Anthropic ``processing_status`` onto the OpenAI vocabulary.
The engine's poll loop breaks on "completed" and hard-fails on
"failed"/"expired"/"cancelled"; anything else keeps polling. Anthropic
batches only end as "ended" (per-request failures surface in the
results, mirroring OpenAI's "completed"-with-errors semantics), so
"ended" maps to "completed" and the non-terminal states pass through.
"""
return "completed" if processing_status == "ended" else processing_status
def _translate_batch_body(self, body: dict[str, Any]) -> dict[str, Any]:
"""Translate one OpenAI-shaped request body into Messages API params.
Mirrors the conversion rules of ``call()``: system messages fold into
the ``system`` param; ``max_completion_tokens`` becomes ``max_tokens``
(default 4096); ``temperature`` is dropped (the sync path never sends
it either — current Claude models reject non-default sampling params);
an OpenAI ``response_format`` json_schema becomes a single forced
tool_use tool when strict (native constrained decoding, issue #1002),
else the schema is injected into the system prompt.
"""
system_prompt: str | None = None
messages: list[dict[str, Any]] = []
for msg in body.get("messages", []):
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
system_prompt = (system_prompt + "\n\n" + content) if system_prompt else content
else:
messages.append({"role": role, "content": content})
params: dict[str, Any] = {
"model": body.get("model") or self.model,
"messages": messages,
"max_tokens": body.get("max_completion_tokens") or 4096,
}
json_schema = (body.get("response_format") or {}).get("json_schema") or {}
schema = json_schema.get("schema")
if schema is not None:
if json_schema.get("strict"):
params["tools"] = [
{
"name": self._BATCH_TOOL_NAME,
"description": "Return the structured response.",
"input_schema": schema,
}
]
params["tool_choice"] = {"type": "tool", "name": self._BATCH_TOOL_NAME}
else:
schema_msg = "\n\nYou must respond with valid JSON matching this schema:\n" + json.dumps(
schema, indent=2, ensure_ascii=False
)
system_prompt = (system_prompt + schema_msg) if system_prompt else schema_msg
if system_prompt:
params["system"] = system_prompt
# Batch params ARE the raw Messages body, so operator-configured extra
# body params merge directly (the sync path routes them through the
# SDK's extra_body, which does the same merge server-side).
if self._extra_body:
params.update(self._extra_body)
return params
def _translate_batch_message(self, message: Any) -> dict[str, Any]:
"""Render an Anthropic Message as the OpenAI response body the engine parses.
The engine reads ``choices[0].message.content`` (json.loads'ing it when
a schema was requested) and sums ``usage`` under the OpenAI key names.
Forced-tool responses carry their JSON in the tool_use block's input,
so that is re-serialized as the content string.
"""
content = ""
tool_input = None
for block in message.content:
if block.type == "tool_use" and block.name == self._BATCH_TOOL_NAME:
tool_input = block.input or {}
elif block.type == "text":
content += block.text
if tool_input is not None:
content = json.dumps(tool_input, ensure_ascii=False)
usage = getattr(message, "usage", None)
input_tokens = (usage.input_tokens or 0) if usage else 0
output_tokens = (usage.output_tokens or 0) if usage else 0
return {
"choices": [
{
"message": {"role": "assistant", "content": content},
"finish_reason": getattr(message, "stop_reason", None),
}
],
"usage": {
"prompt_tokens": input_tokens,
"completion_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens,
},
}
async def submit_batch(
self,
requests: list[dict[str, Any]],
endpoint: str = "/v1/chat/completions",
completion_window: str = "24h",
) -> dict[str, Any]:
"""Submit a batch of requests to the Message Batches API.
Accepts the engine's OpenAI-JSONL-shaped entries. ``endpoint`` and
``completion_window`` belong to that shared shape and have no Anthropic
equivalent (batches always resolve within 24 hours); both are ignored.
"""
batch_requests = [
{
"custom_id": req["custom_id"],
"params": self._translate_batch_body(req.get("body") or {}),
}
for req in requests
]
logger.info(f"Submitting Anthropic message batch with {len(batch_requests)} requests")
batch = await self._client.messages.batches.create(requests=batch_requests)
logger.info(f"Anthropic batch submitted: {batch.id}, status={batch.processing_status}")
return {
"batch_id": batch.id,
"status": self._map_batch_status(batch.processing_status),
"created_at": batch.created_at,
"request_count": len(batch_requests),
}
async def get_batch_status(self, batch_id: str) -> dict[str, Any]:
"""Get batch status in the shape the engine's poll loop expects."""
batch = await self._client.messages.batches.retrieve(batch_id)
counts = batch.request_counts
processing = getattr(counts, "processing", 0) or 0
succeeded = getattr(counts, "succeeded", 0) or 0
errored = getattr(counts, "errored", 0) or 0
canceled = getattr(counts, "canceled", 0) or 0
expired = getattr(counts, "expired", 0) or 0
resolved = succeeded + errored + canceled + expired
result: dict[str, Any] = {
"batch_id": batch.id,
"status": self._map_batch_status(batch.processing_status),
"created_at": batch.created_at,
"request_counts": {
"total": processing + resolved,
"completed": resolved,
"failed": errored,
},
}
ended_at = getattr(batch, "ended_at", None)
if ended_at:
result["completed_at"] = ended_at
return result
async def retrieve_batch_results(self, batch_id: str) -> list[dict[str, Any]]:
"""Retrieve completed batch results, translated to the OpenAI shape.
Succeeded entries become ``{"custom_id", "response": {"body": ...}}``;
errored/canceled/expired entries become ``{"custom_id", "error": ...}``
so the engine's per-result error handling applies unchanged.
"""
batch = await self._client.messages.batches.retrieve(batch_id)
if batch.processing_status != "ended":
raise ValueError(f"Batch {batch_id} is not completed yet (status: {batch.processing_status})")
decoder = await self._client.messages.batches.results(batch_id)
results: list[dict[str, Any]] = []
async for entry in decoder:
outcome = entry.result
if outcome.type == "succeeded":
results.append(
{
"custom_id": entry.custom_id,
"response": {"body": self._translate_batch_message(outcome.message)},
}
)
else:
error = getattr(outcome, "error", None)
if error is not None:
detail = f"{getattr(error, 'type', 'error')}: {getattr(error, 'message', error)}"
else:
detail = f"batch request {outcome.type}"
results.append({"custom_id": entry.custom_id, "error": detail})
logger.info(f"Retrieved {len(results)} results for Anthropic batch {batch_id}")
return results
async def cleanup(self) -> None:
"""Clean up resources (close Anthropic client connections)."""
if hasattr(self, "_client") and self._client:
@@ -49,6 +49,20 @@ def _get_isolated_claude_env() -> dict[str, str]:
return _isolated_claude_env
def _result_error_detail(message: Any) -> str:
"""Build an actionable error string from an ``is_error`` ResultMessage.
The CLI can report a failure with ``is_error=True`` while ``subtype``
still reads ``"success"``, putting the real detail in ``result`` (e.g.
quota exhaustion: ``You've hit your weekly limit · resets ...`` with
``api_error_status: 429``). The SDK's own fallback exception surfaces
only the subtype, producing the misleading "Claude Code returned an
error result: success" (issue #2702) — so prefer ``result``.
"""
detail = (message.result or "").strip() or message.subtype or "unknown error"
return f"Claude Code reported an error: {detail}"
class ClaudeCodeLLM(LLMInterface):
"""
LLM provider using Claude Code authentication.
@@ -176,6 +190,7 @@ class ClaudeCodeLLM(LLMInterface):
from claude_agent_sdk import ( # type: ignore[unresolved-import]
AssistantMessage,
ClaudeAgentOptions,
ResultMessage,
TextBlock,
query,
)
@@ -228,6 +243,11 @@ class ClaudeCodeLLM(LLMInterface):
for block in message.content:
if isinstance(block, TextBlock):
full_text += block.text
elif isinstance(message, ResultMessage) and message.is_error:
# Surface the CLI's actual error text (e.g. quota
# exhaustion) instead of the SDK's subtype-based
# fallback exception (issue #2702).
raise RuntimeError(_result_error_detail(message))
# The Claude Agent SDK doesn't report exact counts; stash the same
# char/4 estimate the success path traces so a later parse/validate
@@ -393,6 +413,7 @@ class ClaudeCodeLLM(LLMInterface):
AssistantMessage,
ClaudeAgentOptions,
ClaudeSDKClient,
ResultMessage,
SdkMcpTool,
TextBlock,
ToolUseBlock,
@@ -532,6 +553,9 @@ class ClaudeCodeLLM(LLMInterface):
# Receive response
async for message in client.receive_response():
if isinstance(message, ResultMessage) and message.is_error:
# Surface the CLI's actual error text (issue #2702).
raise RuntimeError(_result_error_detail(message))
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
@@ -19,6 +19,7 @@ from __future__ import annotations
import base64
import binascii
import contextlib
import json
import logging
import os
@@ -31,6 +32,11 @@ from typing import Any
import httpx
try:
import fcntl
except ImportError: # pragma: no cover - Windows
fcntl = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
@@ -58,6 +64,9 @@ _CODEX_TOKEN_REFRESH_SKEW_SECONDS = 60
_CODEX_TERMINAL_REFRESH_ERROR_CODES = frozenset(
{"refresh_token_expired", "refresh_token_reused", "refresh_token_invalidated"}
)
_CODEX_AUTH_LOCK_TIMEOUT_SECONDS = 20.0
_CODEX_AUTH_LOCKS_GUARD = threading.Lock()
_CODEX_AUTH_LOCKS: dict[Path, threading.Lock] = {}
def default_codex_auth_file() -> Path:
@@ -76,6 +85,44 @@ def default_codex_auth_file() -> Path:
return Path.home() / ".codex" / "auth.json"
def _path_scoped_lock(auth_file: Path) -> threading.Lock:
key = auth_file.expanduser().resolve(strict=False)
with _CODEX_AUTH_LOCKS_GUARD:
lock = _CODEX_AUTH_LOCKS.get(key)
if lock is None:
lock = threading.Lock()
_CODEX_AUTH_LOCKS[key] = lock
return lock
@contextlib.contextmanager
def _codex_auth_lock(auth_file: Path, timeout_seconds: float = _CODEX_AUTH_LOCK_TIMEOUT_SECONDS):
"""Cross-process advisory lock for one Codex auth store."""
with _path_scoped_lock(auth_file):
if fcntl is None: # pragma: no cover - Windows
logger.debug("fcntl unavailable; Codex refresh proceeds without a cross-process lock.")
yield
return
lock_path = auth_file.with_suffix(".lock")
lock_path.parent.mkdir(parents=True, exist_ok=True)
with open(lock_path, "a+") as lock_file:
deadline = time.monotonic() + max(1.0, timeout_seconds)
while True:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except (BlockingIOError, OSError):
if time.monotonic() >= deadline:
raise TimeoutError("Timed out waiting for the Codex auth store lock") from None
time.sleep(0.05)
try:
yield
finally:
with contextlib.suppress(OSError):
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
class CodexRefreshExpiredError(RuntimeError):
"""Raised when the Codex refresh_token itself is no longer valid.
@@ -192,6 +239,34 @@ class CodexAuthManager:
return None
return data.get("tokens", {}).get("refresh_token")
@staticmethod
def _load_tokens_from_file(auth_file: Path) -> dict[str, Any] | None:
try:
with open(auth_file) as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return None
tokens = data.get("tokens")
return tokens if isinstance(tokens, dict) else None
def _adopt_tokens(self, tokens: dict[str, Any]) -> bool:
"""Adopt a newer on-disk Codex token set if present."""
access_token = tokens.get("access_token")
refresh_token = tokens.get("refresh_token")
account_id = tokens.get("account_id")
changed = False
if isinstance(access_token, str) and access_token and access_token != self.access_token:
self.access_token = access_token
changed = True
if isinstance(refresh_token, str) and refresh_token and refresh_token != self.refresh_token:
self.refresh_token = refresh_token
changed = True
if isinstance(account_id, str) and account_id and account_id != self.account_id:
self.account_id = account_id
changed = True
return changed
@staticmethod
def _decode_jwt_exp_unixtime(token: str) -> int | None:
"""Return the JWT ``exp`` claim as a unix timestamp, or None on parse failure.
@@ -225,6 +300,11 @@ class CodexAuthManager:
return False
return exp <= int(time.time()) + skew_seconds
def _token_is_fresh_with_known_expiry(self, skew_seconds: int = _CODEX_TOKEN_REFRESH_SKEW_SECONDS) -> bool:
"""True only when the cached token has a known expiry outside the skew window."""
exp = self._decode_jwt_exp_unixtime(self.access_token)
return exp is not None and exp > int(time.time()) + skew_seconds
# ------------------------------------------------------------------
# Persistence
# ------------------------------------------------------------------
@@ -339,78 +419,93 @@ class CodexAuthManager:
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."
)
with _codex_auth_lock(self._auth_file):
disk_tokens = self._load_tokens_from_file(self._auth_file)
if disk_tokens and self._adopt_tokens(disk_tokens):
if force or self._token_is_fresh_with_known_expiry():
return
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 = self._http_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:
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}). "
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."
)
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:
raise RuntimeError(f"Codex OAuth refresh failed with HTTP {response.status_code}")
log_reason = f" ({reason})" if reason else ""
logger.info(f"Refreshing Codex OAuth access_token{log_reason}")
try:
body = response.json()
except json.JSONDecodeError as e:
raise RuntimeError(f"Codex OAuth refresh returned non-JSON body: {e}") from e
request_access_token = self.access_token
request_refresh_token = self.refresh_token
request_body = {
"client_id": _CODEX_CLIENT_ID,
"grant_type": "refresh_token",
"refresh_token": request_refresh_token,
}
try:
response = self._http_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
new_access = body.get("access_token")
if not new_access:
raise RuntimeError("Codex OAuth refresh returned no access_token")
if response.status_code == 401:
error_code = self._extract_oauth_error_code(response)
disk_tokens = self._load_tokens_from_file(self._auth_file)
if disk_tokens and (
disk_tokens.get("access_token") != request_access_token
or disk_tokens.get("refresh_token") != request_refresh_token
):
self._adopt_tokens(disk_tokens)
return
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."
)
raise CodexRefreshExpiredError(
f"Codex OAuth refresh returned 401 with unrecognized error code "
f"({error_code or 'none'}). Run 'codex auth login' to re-authenticate."
)
new_refresh = body.get("refresh_token") or self.refresh_token
new_id_token = body.get("id_token")
if response.status_code >= 400:
raise RuntimeError(f"Codex OAuth refresh failed with HTTP {response.status_code}")
# Update in-memory state first so waiters see fresh credentials
# immediately, even if disk write fails.
self.access_token = new_access
self.refresh_token = new_refresh
try:
body = response.json()
except json.JSONDecodeError as e:
raise RuntimeError(f"Codex OAuth refresh returned non-JSON body: {e}") from e
persisted: dict[str, Any] = {
"access_token": new_access,
"refresh_token": new_refresh,
}
if new_id_token:
persisted["id_token"] = new_id_token
new_access = body.get("access_token")
if not new_access:
raise RuntimeError("Codex OAuth refresh returned no access_token")
try:
self._persist_auth_atomic(persisted)
except OSError as e:
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."
)
new_refresh = body.get("refresh_token") or self.refresh_token
new_id_token = body.get("id_token")
logger.info("Codex OAuth access_token refreshed successfully")
# Update in-memory state first so waiters see fresh credentials
# immediately, even if disk write fails.
self.access_token = new_access
self.refresh_token = new_refresh
persisted: dict[str, Any] = {
"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:
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")
def ensure_fresh_token(self) -> None:
"""Proactively refresh the access_token if it is near or past expiry.
@@ -53,6 +53,59 @@ __all__ = [
logger = logging.getLogger(__name__)
# Newer Codex models are gated on the first-party client identity; the previous
# browser-shaped User-Agent returned "Model not found" for Luna (#2643).
# Use a neutral version because Hindsight must not claim a specific Codex release.
_CODEX_ORIGINATOR = "codex_cli_rs"
_CODEX_USER_AGENT = "codex_cli_rs/0.0.0 (Hindsight)"
# Name of the single forced function tool used to carry structured output when
# strict_schema is on. The Codex backend speaks the OpenAI Responses API, so a
# forced function call gives us constrained decoding straight into the response
# schema — no prompt-injected schema, no raw json.loads on free-form model text,
# no invalid-\escape retry storm (issue #2504, same class as #1002 / #2339).
_STRUCTURED_TOOL_NAME = "structured_response"
# Valid JSON string escape characters (the char that may follow a backslash).
_VALID_JSON_ESCAPE_CHARS = set('"\\/bfnrtu')
def _repair_invalid_json_escapes(text: str) -> str:
"""Best-effort repair of invalid ``\\escape`` sequences in a JSON string.
Escape-heavy content (code, serial/CLI commands, Windows paths, regexes)
makes weaker models emit backslashes that aren't valid JSON escapes (e.g.
``\\d``, ``\\s``, ``C:\\Users``), so ``json.loads`` fails deterministically
and every retry re-fails the same way (issue #2504). This doubles any
backslash that isn't part of a valid escape so the payload parses. It is a
lenient fallback only — the strict_schema forced-tool path is the real fix.
"""
result: list[str] = []
i = 0
n = len(text)
while i < n:
ch = text[i]
if ch == "\\" and i + 1 < n:
nxt = text[i + 1]
if nxt in _VALID_JSON_ESCAPE_CHARS:
# Preserve the valid escape (both chars) verbatim.
result.append(ch)
result.append(nxt)
i += 2
continue
# Invalid escape: escape the lone backslash so JSON parses.
result.append("\\\\")
i += 1
continue
if ch == "\\" and i + 1 == n:
# Trailing lone backslash — escape it.
result.append("\\\\")
i += 1
continue
result.append(ch)
i += 1
return "".join(result)
class CodexLLM(LLMInterface):
"""
@@ -140,6 +193,18 @@ class CodexLLM(LLMInterface):
def account_id(self) -> str:
return self._auth_manager.account_id
def _build_request_headers(self) -> httpx.Headers:
return httpx.Headers(
{
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
"OpenAI-Account-ID": self.account_id,
"User-Agent": _CODEX_USER_AGENT,
"Origin": "https://chatgpt.com",
"originator": _CODEX_ORIGINATOR,
}
)
@property
def refresh_token(self) -> str | None:
return self._auth_manager.refresh_token
@@ -336,7 +401,18 @@ class CodexLLM(LLMInterface):
strict_schema: bool = False,
return_usage: bool = False,
) -> Any:
"""Make API call to Codex backend with SSE streaming."""
"""Make API call to Codex backend with SSE streaming.
Args:
strict_schema: Route structured output through a single forced
function tool (constrained decoding) instead of prompt-injecting
the schema and parsing free-form text. The Codex backend speaks
the OpenAI Responses API, so the forced function call emits the
response schema directly as tool arguments — eliminating the
invalid-``\\escape`` retry storm (issue #2504). When False, falls
back to schema-in-prompt + JSON parse, now hardened with a lenient
invalid-escape repair before giving up.
"""
start_time = time.time()
# Proactively refresh the OAuth access_token if it's near expiry.
@@ -361,11 +437,22 @@ class CodexLLM(LLMInterface):
else:
user_messages.append(msg)
# Add JSON schema instruction if response_format is provided
# Structured output: prefer a single forced function tool (constrained
# decoding) over text-injecting the schema and parsing the reply. The
# forced tool guarantees schema-shaped JSON in the tool arguments,
# eliminating the invalid-\escape retry storm (issue #2504). When
# strict_schema is off we keep the schema-in-prompt + json.loads
# fallback (now hardened with a lenient escape repair) for callers that
# can't force tools.
schema = None
use_forced_tool = False
if response_format is not None and hasattr(response_format, "model_json_schema"):
schema = response_format.model_json_schema()
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2, ensure_ascii=False)}"
system_instruction += schema_msg
if strict_schema:
use_forced_tool = True
else:
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2, ensure_ascii=False)}"
system_instruction += schema_msg
# gpt-5.2-codex only supports "detailed" reasoning summary
reasoning_summary = "detailed" if "5.2" in self.model else self.reasoning_summary
@@ -392,13 +479,21 @@ class CodexLLM(LLMInterface):
"prompt_cache_key": str(uuid.uuid4()),
}
headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
"OpenAI-Account-ID": self.account_id,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
"Origin": "https://chatgpt.com",
}
if use_forced_tool and schema is not None:
# Single function tool whose parameters ARE the response schema;
# force it via tool_choice so the backend does constrained decoding.
payload["tools"] = [
{
"type": "function",
"name": _STRUCTURED_TOOL_NAME,
"description": "Return the structured response.",
"parameters": schema,
}
]
payload["tool_choice"] = {"type": "function", "name": _STRUCTURED_TOOL_NAME}
payload["parallel_tool_calls"] = False
headers = self._build_request_headers()
url = f"{self.base_url}/codex/responses"
@@ -412,8 +507,15 @@ class CodexLLM(LLMInterface):
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
response.raise_for_status()
# Parse SSE stream
content = await self._parse_sse_stream(response)
# Forced-tool path: read structured output from the function-call
# arguments (already a JSON string in a dedicated channel) rather
# than from free-form assistant text.
if use_forced_tool:
text_content, tool_calls = await self._parse_sse_tool_stream(response)
content = text_content or ""
else:
tool_calls = []
content = await self._parse_sse_stream(response)
# Codex SSE carries no usage block; stash the same char/4 estimate
# the success path traces so a later parse/validate failure records
@@ -426,7 +528,28 @@ class CodexLLM(LLMInterface):
)
# Handle structured output
if response_format is not None:
if use_forced_tool:
tool_input = None
for tc in tool_calls:
if tc.name == _STRUCTURED_TOOL_NAME:
tool_input = tc.arguments if isinstance(tc.arguments, dict) else None
break
if tool_input is None:
# Model ignored the forced tool (rare — e.g. a gateway that
# drops tool_choice). Retry so we don't hard-fail.
logger.warning(
f"Codex forced structured tool missing from response "
f"(attempt {attempt + 1}/{max_retries + 1})"
)
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
attempt += 1
continue
raise RuntimeError("Codex did not return the forced structured_response tool call")
content = json.dumps(tool_input)
result = tool_input if skip_validation else response_format.model_validate(tool_input)
elif response_format is not None:
# Models may wrap JSON in markdown
clean_content = content
if "```json" in content:
@@ -437,13 +560,20 @@ class CodexLLM(LLMInterface):
try:
json_data = json.loads(clean_content)
except json.JSONDecodeError as e:
logger.warning(f"Codex JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {e}")
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
attempt += 1
continue
raise
# Escape-heavy content deterministically re-fails every
# retry (issue #2504). Try a lenient invalid-escape repair
# before burning a retry / re-raising.
try:
json_data = json.loads(_repair_invalid_json_escapes(clean_content))
logger.info("Codex JSON parsed after repairing invalid escape sequences")
except json.JSONDecodeError:
logger.warning(f"Codex JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {e}")
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
attempt += 1
continue
raise
if skip_validation:
result = json_data
@@ -725,13 +855,7 @@ class CodexLLM(LLMInterface):
"prompt_cache_key": str(uuid.uuid4()),
}
headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
"OpenAI-Account-ID": self.account_id,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
"Origin": "https://chatgpt.com",
}
headers = self._build_request_headers()
url = f"{self.base_url}/codex/responses"
@@ -872,8 +996,13 @@ class CodexLLM(LLMInterface):
try:
arguments = json.loads(arguments_str)
except json.JSONDecodeError:
logger.warning(f"Failed to parse tool arguments: {arguments_str}")
arguments = {}
# Escape-heavy content can emit invalid \escape
# sequences (issue #2504); repair before giving up.
try:
arguments = json.loads(_repair_invalid_json_escapes(arguments_str))
except json.JSONDecodeError:
logger.warning(f"Failed to parse tool arguments: {arguments_str}")
arguments = {}
tool_calls.append(
LLMToolCall(
@@ -1030,7 +1030,7 @@ class GeminiLLM(LLMInterface):
Mirrors the synchronous ``call`` path: system messages become
``systemInstruction``; a ``response_format`` json_schema forces JSON
output (``responseMimeType``), appends the schema as a textual hint, and
grammar-enforces via ``responseJsonSchema`` when ``strict`` is set.
grammar-enforces via ``responseJsonSchema`` whenever a schema is present.
"""
system_texts: list[str] = []
contents: list[dict[str, Any]] = []
@@ -1059,8 +1059,13 @@ class GeminiLLM(LLMInterface):
system_texts.append(
"You must respond with valid JSON matching this schema:\n" + json.dumps(schema, ensure_ascii=False)
)
if json_schema.get("strict"):
generation_config["responseJsonSchema"] = schema
# #2699: Gemini always grammar-enforces structured output via its native
# response_schema (``strict`` is an OpenAI concept, meaningless here). Set
# the native schema whenever one is present so the batch path mirrors the
# interactive path; otherwise batch requests at default config
# (HINDSIGHT_API_LLM_STRICT_SCHEMA=False) get only a textual hint and
# intermittently emit malformed JSON, losing every fact in the chunk.
generation_config["responseJsonSchema"] = schema
request: dict[str, Any] = {"contents": contents}
if system_texts:
@@ -47,6 +47,19 @@ logger = logging.getLogger(__name__)
# Seed applied to every Groq request for deterministic behavior
DEFAULT_LLM_SEED = 4242
JSON_MODE_USER_HINT = "Return valid json only."
DEFAULT_VERIFICATION_MAX_COMPLETION_TOKENS = 512
def _validate_ollama_num_ctx(value: Any) -> int | None:
"""Validate a native Ollama context-window override."""
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"ollama_num_ctx must be a positive integer, got {value!r}")
if value < 1:
raise ValueError(f"ollama_num_ctx must be >= 1, got {value}")
return value
# Self-hosted OpenAI-compatible servers that advertise tool_choice="required"
# but silently ignore it: instead of forcing a tool call they return
@@ -67,23 +80,68 @@ class ProviderResponseError(RuntimeError):
self.retryable = retryable
def _is_json(text: str) -> bool:
"""True if ``text`` parses as a JSON value."""
try:
json.loads(text)
except (json.JSONDecodeError, ValueError):
return False
return True
def _outer_json_span(content: str) -> str | None:
"""Return the outermost ``{...}`` / ``[...]`` span if it parses as JSON, else None.
Fallback for responses where fences are partial/absent or the model wrapped
the JSON in surrounding prose. Only returned when it is valid JSON so callers
never receive a worse candidate than the raw content.
"""
starts = [i for i in (content.find("{"), content.find("[")) if i >= 0]
ends = [i for i in (content.rfind("}"), content.rfind("]")) if i >= 0]
if not starts or not ends:
return None
start, end = min(starts), max(ends)
if end <= start:
return None
candidate = content[start : end + 1].strip()
return candidate if _is_json(candidate) else None
def _strip_code_fences(content: str) -> str:
"""Strip markdown code fences from LLM response if present.
Many LLM providers (MiniMax, some Ollama models, Claude via proxies)
wrap JSON responses in ```json ... ``` fences even when json_object
response format is requested. This strips the fences while preserving
the JSON content inside. Returns the original content unchanged if
no fences are detected.
response format is requested. Fences are detected by line (a closing
``` must sit alone on its line) so triple-backticks *inside* JSON string
values do not truncate the payload. When the stripped candidate is not
valid JSON (partial fence, prose-wrapped output, truncated response), fall
back to the outermost parseable JSON span. Returns the original content
unchanged if no better candidate is found.
"""
if "```" not in content:
return content
try:
if "```json" in content:
return content.split("```json")[1].split("```")[0].strip()
return content.split("```")[1].split("```")[0].strip()
except (IndexError, ValueError):
return content
candidate = content
if "```" in content:
lines = content.split("\n")
# Find first line that starts a code fence (``` optionally followed by language)
fence_start = next((i for i, line in enumerate(lines) if line.startswith("```")), None)
if fence_start is not None:
# Find matching closing fence (``` alone or with trailing whitespace)
fence_end = next(
(j for j in range(fence_start + 1, len(lines)) if lines[j].strip() == "```"),
None,
)
if fence_end is not None:
candidate = "\n".join(lines[fence_start + 1 : fence_end]).strip()
if _is_json(candidate):
return candidate
# Fence stripping did not yield valid JSON — try to recover the outer JSON span.
span = _outer_json_span(content)
if span is not None:
return span
return candidate
# Reasoning/thinking tags emitted by extended-thinking models. Some providers
@@ -435,6 +493,8 @@ class OpenAICompatibleLLM(LLMInterface):
timeout: float | None = None,
groq_service_tier: str | None = None,
extra_body: dict[str, Any] | None = None,
*,
ollama_num_ctx: int | None = None,
**kwargs: Any,
):
"""
@@ -449,6 +509,8 @@ class OpenAICompatibleLLM(LLMInterface):
timeout: Request timeout in seconds (uses env var or 120s default).
groq_service_tier: Groq service tier ("on_demand", "flex", "auto").
extra_body: Extra body params merged into every API call.
ollama_num_ctx: Native Ollama context window override. None lets Ollama use
the model/server default.
**kwargs: Additional provider-specific parameters.
"""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
@@ -529,6 +591,7 @@ class OpenAICompatibleLLM(LLMInterface):
# Service tier configuration (from config, not env vars)
self.groq_service_tier = groq_service_tier
self.openai_service_tier = kwargs.get("openai_service_tier")
self.ollama_num_ctx = _validate_ollama_num_ctx(ollama_num_ctx)
# User-configured extra body params (merged into every API call)
self._config_extra_body = extra_body or {}
@@ -571,6 +634,10 @@ class OpenAICompatibleLLM(LLMInterface):
return True
return self.provider == "openai" and bool(self.base_url)
def _verification_max_completion_tokens(self) -> int:
"""Return the startup verification budget for OpenAI-compatible gateways."""
return DEFAULT_VERIFICATION_MAX_COMPLETION_TOKENS
async def verify_connection(self) -> None:
"""
Verify that the provider is configured correctly by making a simple test call.
@@ -582,7 +649,7 @@ class OpenAICompatibleLLM(LLMInterface):
logger.info(f"Verifying connection: {self.provider}/{self.model}")
await self.call(
messages=[{"role": "user", "content": "Say 'ok'"}],
max_completion_tokens=100,
max_completion_tokens=self._verification_max_completion_tokens(),
max_retries=2,
initial_backoff=0.5,
max_backoff=2.0,
@@ -644,6 +711,11 @@ class OpenAICompatibleLLM(LLMInterface):
# use the widely-supported max_tokens
return "max_tokens"
def _apply_provider_extra_body_defaults(self, extra_body: dict[str, Any]) -> None:
"""Apply provider-specific extra_body defaults while preserving user overrides."""
if self.provider == "minimax":
extra_body.setdefault("thinking", {"type": "disabled"})
async def call(
self,
messages: list[dict[str, str]],
@@ -731,6 +803,7 @@ class OpenAICompatibleLLM(LLMInterface):
# Provider-specific parameters
extra_body: dict[str, Any] = {**self._config_extra_body}
self._apply_provider_extra_body_defaults(extra_body)
if self.provider == "groq":
call_params["seed"] = DEFAULT_LLM_SEED
# Add service_tier if configured
@@ -1149,6 +1222,7 @@ class OpenAICompatibleLLM(LLMInterface):
# Provider-specific parameters
extra_body: dict[str, Any] = {**self._config_extra_body}
self._apply_provider_extra_body_defaults(extra_body)
if self.provider == "groq":
call_params["seed"] = DEFAULT_LLM_SEED
if extra_body:
@@ -1337,9 +1411,10 @@ class OpenAICompatibleLLM(LLMInterface):
# Add optional parameters with optimized defaults for Ollama
options: dict[str, Any] = {
"num_ctx": 16384, # 16k context window for larger prompts
"num_batch": 512, # Optimal batch size for prompt processing
}
if self.ollama_num_ctx is not None:
options["num_ctx"] = self.ollama_num_ctx
if max_completion_tokens:
options["num_predict"] = max_completion_tokens
if temperature is not None:
@@ -1386,22 +1386,35 @@ async def _execute_tool(
query = args.get("query")
if not query:
return {"error": "search_mental_models requires a query parameter"}
max_results = int(args.get("max_results") or 5)
max_results, error = _parse_tool_int_arg_or_error(args, "max_results", default=5)
if error:
return {"error": error}
return await search_mental_models_fn(query, max_results)
elif tool_name == "search_observations":
query = args.get("query")
if not query:
return {"error": "search_observations requires a query parameter"}
max_tokens = max(int(args.get("max_tokens") or 5000), 1000) # Default 5000, min 1000
max_tokens, error = _parse_tool_int_arg_or_error(args, "max_tokens", default=5000, minimum=1000)
if error:
return {"error": error}
return await search_observations_fn(query, max_tokens)
elif tool_name == "recall":
query = args.get("query")
if not query:
return {"error": "recall requires a query parameter"}
max_tokens = max(int(args.get("max_tokens") or 2048), 1000) # Default 2048, min 1000
max_chunk_tokens = max(int(args.get("max_chunk_tokens") or 1000), 1000) # Always enabled, min 1000
max_tokens, error = _parse_tool_int_arg_or_error(args, "max_tokens", default=2048, minimum=1000)
if error:
return {"error": error}
max_chunk_tokens, error = _parse_tool_int_arg_or_error(
args,
"max_chunk_tokens",
default=1000,
minimum=1000,
)
if error:
return {"error": error}
return await recall_fn(query, max_tokens, max_chunk_tokens)
elif tool_name == "expand":
@@ -1415,23 +1428,63 @@ async def _execute_tool(
return {"error": f"Unknown tool: {tool_name}"}
_NULLISH_TOOL_INT_STRINGS = {"", "none", "null"}
def _parse_tool_int_arg(args: dict[str, Any], key: str, *, default: int, minimum: int | None = None) -> int:
raw_value = args.get(key)
if not raw_value:
value = default
elif isinstance(raw_value, str) and raw_value.strip().lower() in _NULLISH_TOOL_INT_STRINGS:
value = default
else:
value = int(raw_value)
if minimum is None:
return value
return max(value, minimum)
def _parse_tool_int_arg_or_error(
args: dict[str, Any],
key: str,
*,
default: int,
minimum: int | None = None,
) -> tuple[int, str | None]:
try:
return _parse_tool_int_arg(args, key, default=default, minimum=minimum), None
except (OverflowError, TypeError, ValueError):
return default, f"{key} must be an integer or null-like value"
def _summarize_tool_int_arg(args: dict[str, Any], key: str, *, default: int, minimum: int | None = None) -> str:
try:
return str(_parse_tool_int_arg(args, key, default=default, minimum=minimum))
except (OverflowError, TypeError, ValueError):
return f"invalid:{args.get(key)!r}"
def _summarize_tool_query(args: dict[str, Any]) -> str:
query = args.get("query") or ""
if not isinstance(query, str):
query = str(query)
return f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
def _summarize_input(tool_name: str, args: dict[str, Any]) -> str:
"""Create a summary of tool input for logging, showing all params."""
if tool_name == "search_mental_models":
query = args.get("query", "")
query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
max_results = int(args.get("max_results") or 5)
query_preview = _summarize_tool_query(args)
max_results = _summarize_tool_int_arg(args, "max_results", default=5)
return f"(query={query_preview}, max_results={max_results})"
elif tool_name == "search_observations":
query = args.get("query", "")
query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
max_tokens = max(int(args.get("max_tokens") or 5000), 1000)
query_preview = _summarize_tool_query(args)
max_tokens = _summarize_tool_int_arg(args, "max_tokens", default=5000, minimum=1000)
return f"(query={query_preview}, max_tokens={max_tokens})"
elif tool_name == "recall":
query = args.get("query", "")
query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
max_tokens = max(int(args.get("max_tokens") or 2048), 1000)
max_chunk_tokens = max(int(args.get("max_chunk_tokens") or 1000), 1000)
query_preview = _summarize_tool_query(args)
max_tokens = _summarize_tool_int_arg(args, "max_tokens", default=2048, minimum=1000)
max_chunk_tokens = _summarize_tool_int_arg(args, "max_chunk_tokens", default=1000, minimum=1000)
return f"(query={query_preview}, max_tokens={max_tokens}, max_chunk_tokens={max_chunk_tokens})"
elif tool_name == "expand":
memory_ids = args.get("memory_ids", [])
@@ -64,8 +64,53 @@ async def delete_chunks_by_ids(conn, chunk_ids: list[str]) -> None:
"""
if not chunk_ids:
return
# PostgreSQL's FK cascade deletes child memory_links in executor-chosen
# order. Concurrent chunk deletes for the same bank can then lock overlapping
# memory_links in opposite orders and deadlock. Delete links explicitly in a
# total order before deleting chunks so every writer takes row locks the same
# way; the FK cascade still handles anything inserted later in this txn.
await conn.execute(
f"DELETE FROM {fq_table('chunks')} WHERE chunk_id = ANY($1::text[])",
f"""
WITH target_units AS MATERIALIZED (
SELECT id
FROM {fq_table("memory_units")}
WHERE chunk_id = ANY($1::text[])
),
ordered_links AS MATERIALIZED (
SELECT ml.ctid
FROM {fq_table("memory_links")} ml
WHERE EXISTS (
SELECT 1
FROM target_units tu
WHERE tu.id = ml.from_unit_id OR tu.id = ml.to_unit_id
)
ORDER BY
LEAST(ml.from_unit_id, ml.to_unit_id),
GREATEST(ml.from_unit_id, ml.to_unit_id),
ml.link_type,
COALESCE(ml.entity_id, '00000000-0000-0000-0000-000000000000'::uuid)
FOR UPDATE OF ml
)
DELETE FROM {fq_table("memory_links")} ml
USING ordered_links ol
WHERE ml.ctid = ol.ctid
""",
chunk_ids,
)
await conn.execute(
f"""
WITH ordered_chunks AS MATERIALIZED (
SELECT chunk_id
FROM {fq_table("chunks")}
WHERE chunk_id = ANY($1::text[])
ORDER BY chunk_id
FOR UPDATE
)
DELETE FROM {fq_table("chunks")} c
USING ordered_chunks oc
WHERE c.chunk_id = oc.chunk_id
""",
chunk_ids,
)
@@ -15,7 +15,7 @@ from typing import Any, Literal, cast
from pydantic import BaseModel, ConfigDict, Field, create_model, field_validator
from ..llm_interface import ProviderRateLimitResetError
from ..llm_wrapper import LLMConfig, OutputTooLongError, sanitize_llm_output
from ..llm_wrapper import LLMConfig, OutputTooLongError, parse_llm_json, sanitize_llm_output
from ..operation_metadata import RetainExtractionErrors
from ..response_models import TokenUsage
from .entity_labels import (
@@ -232,6 +232,55 @@ class FactExtractionResponse(BaseModel):
facts: list[ExtractedFact] = Field(description="List of extracted factual statements")
def _split_chunk_for_output_retry(chunk: str) -> tuple[str, str] | None:
"""Split an oversized extraction chunk without corrupting structured input."""
stripped = chunk.strip()
if len(stripped) <= 1:
return None
try:
parsed = json.loads(stripped)
except (TypeError, ValueError, json.JSONDecodeError):
parsed = None
if isinstance(parsed, list):
if len(parsed) >= 2:
mid = len(parsed) // 2
return json.dumps(parsed[:mid]), json.dumps(parsed[mid:])
if len(parsed) == 1 and isinstance(parsed[0], dict):
turn = parsed[0]
content = turn.get("content")
if isinstance(content, str) and len(content) > 1:
cut = len(content) // 2
first_turn = dict(turn)
second_turn = dict(turn)
first_turn["content"] = content[:cut]
second_turn["content"] = content[cut:]
return json.dumps([first_turn]), json.dumps([second_turn])
return None
# Split plain text at the midpoint, preferring sentence boundaries nearby.
mid_point = len(stripped) // 2
search_range = int(len(stripped) * 0.2)
search_start = max(0, mid_point - search_range)
search_end = min(len(stripped), mid_point + search_range)
best_split = mid_point
for ending in [". ", "! ", "? ", "\n\n"]:
pos = stripped.rfind(ending, search_start, search_end)
if pos != -1:
best_split = pos + len(ending)
break
first_half = stripped[:best_split].strip()
second_half = stripped[best_split:].strip()
if not first_half or not second_half or first_half == stripped or second_half == stripped:
return None
return first_half, second_half
class ExtractedFactVerbose(BaseModel):
"""A single extracted fact with verbose field descriptions for detailed extraction."""
@@ -1236,6 +1285,15 @@ def _build_request_body(llm_config, config, prompt: str, user_message: str, resp
return request_body
def _coerce_fact_response(response: Any) -> dict[str, Any] | None:
"""Accept the schema wrapper, or a recoverable top-level facts array."""
if isinstance(response, dict):
return response
if isinstance(response, list) and all(isinstance(item, dict) for item in response):
return {"facts": response}
return None
async def _extract_facts_from_chunk(
chunk: str,
chunk_index: int,
@@ -1341,7 +1399,8 @@ async def _extract_facts_from_chunk(
has_malformed_facts = False
# Handle malformed LLM responses
if not isinstance(extraction_response_json, dict):
coerced_response_json = _coerce_fact_response(extraction_response_json)
if coerced_response_json is None:
if attempt < llm_max_retries - 1:
logger.warning(
f"LLM returned non-dict JSON on attempt {attempt + 1}/{llm_max_retries}: {type(extraction_response_json).__name__}. Retrying..."
@@ -1356,6 +1415,7 @@ async def _extract_facts_from_chunk(
f"Fact extraction failed: LLM returned non-dict JSON after {llm_max_retries} attempts "
f"({type(extraction_response_json).__name__}). Raw: {str(extraction_response_json)[:500]}"
)
extraction_response_json = coerced_response_json
raw_facts = extraction_response_json.get("facts", [])
@@ -1664,33 +1724,22 @@ async def _extract_facts_with_auto_split(
metadata=metadata,
)
except OutputTooLongError:
# Output exceeded token limits - split the chunk in half and retry
# Output exceeded token limits - split the chunk and retry. Conversation
# chunks are JSON arrays, so preserve array/turn boundaries when possible.
logger.warning(
f"Output too long for chunk {chunk_index + 1}/{total_chunks} "
f"({len(chunk)} chars). Splitting in half and retrying..."
f"({len(chunk)} chars). Splitting and retrying..."
)
# Split at the midpoint, preferring sentence boundaries
mid_point = len(chunk) // 2
split_chunks = _split_chunk_for_output_retry(chunk)
if split_chunks is None:
logger.warning(
f"Cannot make progress splitting chunk {chunk_index + 1}/{total_chunks} "
f"({len(chunk)} chars); dropping this sub-chunk."
)
return [], TokenUsage()
# Try to find a sentence boundary near the midpoint
# Look for ". ", "! ", "? " within 20% of midpoint
search_range = int(len(chunk) * 0.2)
search_start = max(0, mid_point - search_range)
search_end = min(len(chunk), mid_point + search_range)
sentence_endings = [". ", "! ", "? ", "\n\n"]
best_split = mid_point
for ending in sentence_endings:
pos = chunk.rfind(ending, search_start, search_end)
if pos != -1:
best_split = pos + len(ending)
break
# Split the chunk
first_half = chunk[:best_split].strip()
second_half = chunk[best_split:].strip()
first_half, second_half = split_chunks
logger.info(
f"Split chunk {chunk_index + 1} into two sub-chunks: {len(first_half)} chars and {len(second_half)} chars"
@@ -2132,7 +2181,10 @@ async def extract_facts_from_contents_batch_api(
content_str = message.get("content", "{}")
try:
extraction_response_json = json.loads(content_str)
# #2701: use the lenient parser (strips markdown fences, scrubs
# embedded control chars) so recoverable batch responses — e.g.
# transient Gemini quirks — aren't dropped along with all their facts.
extraction_response_json = parse_llm_json(content_str)
except json.JSONDecodeError as e:
message = f"{custom_id}: failed to parse JSON: {e}"
logger.error(message)
@@ -2144,6 +2196,19 @@ async def extract_facts_from_contents_batch_api(
)
continue
response_type_name = type(extraction_response_json).__name__
extraction_response_json = _coerce_fact_response(extraction_response_json)
if extraction_response_json is None:
message = f"{custom_id}: LLM returned non-dict JSON ({response_type_name})"
logger.error(message)
extraction_errors.add(message)
chunks_metadata.append(
ChunkMetadata(
chunk_text=chunk_content, fact_count=0, content_index=content_index, chunk_index=chunk_idx
)
)
continue
# Parse facts (reuse existing logic from _extract_facts_from_chunk)
raw_facts = extraction_response_json.get("facts", [])
chunk_facts = []
@@ -74,7 +74,9 @@ async def create_causal_links_batch(
"""
Create causal links between facts.
Links facts that have causal relationships (causes, enables, prevents).
Retain writes the canonical ``caused_by`` relationship only. The database and
retrieval paths also recognize historical causal types so imported and
pre-existing memories remain traversable.
Args:
conn: Database connection
@@ -90,22 +92,7 @@ async def create_causal_links_batch(
if len(unit_ids) != len(facts):
raise ValueError(f"Mismatch between unit_ids ({len(unit_ids)}) and facts ({len(facts)})")
# Extract causal relations in the format expected by link_utils
# Format: List of lists, where each inner list is the causal relations for that fact
causal_relations_per_fact = []
for fact in facts:
if fact.causal_relations:
# Convert CausalRelation objects to dicts
relations_dicts = [
{
"relation_type": rel.relation_type,
"target_fact_index": rel.target_fact_index,
}
for rel in fact.causal_relations
]
causal_relations_per_fact.append(relations_dicts)
else:
causal_relations_per_fact.append([])
causal_relations_per_fact = [fact.causal_relations or [] for fact in facts]
link_count = await link_utils.create_causal_links_batch(conn, bank_id, unit_ids, causal_relations_per_fact, ops=ops)
@@ -7,12 +7,17 @@ import time
from datetime import UTC, datetime, timedelta
from ..._vector_index import ann_search_tuning_settings, configured_vector_extension
from ..db.base import DatabaseConnection
from ..db.ops import DataAccessOps
from ..memory_engine import fq_table
from .types import CausalRelation
logger = logging.getLogger(__name__)
# Sentinel UUID used in the unique index to represent NULL entity_id
_NIL_ENTITY_UUID = "00000000-0000-0000-0000-000000000000"
_CANONICAL_CAUSAL_LINK_TYPES = frozenset({"caused_by"})
_LEGACY_CAUSAL_LINK_TYPES = frozenset({"causes", "enables", "prevents"})
# Maximum number of temporal links to keep per unit (from_unit_id).
# Retrieval only reads top 10-20 per unit via LATERAL join, so keeping
@@ -771,28 +776,61 @@ async def create_semantic_links_batch(
async def create_causal_links_batch(
conn,
conn: DatabaseConnection,
bank_id: str,
unit_ids: list[str],
causal_relations_per_fact: list[list[dict]],
ops=None,
causal_relations_per_fact: list[list[CausalRelation]],
ops: DataAccessOps | None = None,
) -> int:
"""
Create causal links between facts based on LLM-extracted causal relationships.
"""Create canonical causal links for the retain pipeline.
Args:
conn: Database connection
unit_ids: List of unit IDs (in same order as causal_relations_per_fact)
causal_relations_per_fact: List of causal relations for each fact.
Each element is a list of dicts with:
- target_fact_index: Index into unit_ids for the target fact
- relation_type: "caused_by"
Retain must only create the backward-looking ``caused_by`` form. Historical
types are restored exclusively through ``restore_legacy_causal_links_batch``.
"""
return await _write_causal_links_batch(
conn,
bank_id,
unit_ids,
causal_relations_per_fact,
_CANONICAL_CAUSAL_LINK_TYPES,
ops=ops,
)
async def restore_legacy_causal_links_batch(
conn: DatabaseConnection,
bank_id: str,
unit_ids: list[str],
causal_relations_per_fact: list[list[CausalRelation]],
ops: DataAccessOps | None = None,
) -> int:
"""Restore historical causal links while importing a transfer archive.
This is deliberately separate from the retain writer: retrieval continues
reading historical types, but only transfer import may create them.
"""
return await _write_causal_links_batch(
conn,
bank_id,
unit_ids,
causal_relations_per_fact,
_LEGACY_CAUSAL_LINK_TYPES,
ops=ops,
)
async def _write_causal_links_batch(
conn: DatabaseConnection,
bank_id: str,
unit_ids: list[str],
causal_relations_per_fact: list[list[CausalRelation]],
allowed_relation_types: frozenset[str],
ops: DataAccessOps | None = None,
) -> int:
"""Write causal links after the caller has selected its allowed taxonomy.
Returns:
Number of causal links created
Causal link type:
- "caused_by": This fact was caused by the target fact
"""
if not unit_ids or not causal_relations_per_fact:
return 0
@@ -809,15 +847,13 @@ async def create_causal_links_batch(
from_unit_id = unit_ids[fact_idx]
for relation in causal_relations:
target_idx = relation["target_fact_index"]
relation_type = relation["relation_type"]
target_idx = relation.target_fact_index
relation_type = relation.relation_type
# Validate relation_type - only "caused_by" is supported (DB constraint)
valid_types = {"caused_by"}
if relation_type not in valid_types:
if relation_type not in allowed_relation_types:
logger.error(
f"Invalid relation_type '{relation_type}' (type: {type(relation_type).__name__}) "
f"from fact {fact_idx}. Must be one of: {valid_types}. "
f"from fact {fact_idx}. Must be one of: {allowed_relation_types}. "
f"Relation data: {relation}"
)
continue
@@ -831,6 +831,12 @@ async def retain_batch(
first = contents_dicts[0]
if first.get("context"):
existing_content["context"] = first["context"]
if first.get("event_date"):
existing_content["event_date"] = first["event_date"]
if first.get("metadata"):
existing_content["metadata"] = first["metadata"]
if first.get("observation_scopes") is not None:
existing_content["observation_scopes"] = first["observation_scopes"]
if first.get("tags"):
existing_content["tags"] = first["tags"]
contents_dicts = [existing_content, *contents_dicts]
@@ -852,6 +858,12 @@ async def retain_batch(
contents_dicts = [{"content": json.dumps(_merged, ensure_ascii=False)}]
if first.get("context"):
contents_dicts[0]["context"] = first["context"]
if first.get("event_date"):
contents_dicts[0]["event_date"] = first["event_date"]
if first.get("metadata"):
contents_dicts[0]["metadata"] = first["metadata"]
if first.get("observation_scopes") is not None:
contents_dicts[0]["observation_scopes"] = first["observation_scopes"]
if first.get("tags"):
contents_dicts[0]["tags"] = first["tags"]
except (json.JSONDecodeError, ValueError, TypeError):
@@ -96,10 +96,12 @@ class CausalRelation:
"""
Causal relationship between facts.
Represents how one fact was caused by another.
Retain emits only the backward-looking ``caused_by`` form. Transfer import
reuses this structure to restore historical causal types without allowing
normal retain writes to create them.
"""
relation_type: str # "caused_by"
relation_type: str # ``caused_by`` for retain; legacy types for transfer restore
target_fact_index: int # Index of the target fact in the batch
@@ -243,12 +243,16 @@ class LinkExpansionRetriever(GraphRetriever):
}
sorted_ids = sorted(score_map.keys(), key=lambda x: score_map[x], reverse=True)[:budget]
rows = [row_map[fact_id] for fact_id in sorted_ids]
results = []
for row in rows:
for fact_id in sorted_ids:
row = row_map[fact_id]
result = RetrievalResult.from_db_row(dict(row))
result.activation = row["score"]
# ``activation`` is used to re-sort graph results after fact types are
# combined. It must retain the final additive score rather than the
# raw score from one signal, which would otherwise discard the other
# signals and make the cross-fact-type order disagree with this order.
result.activation = score_map[fact_id]
results.append(result)
# filter_results_by_tags is a no-op when no filter applies (tags falsy and not
@@ -15,7 +15,7 @@ from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Optional
from ...config import get_config
from ...config import DEFAULT_BM25_MAX_QUERY_TERMS, get_config
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table
from ..sql import create_sql_dialect
@@ -222,7 +222,12 @@ async def retrieve_semantic_bm25_combined(
# --- BM25 UNION ALL arms (one per fact_type, only when tokens present) ---
if _include_bm25:
text_ext = config.text_search_extension
bm25_text_param: str = dialect.prepare_bm25_text(tokens, query_text, text_search_extension=text_ext)
bm25_text_param: str = dialect.prepare_bm25_text(
tokens,
query_text,
text_search_extension=text_ext,
max_query_terms=getattr(config, "bm25_max_query_terms", DEFAULT_BM25_MAX_QUERY_TERMS),
)
for i, ft in enumerate(fact_types):
arms.append(
dialect.build_bm25_arm(
@@ -793,7 +798,7 @@ async def retrieve_all_fact_types_parallel(
tc_start,
tc_end,
budget=thinking_budget,
semantic_threshold=min_semantic if min_semantic is not None else 0.1,
semantic_threshold=0.1,
tags=tags,
tags_match=tags_match,
tag_groups=tag_groups,
@@ -449,6 +449,7 @@ class SQLDialect(ABC):
query_text: str,
*,
text_search_extension: str = "native",
max_query_terms: int | None = None,
) -> str:
"""Prepare the text parameter value for BM25 search.
@@ -459,6 +460,8 @@ class SQLDialect(ABC):
tokens: Tokenized query words.
query_text: Original query text.
text_search_extension: Full-text search backend variant.
max_query_terms: Optional backend-specific token cap. 0 or None
leaves query terms uncapped.
Returns:
Prepared text string to bind as the BM25 text parameter.
@@ -303,6 +303,7 @@ class OracleDialect(SQLDialect):
query_text: str,
*,
text_search_extension: str = "native",
max_query_terms: int | None = None,
) -> str:
# Oracle Text: filter tokens with special chars, escape reserved words
# with curly braces (e.g. "about" → "{about}"), and join with OR.
@@ -254,8 +254,11 @@ class PostgreSQLDialect(SQLDialect):
query_text: str,
*,
text_search_extension: str = "native",
max_query_terms: int | None = None,
) -> str:
if text_search_extension in ("vchord", "pg_textsearch", "pgroonga", "pg_search"):
return query_text
if max_query_terms is not None and max_query_terms > 0:
tokens = tokens[:max_query_terms]
# native tsvector: join tokens with OR operator
return " | ".join(tokens)
@@ -125,8 +125,9 @@ class _LoadedExport:
unit_index: dict[Any, _UnitLocation] = field(default_factory=dict)
# Causal link types that retain persists between facts. Only these travel in the
# archive; temporal/semantic/entity links are regenerated against the target bank.
# Retain currently writes only ``caused_by``. The legacy types stay in archives
# so importing a historical bank preserves its graph; temporal/semantic/entity
# links are regenerated against the target bank.
_CAUSAL_LINK_TYPES = ("caused_by", "causes", "enables", "prevents")
# Facts of these types are exported; observations are derived and excluded.
@@ -19,7 +19,7 @@ from datetime import UTC, date, datetime
from typing import Any, Literal
from ..db_utils import acquire_with_retry
from ..retain import bank_utils, chunk_storage, embedding_processing, fact_storage, orchestrator
from ..retain import bank_utils, chunk_storage, embedding_processing, fact_storage, link_utils, orchestrator
from ..retain.types import (
CausalRelation,
ChunkMetadata,
@@ -40,6 +40,7 @@ logger = logging.getLogger(__name__)
OnConflict = Literal["skip", "replace", "new-id"]
_VALID_CONFLICT_MODES: tuple[OnConflict, ...] = ("skip", "replace", "new-id")
_LEGACY_CAUSAL_LINK_TYPES = frozenset({"causes", "enables", "prevents"})
@dataclass
@@ -474,6 +475,7 @@ async def _import_one_document(
)
extracted_facts = [_to_extracted_fact(fact) for fact in document.facts]
legacy_causal_relations = _legacy_causal_relations(document)
processed_facts: list[ProcessedFact] = []
if extracted_facts:
@@ -545,6 +547,18 @@ async def _import_one_document(
ops=ops,
)
# Retain writes only ``caused_by``. Restore legacy archive edges
# separately so their distinct direction and semantics survive a
# transfer without broadening the normal retain write contract.
if result_unit_ids and legacy_causal_relations:
await link_utils.restore_legacy_causal_links_batch(
conn,
bank_id,
result_unit_ids[0],
legacy_causal_relations,
ops=ops,
)
try:
await entity_resolver.flush_pending_stats()
except Exception:
@@ -705,6 +719,7 @@ def _to_extracted_fact(fact: TransferFact) -> ExtractedFact:
causal_relations=[
CausalRelation(relation_type=rel.relation_type, target_fact_index=rel.target_fact_index)
for rel in fact.causal_relations
if rel.relation_type == "caused_by"
],
content_index=0,
chunk_index=fact.chunk_index,
@@ -714,3 +729,19 @@ def _to_extracted_fact(fact: TransferFact) -> ExtractedFact:
tags=list(fact.tags),
observation_scopes=fact.observation_scopes,
)
def _legacy_causal_relations(document: TransferDocument) -> list[list[CausalRelation]]:
"""Return legacy archive edges for transfer-only restoration.
Invalid archive values are excluded. The write helper repeats the explicit
compatibility allowlist as a persistence boundary.
"""
return [
[
CausalRelation(relation_type=relation.relation_type, target_fact_index=relation.target_fact_index)
for relation in fact.causal_relations
if relation.relation_type in _LEGACY_CAUSAL_LINK_TYPES
]
for fact in document.facts
]
@@ -284,6 +284,8 @@ class MemoryLink(Base):
entity = relationship("Entity", back_populates="memory_links")
__table_args__ = (
# Retain writes ``caused_by`` only. Keep the historical causal values
# valid so existing rows and transfer archives remain queryable.
CheckConstraint(
"link_type IN ('temporal', 'semantic', 'entity', 'causes', 'caused_by', 'enables', 'prevents')",
name="memory_links_link_type_check",
@@ -197,6 +197,11 @@ def main():
shared_pool = max(0, config.worker_max_slots - sum(reservations.values()))
print(f" Slot reservations: {reservations_str}")
print(f" Shared pool: {shared_pool}")
if config.operation_retention_days == 0:
print(" Operation retention: disabled (terminal rows and payloads are kept)")
else:
print(f" Operation retention: {config.operation_retention_days} days (terminal rows, payloads, and metadata)")
print(f" Operation cleanup batch: {config.operation_cleanup_batch_size} rows/schema/cycle")
print(f" HTTP server: {args.http_host}:{args.http_port}")
print()
@@ -264,6 +269,8 @@ def main():
max_slots=config.worker_max_slots,
slot_reservations=config.worker_slot_reservations,
consolidation_bank_priority=config.worker_consolidation_bank_priority or None,
operation_retention_days=config.operation_retention_days,
operation_cleanup_batch_size=config.operation_cleanup_batch_size,
)
# Create the HTTP app for metrics/health
@@ -17,6 +17,7 @@ import traceback
from collections import Counter
from collections.abc import Awaitable, Callable, Iterable
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
from ..engine.schema import fq_table_explicit as fq_table
@@ -37,6 +38,19 @@ def _metric_operation_label(operation_type: str | None) -> str:
return operation_type or "unknown"
def _updated_row_count(result: Any) -> int:
"""Extract a row count from backend execute() results."""
if isinstance(result, int):
return result
if isinstance(result, str):
try:
return int(result.rsplit(" ", 1)[-1])
except (TypeError, ValueError):
return 0
rowcount = getattr(result, "rowcount", None)
return rowcount if isinstance(rowcount, int) else 0
if TYPE_CHECKING:
from hindsight_api.engine.db.base import DatabaseBackend, DatabaseConnection
from hindsight_api.extensions.tenant import TenantExtension
@@ -45,6 +59,7 @@ logger = logging.getLogger(__name__)
# Progress logging interval in seconds
PROGRESS_LOG_INTERVAL = 30
OPERATION_CLEANUP_INTERVAL_SECONDS = 60
# Stuck-task stack-dump thresholds (seconds). Each task gets one stack dump
# per threshold it crosses (5min, 10min, 20min, 40min, 80min...).
@@ -148,6 +163,8 @@ class WorkerPoller:
max_slots: int = 10,
slot_reservations: dict[str, int] | None = None,
consolidation_bank_priority: dict[str, int] | None = None,
operation_retention_days: int = 30,
operation_cleanup_batch_size: int = 1000,
):
"""
Initialize the worker poller.
@@ -170,7 +187,15 @@ class WorkerPoller:
Patterns support ``*`` as wildcard. A bare ``*`` key is the catch-all default.
When set, consolidation tasks are claimed in priority tiers rather than
pure created_at order. None or empty dict preserves current behavior.
operation_retention_days: Days to retain completed, failed, and cancelled
operation rows with their payload and metadata. Zero disables cleanup.
operation_cleanup_batch_size: Maximum terminal rows deleted per schema
during each cleanup cycle.
"""
if operation_retention_days < 0:
raise ValueError("operation_retention_days must be >= 0")
if operation_cleanup_batch_size < 1:
raise ValueError("operation_cleanup_batch_size must be >= 1")
self._backend = backend
self._worker_id = worker_id
self._executor = executor
@@ -191,6 +216,9 @@ class WorkerPoller:
self._consolidation_bank_priority: dict[str, int] | None = (
consolidation_bank_priority if consolidation_bank_priority else None
)
self._operation_retention_days = operation_retention_days
self._operation_cleanup_batch_size = operation_cleanup_batch_size
self._last_operation_cleanup_monotonic: float | None = None
# Cache of which optional PG routines are installed on the server
# (probed once, memoised for the life of the poller).
from ..engine.db.optional_routines import OptionalRoutines
@@ -209,6 +237,9 @@ class WorkerPoller:
# Rotation offset for per-tenant fair claiming. Advances past the last
# schema we serviced so a busy tenant can't monopolize the poll order.
self._next_schema_idx: int = 0
# Retention cleanup runs outside the claim loop. Keep one task per
# poller so maintenance cannot overlap with itself or block slot refill.
self._operation_cleanup_task: asyncio.Task[None] | None = None
@staticmethod
def _normalize_poll_schema(schema: str | None) -> str | None:
@@ -223,6 +254,67 @@ class WorkerPoller:
# Convert default schema to None for SQL compatibility (no prefix), keep others as-is
return [self._normalize_poll_schema(t.schema) for t in tenants]
async def _cleanup_terminal_operations_if_due(self) -> None:
"""Schedule one cleanup sweep without blocking the task-claiming loop."""
if self._operation_retention_days == 0:
return
if self._operation_cleanup_task is not None and not self._operation_cleanup_task.done():
return
now = time.monotonic()
if (
self._last_operation_cleanup_monotonic is not None
and now - self._last_operation_cleanup_monotonic < OPERATION_CLEANUP_INTERVAL_SECONDS
):
return
# Advance the guard before scheduling so a failing database cannot turn
# the tight poll loop into an unbounded maintenance retry loop.
self._last_operation_cleanup_monotonic = now
self._operation_cleanup_task = asyncio.create_task(self._cleanup_terminal_operations())
async def _cleanup_terminal_operations(self) -> None:
"""Prune one bounded terminal-operation batch from every tenant schema."""
try:
schemas = await self._get_schemas()
except Exception as e:
logger.warning(f"Worker {self._worker_id} failed to discover schemas for operation cleanup: {e}")
return
# Oracle resolves unqualified table names from a context-bound session
# schema. Bind every iteration before acquiring its connection; on
# PostgreSQL this is harmless and fq_table remains explicit.
from ..engine.memory_engine import _current_schema
cutoff = datetime.now(UTC) - timedelta(days=self._operation_retention_days)
for schema in schemas:
table = fq_table("async_operations", schema)
schema_display = f'"{schema}"' if schema else "default"
schema_token = _current_schema.set(schema)
try:
async with self._backend.acquire() as conn:
async with conn.transaction():
deleted = await self._backend.ops.prune_terminal_operations(
conn,
table,
cutoff,
batch_size=self._operation_cleanup_batch_size,
)
if deleted:
logger.info(
f"Worker {self._worker_id} pruned {deleted} expired terminal operations "
f"from schema {schema_display}"
)
except Exception as e:
logger.warning(
f"Worker {self._worker_id} failed to prune terminal operations from schema {schema_display}: {e}"
)
finally:
_current_schema.reset(schema_token)
# Measure the next interval from completion as well as from the initial
# attempt. A slow multi-schema sweep remains bounded to one active task.
self._last_operation_cleanup_monotonic = time.monotonic()
async def _scan_active_schemas(self, schemas: list[str | None]) -> set[str | None]:
"""Find which schemas have pending work.
@@ -504,17 +596,20 @@ class WorkerPoller:
return result
async def _mark_completed(self, operation_id: str, schema: str | None):
"""Mark a task as completed."""
"""Mark a processing task as completed, then propagate to parent if needed."""
table = fq_table("async_operations", schema)
async with self._backend.acquire() as conn:
await conn.execute(
f"""
UPDATE {table}
SET status = 'completed', completed_at = now(), updated_at = now()
WHERE operation_id = $1
""",
operation_id,
)
async with conn.transaction():
result = await conn.execute(
f"""
UPDATE {table}
SET status = 'completed', completed_at = now(), updated_at = now()
WHERE operation_id = $1 AND status = 'processing'
""",
operation_id,
)
if _updated_row_count(result):
await self._maybe_update_parent_operation(operation_id, schema, conn)
async def _mark_failed(self, operation_id: str, error_message: str, schema: str | None):
"""Mark a task as failed with error message, then propagate to parent if applicable."""
@@ -749,6 +844,7 @@ class WorkerPoller:
task.task_dict["_schema"] = task.schema
await self._executor(task.task_dict)
logger.debug(f"Task {task.operation_id} execution finished")
await self._mark_completed(task.operation_id, task.schema)
terminal_success = True
except DeferOperation as e:
# Deferral is not a terminal outcome — do not record a completion.
@@ -938,6 +1034,13 @@ class WorkerPoller:
for task in tasks:
await self.execute_task(task)
# Run maintenance after newly claimed work has started. Keeping
# this before the continue/sleep split means a perpetually
# non-empty queue cannot starve cleanup, while a large tenant
# sweep cannot delay the first available task either.
await self._cleanup_terminal_operations_if_due()
if tasks:
# Continue immediately to claim more tasks (if slots available)
continue
@@ -963,6 +1066,14 @@ class WorkerPoller:
# Backoff on error
await asyncio.sleep(1)
cleanup_task = self._operation_cleanup_task
if cleanup_task is not None and not cleanup_task.done():
cleanup_task.cancel()
try:
await cleanup_task
except asyncio.CancelledError:
pass
logger.info(f"Worker {self._worker_id} polling loop stopped")
async def shutdown_graceful(self, timeout: float = 30.0):
+1 -1
View File
@@ -51,7 +51,7 @@ dependencies = [
"anthropic>=0.40.0",
"typer>=0.9.0",
"cohere>=5.0.0",
"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
"litellm>=1.84.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 / GHSA-pq44-5pcq-4r5g / GHSA-8cjq-wjmh-q42r; 1.84.0 fixes GHSA-4xpc-pv4p-pm3w
"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'",
@@ -0,0 +1,234 @@
"""Anthropic Message Batches support for the provider batch interface.
The engine's batch path (retain fact extraction, gated on
``retain_batch_enabled``) speaks the OpenAI batch wire shape: JSONL entries
with ``custom_id``/``method``/``url``/``body`` going in, and
``response.body.choices[0].message.content`` (+ OpenAI-keyed ``usage``) coming
out. ``AnthropicLLM`` translates both directions onto the Message Batches API,
which bills all token usage at 50% of standard price.
Translation rules mirror the provider's synchronous ``call()`` path:
- system messages fold into the ``system`` param;
- ``max_completion_tokens`` becomes ``max_tokens`` (default 4096);
- ``temperature`` is dropped (the sync path never sends it either current
Claude models reject non-default sampling params);
- ``response_format`` with ``strict=True`` becomes a single forced tool_use
tool (native constrained decoding, issue #1002); non-strict injects the
schema into the system prompt and expects JSON text back.
"""
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
pytestmark = pytest.mark.asyncio
def _make_provider():
with patch("anthropic.AsyncAnthropic") as mock_client_cls:
mock_client_cls.return_value = MagicMock()
from hindsight_api.engine.providers.anthropic_llm import AnthropicLLM
provider = AnthropicLLM(
provider="anthropic",
api_key="fake-key",
base_url="",
model="claude-sonnet-5",
)
provider._client = MagicMock()
return provider
_SCHEMA = {
"type": "object",
"properties": {"facts": {"type": "array", "items": {"type": "string"}}},
"required": ["facts"],
}
def _openai_request(custom_id: str, *, strict: bool = True, temperature: float | None = 0.1) -> dict:
body = {
"model": "claude-sonnet-5",
"messages": [
{"role": "system", "content": "Extract facts."},
{"role": "user", "content": f"Text for {custom_id}"},
],
"max_completion_tokens": 2000,
"response_format": {
"type": "json_schema",
"json_schema": {"name": "facts", "schema": _SCHEMA, "strict": strict},
},
}
if temperature is not None:
body["temperature"] = temperature
return {"custom_id": custom_id, "method": "POST", "url": "/v1/chat/completions", "body": body}
def _batch(status: str = "in_progress", **counts) -> SimpleNamespace:
defaults = {"processing": 0, "succeeded": 0, "errored": 0, "canceled": 0, "expired": 0}
defaults.update(counts)
return SimpleNamespace(
id="msgbatch_test1",
processing_status=status,
created_at="2026-07-08T00:00:00Z",
ended_at="2026-07-08T00:30:00Z" if status == "ended" else None,
request_counts=SimpleNamespace(**defaults),
)
class _AsyncIter:
def __init__(self, items):
self._items = list(items)
def __aiter__(self):
self._iter = iter(self._items)
return self
async def __anext__(self):
try:
return next(self._iter)
except StopIteration:
raise StopAsyncIteration from None
def _succeeded_entry(custom_id: str, tool_input: dict) -> SimpleNamespace:
block = SimpleNamespace(type="tool_use", name="structured_response", input=tool_input, text=None)
message = SimpleNamespace(
content=[block],
usage=SimpleNamespace(input_tokens=100, output_tokens=40, cache_read_input_tokens=0),
stop_reason="tool_use",
)
return SimpleNamespace(custom_id=custom_id, result=SimpleNamespace(type="succeeded", message=message))
def _errored_entry(custom_id: str) -> SimpleNamespace:
error = SimpleNamespace(type="invalid_request", message="bad request")
return SimpleNamespace(custom_id=custom_id, result=SimpleNamespace(type="errored", error=error))
async def test_supports_batch_api():
provider = _make_provider()
assert await provider.supports_batch_api() is True
async def test_submit_batch_translates_openai_requests():
provider = _make_provider()
provider._client.messages.batches.create = AsyncMock(return_value=_batch("in_progress", processing=2))
requests = [_openai_request("chunk_0"), _openai_request("chunk_1")]
metadata = await provider.submit_batch(requests)
provider._client.messages.batches.create.assert_awaited_once()
submitted = provider._client.messages.batches.create.await_args.kwargs["requests"]
assert [r["custom_id"] for r in submitted] == ["chunk_0", "chunk_1"]
params = submitted[0]["params"]
assert params["model"] == "claude-sonnet-5"
# System message folded into the system param, not left in messages.
assert "Extract facts." in params["system"]
assert all(m["role"] != "system" for m in params["messages"])
assert params["messages"] == [{"role": "user", "content": "Text for chunk_0"}]
assert params["max_tokens"] == 2000
# temperature is dropped, mirroring the sync call() path.
assert "temperature" not in params
# strict=True → forced tool_use (native constrained decoding).
assert params["tools"][0]["input_schema"] == _SCHEMA
assert params["tool_choice"] == {"type": "tool", "name": "structured_response"}
assert metadata["batch_id"] == "msgbatch_test1"
assert metadata["status"] == "in_progress"
assert metadata["request_count"] == 2
async def test_submit_batch_non_strict_schema_injects_into_system():
provider = _make_provider()
provider._client.messages.batches.create = AsyncMock(return_value=_batch("in_progress", processing=1))
await provider.submit_batch([_openai_request("chunk_0", strict=False)])
params = provider._client.messages.batches.create.await_args.kwargs["requests"][0]["params"]
assert "tools" not in params
assert "tool_choice" not in params
# Schema is injected into the system prompt for JSON-text output.
assert "facts" in params["system"]
assert "valid JSON" in params["system"]
async def test_get_batch_status_in_progress():
provider = _make_provider()
provider._client.messages.batches.retrieve = AsyncMock(
return_value=_batch("in_progress", processing=3, succeeded=1)
)
status = await provider.get_batch_status("msgbatch_test1")
assert status["batch_id"] == "msgbatch_test1"
assert status["status"] == "in_progress"
assert status["request_counts"]["total"] == 4
assert status["request_counts"]["completed"] == 1
async def test_get_batch_status_ended_maps_to_completed():
"""The engine's poll loop breaks on the OpenAI-vocabulary status 'completed'."""
provider = _make_provider()
provider._client.messages.batches.retrieve = AsyncMock(return_value=_batch("ended", succeeded=3, errored=1))
status = await provider.get_batch_status("msgbatch_test1")
assert status["status"] == "completed"
assert status["request_counts"]["total"] == 4
assert status["request_counts"]["completed"] == 4
assert status["request_counts"]["failed"] == 1
assert status["completed_at"] == "2026-07-08T00:30:00Z"
async def test_retrieve_batch_results_translates_to_openai_shape():
provider = _make_provider()
provider._client.messages.batches.retrieve = AsyncMock(return_value=_batch("ended", succeeded=1, errored=1))
entries = [
_succeeded_entry("chunk_0", {"facts": ["Alice is an engineer."]}),
_errored_entry("chunk_1"),
]
provider._client.messages.batches.results = AsyncMock(return_value=_AsyncIter(entries))
results = await provider.retrieve_batch_results("msgbatch_test1")
by_id = {r["custom_id"]: r for r in results}
ok = by_id["chunk_0"]
body = ok["response"]["body"]
# The engine reads choices[0].message.content and json.loads() it.
assert json.loads(body["choices"][0]["message"]["content"]) == {"facts": ["Alice is an engineer."]}
# Usage arrives under the OpenAI key names the engine sums.
assert body["usage"] == {"prompt_tokens": 100, "completion_tokens": 40, "total_tokens": 140}
failed = by_id["chunk_1"]
assert failed["error"]
assert "response" not in failed
async def test_retrieve_batch_results_text_content_passthrough():
"""Non-strict requests come back as text blocks; concatenate them as content."""
provider = _make_provider()
provider._client.messages.batches.retrieve = AsyncMock(return_value=_batch("ended", succeeded=1))
text_block = SimpleNamespace(type="text", text='{"facts": []}')
message = SimpleNamespace(
content=[text_block],
usage=SimpleNamespace(input_tokens=10, output_tokens=5, cache_read_input_tokens=0),
stop_reason="end_turn",
)
entry = SimpleNamespace(custom_id="chunk_0", result=SimpleNamespace(type="succeeded", message=message))
provider._client.messages.batches.results = AsyncMock(return_value=_AsyncIter([entry]))
results = await provider.retrieve_batch_results("msgbatch_test1")
assert results[0]["response"]["body"]["choices"][0]["message"]["content"] == '{"facts": []}'
async def test_retrieve_batch_results_raises_when_not_ended():
provider = _make_provider()
provider._client.messages.batches.retrieve = AsyncMock(return_value=_batch("in_progress", processing=2))
with pytest.raises(ValueError, match="not completed"):
await provider.retrieve_batch_results("msgbatch_test1")
@@ -0,0 +1,181 @@
"""Anthropic prompt caching via inline cache_control markers.
``LLMInterface.get_or_create_cached_prefix`` documents Anthropic as an
"inline-marker provider": rather than returning an explicit cache handle, the
provider marks the reusable prefix inside ``call`` / ``call_with_tools`` with
``cache_control`` breakpoints. Cache reads bill at ~10% of the base input
price; a marker below the model's minimum cacheable prefix is silently
ignored by the API (no premium), so marking is safe unconditionally.
Two breakpoints (of the 4 allowed):
- the system prompt, in both entry points it is stable per scope (fact
extraction reuses it across every chunk; reflect/consolidation put their
stable instructions there), so tools+system cache across calls;
- the last message content block, in ``call_with_tools`` only the reflect
agent loop resends the whole growing conversation each iteration, so each
request's end-marker becomes the next iteration's cache read point.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import BaseModel
pytestmark = pytest.mark.asyncio
EPHEMERAL = {"type": "ephemeral"}
def _make_provider():
with patch("anthropic.AsyncAnthropic") as mock_client_cls:
mock_client_cls.return_value = MagicMock()
from hindsight_api.engine.providers.anthropic_llm import AnthropicLLM
provider = AnthropicLLM(
provider="anthropic",
api_key="fake-key",
base_url="",
model="claude-sonnet-5",
)
provider._client = MagicMock()
return provider
def _text_response(text: str = "ok"):
block = MagicMock()
block.type = "text"
block.text = text
resp = MagicMock()
resp.content = [block]
resp.usage = MagicMock(input_tokens=10, output_tokens=2, cache_read_input_tokens=0)
resp.stop_reason = "end_turn"
return resp
def _tool_response():
resp = MagicMock()
resp.content = []
resp.usage = MagicMock(input_tokens=10, output_tokens=2, cache_read_input_tokens=0)
resp.stop_reason = "end_turn"
return resp
class _Out(BaseModel):
facts: list[str]
async def test_call_marks_system_prompt_for_caching():
provider = _make_provider()
provider._client.messages.create = AsyncMock(return_value=_text_response())
with patch("hindsight_api.engine.providers.anthropic_llm.get_metrics_collector"):
await provider.call(
messages=[
{"role": "system", "content": "Stable extraction instructions."},
{"role": "user", "content": "Chunk text."},
],
scope="test",
max_retries=0,
)
params = provider._client.messages.create.await_args.kwargs
assert params["system"] == [{"type": "text", "text": "Stable extraction instructions.", "cache_control": EPHEMERAL}]
# User messages are untouched in call() — one-shot calls share no
# conversation prefix with each other, only the system prompt.
assert params["messages"] == [{"role": "user", "content": "Chunk text."}]
async def test_call_non_strict_schema_lands_inside_cached_system_block():
"""Schema injection happens before marking, so the marked block includes it."""
provider = _make_provider()
provider._client.messages.create = AsyncMock(return_value=_text_response('{"facts": []}'))
with patch("hindsight_api.engine.providers.anthropic_llm.get_metrics_collector"):
await provider.call(
messages=[
{"role": "system", "content": "Extract."},
{"role": "user", "content": "Text."},
],
response_format=_Out,
scope="test",
max_retries=0,
)
params = provider._client.messages.create.await_args.kwargs
assert len(params["system"]) == 1
system_block = params["system"][0]
assert system_block["cache_control"] == EPHEMERAL
assert "Extract." in system_block["text"]
assert "valid JSON" in system_block["text"]
async def test_call_without_system_prompt_sends_no_system_param():
provider = _make_provider()
provider._client.messages.create = AsyncMock(return_value=_text_response())
with patch("hindsight_api.engine.providers.anthropic_llm.get_metrics_collector"):
await provider.call(
messages=[{"role": "user", "content": "hi"}],
scope="test",
max_retries=0,
)
assert "system" not in provider._client.messages.create.await_args.kwargs
async def test_call_with_tools_marks_system_and_last_message():
provider = _make_provider()
provider._client.messages.create = AsyncMock(return_value=_tool_response())
with patch("hindsight_api.engine.providers.anthropic_llm.get_metrics_collector"):
await provider.call_with_tools(
messages=[
{"role": "system", "content": "Reflect agent instructions."},
{"role": "user", "content": "Question?"},
{"role": "assistant", "content": "Working on it."},
{"role": "user", "content": "Latest turn."},
],
tools=[{"function": {"name": "recall", "description": "d", "parameters": {"type": "object"}}}],
max_retries=0,
)
params = provider._client.messages.create.await_args.kwargs
assert params["system"] == [{"type": "text", "text": "Reflect agent instructions.", "cache_control": EPHEMERAL}]
messages = params["messages"]
# Earlier messages carry no markers — only the final block gets one, so
# the next iteration of the agent loop reads the whole prefix from cache.
assert messages[0] == {"role": "user", "content": "Question?"}
assert messages[1] == {"role": "assistant", "content": "Working on it."}
assert messages[2]["content"] == [{"type": "text", "text": "Latest turn.", "cache_control": EPHEMERAL}]
async def test_call_with_tools_marks_last_block_of_tool_result_message():
"""Tool-result turns arrive as block lists; the marker goes on the last block."""
provider = _make_provider()
provider._client.messages.create = AsyncMock(return_value=_tool_response())
with patch("hindsight_api.engine.providers.anthropic_llm.get_metrics_collector"):
await provider.call_with_tools(
messages=[
{"role": "user", "content": "Question?"},
{
"role": "assistant",
"tool_calls": [
{"id": "t1", "function": {"name": "recall", "arguments": "{}"}},
{"id": "t2", "function": {"name": "recall", "arguments": "{}"}},
],
},
{"role": "tool", "tool_call_id": "t1", "content": "result one"},
{"role": "tool", "tool_call_id": "t2", "content": "result two"},
],
tools=[{"function": {"name": "recall", "description": "d", "parameters": {"type": "object"}}}],
max_retries=0,
)
messages = provider._client.messages.create.await_args.kwargs["messages"]
last_blocks = messages[-1]["content"]
assert last_blocks[-1]["type"] == "tool_result"
assert last_blocks[-1]["cache_control"] == EPHEMERAL
# The earlier tool-result message is unmarked.
assert all("cache_control" not in block for block in messages[-2]["content"])
@@ -107,5 +107,8 @@ async def test_non_strict_keeps_text_injection_fallback():
)
kwargs = provider._client.messages.create.call_args.kwargs
assert "tools" not in kwargs # no forced tool when not strict
assert "valid JSON matching this schema" in (kwargs.get("system") or "")
# system is a cache_control-marked block list; the schema text-injection
# lands inside the (single) block.
system_text = "".join(block["text"] for block in (kwargs.get("system") or []))
assert "valid JSON matching this schema" in system_text
assert isinstance(result, _Decision)
@@ -2,6 +2,7 @@
import asyncio
import json
import os
import uuid
import pytest
@@ -515,6 +516,105 @@ async def test_retain_outcome_metadata_records_zero_counts(memory, request_conte
assert "extraction_errors_sample" not in parent["result_metadata"]
async def _seed_retain_op_with_errors(pool, bank_id: str, error_count: int) -> uuid.UUID:
"""Insert a pending retain operation whose outcome metadata records extraction errors."""
operation_id = uuid.uuid4()
await pool.execute(
"""
INSERT INTO async_operations (operation_id, bank_id, operation_type, result_metadata, status)
VALUES ($1, $2, $3, $4, $5)
""",
operation_id,
bank_id,
"retain",
json.dumps(
{
"unit_ids_count": 3,
"extraction_errors_count": error_count,
"extraction_errors_sample": ["chunk 2 failed to parse"],
}
),
"pending",
)
return operation_id
async def _op_row(pool, operation_id: uuid.UUID):
return await pool.fetchrow(
"SELECT status, error_message FROM async_operations WHERE operation_id = $1",
operation_id,
)
@pytest.mark.asyncio
async def test_completion_marks_failed_when_flag_on_and_errors_present(memory):
"""With the escape hatch on, a retain that dropped facts ends 'failed' (issue #2700)."""
from hindsight_api.config import ENV_FAIL_ON_EXTRACTION_ERRORS, clear_config_cache
bank_id = "test_fail_on_extraction_errors_on"
pool = await memory._get_pool()
await _ensure_bank(pool, bank_id)
operation_id = await _seed_retain_op_with_errors(pool, bank_id, error_count=2)
os.environ[ENV_FAIL_ON_EXTRACTION_ERRORS] = "true"
clear_config_cache()
try:
await memory._mark_operation_completed(str(operation_id))
finally:
del os.environ[ENV_FAIL_ON_EXTRACTION_ERRORS]
clear_config_cache()
row = await _op_row(pool, operation_id)
assert row["status"] == "failed"
assert row["error_message"] is not None
assert "2" in row["error_message"]
assert "extraction error" in row["error_message"].lower()
@pytest.mark.asyncio
async def test_completion_stays_completed_when_flag_off(memory):
"""Default behavior is preserved: extraction errors still complete the operation."""
from hindsight_api.config import ENV_FAIL_ON_EXTRACTION_ERRORS, clear_config_cache
bank_id = "test_fail_on_extraction_errors_off"
pool = await memory._get_pool()
await _ensure_bank(pool, bank_id)
operation_id = await _seed_retain_op_with_errors(pool, bank_id, error_count=2)
os.environ.pop(ENV_FAIL_ON_EXTRACTION_ERRORS, None)
clear_config_cache()
try:
await memory._mark_operation_completed(str(operation_id))
finally:
clear_config_cache()
row = await _op_row(pool, operation_id)
assert row["status"] == "completed"
assert row["error_message"] is None
@pytest.mark.asyncio
async def test_completion_completed_when_flag_on_but_no_errors(memory):
"""The flag only fails operations that actually accumulated extraction errors."""
from hindsight_api.config import ENV_FAIL_ON_EXTRACTION_ERRORS, clear_config_cache
bank_id = "test_fail_on_extraction_errors_none"
pool = await memory._get_pool()
await _ensure_bank(pool, bank_id)
operation_id = await _seed_retain_op_with_errors(pool, bank_id, error_count=0)
os.environ[ENV_FAIL_ON_EXTRACTION_ERRORS] = "true"
clear_config_cache()
try:
await memory._mark_operation_completed(str(operation_id))
finally:
del os.environ[ENV_FAIL_ON_EXTRACTION_ERRORS]
clear_config_cache()
row = await _op_row(pool, operation_id)
assert row["status"] == "completed"
@pytest.mark.asyncio
async def test_retain_records_user_provided_document_ids(memory, request_context):
"""User-supplied document_ids land in child op result_metadata.document_ids."""
@@ -5,6 +5,7 @@ import pytest_asyncio
import httpx
from datetime import datetime
from hindsight_api.api import create_app
from hindsight_api.api.http import BankTemplateManifest, validate_bank_template
@pytest_asyncio.fixture
@@ -81,6 +82,17 @@ class TestImportValidation:
assert set(data["mental_models_created"]) == {"test-model-one", "test-model-two"}
assert set(data["directives_created"]) == {"Be concise", "Use examples"}
def test_verbatim_extraction_mode_is_valid(self):
"""verbatim is a valid retain extraction mode in bank manifests."""
manifest = BankTemplateManifest.model_validate(
{
"version": "1",
"bank": {"retain_extraction_mode": "verbatim"},
}
)
assert validate_bank_template(manifest) == []
@pytest.mark.asyncio
async def test_import_invalid_version(self, api_client, bank_id):
"""Reject manifest with unsupported version."""
+219 -4
View File
@@ -8,18 +8,15 @@ Tests cover:
- Worker recovery on restart
"""
import asyncio
import json
import logging
import uuid
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, MagicMock
import pytest
from hindsight_api import RequestContext
from hindsight_api.config import HindsightConfig
from hindsight_api.engine.llm_wrapper import create_llm_provider
from hindsight_api.engine.retain.fact_extraction import (
RetainContent,
extract_facts_from_contents,
@@ -202,6 +199,224 @@ async def test_batch_api_normal_flow(mock_llm_config, test_contents, hindsight_c
pass
@pytest.mark.asyncio
async def test_batch_api_accepts_top_level_fact_list(mock_llm_config, test_contents, hindsight_config):
"""Batch extraction accepts a recoverable top-level facts array."""
batch_id = "batch_top_level_facts"
mock_llm_config._provider_impl.supports_batch_api = AsyncMock(return_value=True)
mock_llm_config._provider_impl.submit_batch = AsyncMock(
return_value={
"batch_id": batch_id,
"status": "validating",
"request_counts": {"total": 1, "completed": 0, "failed": 0},
}
)
mock_llm_config._provider_impl.get_batch_status = AsyncMock(
return_value={"status": "completed", "request_counts": {"total": 1, "completed": 1, "failed": 0}}
)
mock_llm_config._provider_impl.retrieve_batch_results = AsyncMock(
return_value=[
{
"custom_id": "chunk_0",
"response": {
"body": {
"choices": [
{
"message": {
"content": json.dumps(
[
{
"what": "Alice is a senior software engineer at TechCorp",
"when": "present",
"where": "TechCorp",
"who": "Alice",
"why": "Professional background information",
"fact_type": "world",
"fact_kind": "conversation",
}
]
)
}
}
],
"usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150},
}
},
}
]
)
facts, chunks, usage = await extract_facts_from_contents_batch_api(
contents=[test_contents[0]],
llm_config=mock_llm_config,
agent_name="test_agent",
config=hindsight_config,
pool=None,
operation_id=None,
schema=None,
)
assert len(facts) == 1
assert "Alice" in facts[0].fact_text
assert len(chunks) == 1
assert chunks[0].fact_count == 1
assert usage.total_tokens == 150
@pytest.mark.asyncio
async def test_batch_api_rejects_top_level_non_fact_list(mock_llm_config, test_contents, hindsight_config):
"""Batch extraction records malformed top-level lists instead of crashing."""
batch_id = "batch_malformed_list"
mock_llm_config._provider_impl.supports_batch_api = AsyncMock(return_value=True)
mock_llm_config._provider_impl.submit_batch = AsyncMock(return_value={"batch_id": batch_id})
mock_llm_config._provider_impl.get_batch_status = AsyncMock(
return_value={"status": "completed", "request_counts": {"total": 1, "completed": 1, "failed": 0}}
)
mock_llm_config._provider_impl.retrieve_batch_results = AsyncMock(
return_value=[
{
"custom_id": "chunk_0",
"response": {
"body": {
"choices": [{"message": {"content": json.dumps(["not a fact dict"])}}],
"usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150},
}
},
}
]
)
facts, chunks, usage = await extract_facts_from_contents_batch_api(
contents=[test_contents[0]],
llm_config=mock_llm_config,
agent_name="test_agent",
config=hindsight_config,
pool=None,
operation_id=None,
schema=None,
)
assert facts == []
assert len(chunks) == 1
assert chunks[0].fact_count == 0
assert usage.total_tokens == 0
@pytest.mark.asyncio
async def test_batch_api_recovers_fenced_and_control_char_json(mock_llm_config, test_contents, hindsight_config):
"""#2701: batch content that bare json.loads can't parse but parse_llm_json can
(markdown code fences + an embedded raw control character, e.g. a transient
Gemini quirk) must still yield facts instead of dropping the whole chunk."""
batch_id = "batch_recoverable_json"
# Valid facts JSON, but wrapped in ```json fences AND containing a raw
# control character (\x01) inside a string value. Bare json.loads fails on
# both; parse_llm_json strips the fences and scrubs the control char.
inner_json = json.dumps(
{
"facts": [
{
"what": "Alice is a senior software engineer at TechCorp",
"when": "present",
"where": "TechCorp",
"who": "Alice",
"why": "Professional background\x01information",
"fact_type": "world",
"fact_kind": "conversation",
}
]
}
)
unparseable_content = f"```json\n{inner_json}\n```"
# Sanity: the raw content is NOT parseable by the bare parser.
with pytest.raises(json.JSONDecodeError):
json.loads(unparseable_content)
mock_llm_config._provider_impl.supports_batch_api = AsyncMock(return_value=True)
mock_llm_config._provider_impl.submit_batch = AsyncMock(return_value={"batch_id": batch_id})
mock_llm_config._provider_impl.get_batch_status = AsyncMock(
return_value={"status": "completed", "request_counts": {"total": 1, "completed": 1, "failed": 0}}
)
mock_llm_config._provider_impl.retrieve_batch_results = AsyncMock(
return_value=[
{
"custom_id": "chunk_0",
"response": {
"body": {
"choices": [{"message": {"content": unparseable_content}}],
"usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150},
}
},
}
]
)
facts, chunks, usage = await extract_facts_from_contents_batch_api(
contents=[test_contents[0]],
llm_config=mock_llm_config,
agent_name="test_agent",
config=hindsight_config,
pool=None,
operation_id=None,
schema=None,
)
# The facts are recovered rather than lost.
assert len(facts) == 1
assert "Alice" in facts[0].fact_text
assert len(chunks) == 1
assert chunks[0].fact_count == 1
assert usage.total_tokens == 150
@pytest.mark.asyncio
async def test_batch_api_unparseable_json_still_records_error(mock_llm_config, test_contents, hindsight_config):
"""#2701: genuinely unparseable content (not recoverable by parse_llm_json)
must preserve the existing behavior record the error, fact_count=0, no crash."""
batch_id = "batch_unparseable_json"
# Not JSON at all, and not recoverable by fence-stripping or control-char scrubbing.
unparseable_content = "this is not json {{{ ["
with pytest.raises(json.JSONDecodeError):
json.loads(unparseable_content)
mock_llm_config._provider_impl.supports_batch_api = AsyncMock(return_value=True)
mock_llm_config._provider_impl.submit_batch = AsyncMock(return_value={"batch_id": batch_id})
mock_llm_config._provider_impl.get_batch_status = AsyncMock(
return_value={"status": "completed", "request_counts": {"total": 1, "completed": 1, "failed": 0}}
)
mock_llm_config._provider_impl.retrieve_batch_results = AsyncMock(
return_value=[
{
"custom_id": "chunk_0",
"response": {
"body": {
"choices": [{"message": {"content": unparseable_content}}],
"usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150},
}
},
}
]
)
facts, chunks, usage = await extract_facts_from_contents_batch_api(
contents=[test_contents[0]],
llm_config=mock_llm_config,
agent_name="test_agent",
config=hindsight_config,
pool=None,
operation_id=None,
schema=None,
)
assert facts == []
assert len(chunks) == 1
assert chunks[0].fact_count == 0
assert usage.total_tokens == 0
@pytest.mark.asyncio
async def test_batch_api_crash_recovery(mock_llm_config, test_contents, hindsight_config, memory, request_context):
"""Test crash recovery: resume polling from existing batch_id."""
@@ -0,0 +1,22 @@
"""Regression tests for the causal link taxonomy used by retain."""
import pytest
from pydantic import ValidationError
from hindsight_api.engine.retain.fact_extraction import CausalRelation, FactCausalRelation
@pytest.mark.parametrize("relation_type", ["causes", "enables", "prevents"])
def test_retain_causal_models_reject_legacy_relation_types(relation_type: str) -> None:
"""New retain output is canonical even though storage reads legacy links."""
with pytest.raises(ValidationError):
CausalRelation(target_fact_index=0, relation_type=relation_type)
with pytest.raises(ValidationError):
FactCausalRelation(target_index=0, relation_type=relation_type)
def test_retain_causal_models_accept_caused_by() -> None:
"""The canonical causal relationship remains valid in both extraction schemas."""
assert CausalRelation(target_fact_index=0, relation_type="caused_by").relation_type == "caused_by"
assert FactCausalRelation(target_index=0, relation_type="caused_by").relation_type == "caused_by"
@@ -63,9 +63,7 @@ class TestCausalRelationsValidation:
assert rel.target_fact_index >= 0, (
f"Fact {i} has negative causal relation index: {rel.target_fact_index}"
)
assert rel.relation_type in ["caused_by", "enabled_by", "prevented_by"], (
f"Invalid relation_type: {rel.relation_type}"
)
assert rel.relation_type == "caused_by", f"Invalid relation_type: {rel.relation_type}"
@pytest.mark.asyncio
async def test_first_fact_has_no_causal_relations(self):
@@ -196,10 +194,10 @@ class TestCausalRelationsValidation:
)
@pytest.mark.asyncio
async def test_relation_types_are_backward_looking(self):
async def test_relation_types_use_the_canonical_form(self):
"""
Test that all relation types describe how the current fact
relates to a previous fact (caused_by, enabled_by, prevented_by).
Test that all extracted relation types use the canonical backward-looking
``caused_by`` form.
"""
text = """
Alice learned Python programming.
@@ -220,12 +218,9 @@ class TestCausalRelationsValidation:
config=_get_raw_config(),
)
# Verify relation types are all backward-looking
valid_types = {"caused_by", "enabled_by", "prevented_by"}
for i, fact in enumerate(facts):
if fact.causal_relations:
for rel in fact.causal_relations:
assert rel.relation_type in valid_types, (
f"Invalid relation_type '{rel.relation_type}'. Must be one of: {valid_types}"
assert rel.relation_type == "caused_by", (
f"Invalid relation_type '{rel.relation_type}'. Must be 'caused_by'"
)
@@ -85,11 +85,10 @@ After searching for weeks, I finally found a cheaper apartment in Brooklyn.
f"Got {len(all_causal_relations)}: {all_causal_relations}"
)
# Verify relation types are valid (passive only - facts reference PREVIOUS facts)
valid_types = {"caused_by", "enabled_by", "prevented_by"}
# Retain writes the single canonical passive form for previous facts.
for rel in all_causal_relations:
assert rel["relation_type"] in valid_types, (
f"Invalid relation_type '{rel['relation_type']}'. Must be one of {valid_types}"
assert rel["relation_type"] == "caused_by", (
f"Invalid relation_type '{rel['relation_type']}'. Must be 'caused_by'"
)
@pytest.mark.asyncio
@@ -165,10 +164,9 @@ Machine learning fascinated me so much that I changed my career to data science.
)
@pytest.mark.asyncio
async def test_bidirectional_causal_relationships(self):
async def test_causal_relationships_use_backward_references(self):
"""
Test that bidirectional causal relationships (causes and caused_by)
are handled correctly.
Test that causal relationships are represented as backward references.
"""
text = """
My promotion at work caused me to move to New York.
@@ -0,0 +1,50 @@
"""Regression coverage for deterministic chunk deletion ordering."""
import pytest
from hindsight_api.engine.retain import chunk_storage
class RecordingConn:
def __init__(self) -> None:
self.calls: list[tuple[str, tuple[object, ...]]] = []
async def execute(self, sql: str, *args: object) -> None:
self.calls.append((sql, args))
@pytest.mark.asyncio
async def test_delete_chunks_by_ids_predeletes_links_before_chunks():
conn = RecordingConn()
chunk_ids = ["chunk-b", "chunk-a"]
await chunk_storage.delete_chunks_by_ids(conn, chunk_ids)
assert len(conn.calls) == 2
link_sql, link_args = conn.calls[0]
chunk_sql, chunk_args = conn.calls[1]
assert link_args == (chunk_ids,)
assert chunk_args == (chunk_ids,)
assert "DELETE FROM" in link_sql
assert "memory_links" in link_sql
assert "target_units AS MATERIALIZED" in link_sql
assert "ordered_links AS MATERIALIZED" in link_sql
assert "ORDER BY" in link_sql
assert "FOR UPDATE OF ml" in link_sql
assert "DELETE FROM" in chunk_sql
assert "chunks" in chunk_sql
assert "ordered_chunks AS MATERIALIZED" in chunk_sql
assert "ORDER BY chunk_id" in chunk_sql
assert "FOR UPDATE" in chunk_sql
@pytest.mark.asyncio
async def test_delete_chunks_by_ids_noops_without_chunks():
conn = RecordingConn()
await chunk_storage.delete_chunks_by_ids(conn, [])
assert conn.calls == []
@@ -0,0 +1,199 @@
"""Regression test for surfacing the CLI's real error text (issue #2702).
The Claude Code CLI can report a failure with ``is_error=True`` while
``subtype`` still reads ``"success"``, putting the actual detail in
``result`` e.g. quota exhaustion:
{"type":"result","subtype":"success","is_error":true,
"api_error_status":429,
"result":"You've hit your weekly limit · resets Jul 18, 12pm (UTC)"}
The Agent SDK's fallback exception is built from ``errors`` (empty here)
or ``subtype``, producing the misleading "Claude Code returned an error
result: success". These tests assert that both provider call paths inspect
the ResultMessage directly and raise with the CLI's actual error text.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import pytest
QUOTA_ERROR_TEXT = "You've hit your weekly limit · resets Jul 18, 12pm (UTC)"
@dataclass
class _FakeOptions:
"""Stand-in for ClaudeAgentOptions; captures kwargs without importing SDK."""
system_prompt: str | None = None
max_turns: int | None = None
allowed_tools: list[str] = field(default_factory=list)
tools: list[str] = field(default_factory=list)
env: dict[str, str] = field(default_factory=dict)
mcp_servers: dict[str, Any] = field(default_factory=dict)
class _FakeAssistantMessage:
def __init__(self, content: list[Any]) -> None:
self.content = content
class _FakeTextBlock:
def __init__(self, text: str) -> None:
self.text = text
class _FakeResultMessage:
def __init__(self, subtype: str, is_error: bool, result: str | None) -> None:
self.subtype = subtype
self.is_error = is_error
self.result = result
def _instantiate_provider():
from hindsight_api.engine.providers.claude_code_llm import ClaudeCodeLLM
return ClaudeCodeLLM(
provider="claude-code",
api_key="",
base_url="",
model="claude-haiku-4-5",
reasoning_effort="low",
)
@pytest.mark.asyncio
async def test_call_raises_with_result_text_on_error_result(monkeypatch):
"""call() must surface ResultMessage.result, not the 'success' subtype."""
import claude_agent_sdk
async def fake_query(prompt: str, options: _FakeOptions):
yield _FakeResultMessage(subtype="success", is_error=True, result=QUOTA_ERROR_TEXT)
monkeypatch.setattr(claude_agent_sdk, "ClaudeAgentOptions", _FakeOptions)
monkeypatch.setattr(claude_agent_sdk, "AssistantMessage", _FakeAssistantMessage)
monkeypatch.setattr(claude_agent_sdk, "TextBlock", _FakeTextBlock)
monkeypatch.setattr(claude_agent_sdk, "ResultMessage", _FakeResultMessage)
monkeypatch.setattr(claude_agent_sdk, "query", fake_query)
provider = _instantiate_provider()
with pytest.raises(RuntimeError) as excinfo:
await provider.call(
messages=[{"role": "user", "content": "hi"}],
max_retries=0,
scope="test",
)
assert QUOTA_ERROR_TEXT in str(excinfo.value)
assert "error result: success" not in str(excinfo.value)
@pytest.mark.asyncio
async def test_call_falls_back_to_subtype_when_result_empty(monkeypatch):
"""With no result text, the subtype is still better than nothing."""
import claude_agent_sdk
async def fake_query(prompt: str, options: _FakeOptions):
yield _FakeResultMessage(subtype="error_max_turns", is_error=True, result=None)
monkeypatch.setattr(claude_agent_sdk, "ClaudeAgentOptions", _FakeOptions)
monkeypatch.setattr(claude_agent_sdk, "AssistantMessage", _FakeAssistantMessage)
monkeypatch.setattr(claude_agent_sdk, "TextBlock", _FakeTextBlock)
monkeypatch.setattr(claude_agent_sdk, "ResultMessage", _FakeResultMessage)
monkeypatch.setattr(claude_agent_sdk, "query", fake_query)
provider = _instantiate_provider()
with pytest.raises(RuntimeError, match="error_max_turns"):
await provider.call(
messages=[{"role": "user", "content": "hi"}],
max_retries=0,
scope="test",
)
@pytest.mark.asyncio
async def test_call_ignores_non_error_result_message(monkeypatch):
"""A normal is_error=False ResultMessage must not affect the response."""
import claude_agent_sdk
async def fake_query(prompt: str, options: _FakeOptions):
yield _FakeAssistantMessage(content=[_FakeTextBlock(text="ok")])
yield _FakeResultMessage(subtype="success", is_error=False, result="ok")
monkeypatch.setattr(claude_agent_sdk, "ClaudeAgentOptions", _FakeOptions)
monkeypatch.setattr(claude_agent_sdk, "AssistantMessage", _FakeAssistantMessage)
monkeypatch.setattr(claude_agent_sdk, "TextBlock", _FakeTextBlock)
monkeypatch.setattr(claude_agent_sdk, "ResultMessage", _FakeResultMessage)
monkeypatch.setattr(claude_agent_sdk, "query", fake_query)
provider = _instantiate_provider()
result = await provider.call(
messages=[{"role": "user", "content": "hi"}],
max_retries=0,
scope="test",
)
assert result == "ok"
@pytest.mark.asyncio
async def test_call_with_tools_raises_with_result_text_on_error_result(monkeypatch):
"""call_with_tools() must surface ResultMessage.result the same way."""
import claude_agent_sdk
class _FakeClient:
def __init__(self, options: _FakeOptions) -> None:
self.options = options
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def query(self, prompt: str) -> None:
return None
async def receive_response(self):
yield _FakeResultMessage(subtype="success", is_error=True, result=QUOTA_ERROR_TEXT)
@dataclass
class _FakeSdkMcpTool:
name: str
description: str
input_schema: dict[str, Any]
handler: Any
def fake_create_sdk_mcp_server(name: str, version: str, tools=None):
return {"name": name, "version": version, "tools": tools}
monkeypatch.setattr(claude_agent_sdk, "ClaudeAgentOptions", _FakeOptions)
monkeypatch.setattr(claude_agent_sdk, "AssistantMessage", _FakeAssistantMessage)
monkeypatch.setattr(claude_agent_sdk, "TextBlock", _FakeTextBlock)
monkeypatch.setattr(claude_agent_sdk, "ResultMessage", _FakeResultMessage)
monkeypatch.setattr(claude_agent_sdk, "ToolUseBlock", type("ToolUseBlock", (), {}))
monkeypatch.setattr(claude_agent_sdk, "ClaudeSDKClient", _FakeClient)
monkeypatch.setattr(claude_agent_sdk, "SdkMcpTool", _FakeSdkMcpTool)
monkeypatch.setattr(claude_agent_sdk, "create_sdk_mcp_server", fake_create_sdk_mcp_server)
provider = _instantiate_provider()
with pytest.raises(RuntimeError) as excinfo:
await provider.call_with_tools(
messages=[{"role": "user", "content": "hi"}],
tools=[
{
"function": {
"name": "noop",
"description": "no-op",
"parameters": {"type": "object", "properties": {}},
}
}
],
max_retries=0,
scope="test",
)
assert QUOTA_ERROR_TEXT in str(excinfo.value)
@@ -24,10 +24,11 @@ from __future__ import annotations
import asyncio
import base64
import json
import os
import stat
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
@@ -38,6 +39,7 @@ import pytest
from hindsight_api.engine.providers.codex_llm import (
_CODEX_CLIENT_ID,
_CODEX_REFRESH_TOKEN_URL,
CodexAuthManager,
CodexLLM,
CodexRefreshExpiredError,
)
@@ -396,6 +398,63 @@ async def test_concurrent_ensure_fresh_token_calls_produce_one_refresh(tmp_path:
assert call_count == 1, f"expected 1 network refresh under contention, got {call_count}"
def test_sibling_auth_manager_adopts_rotated_codex_credentials(tmp_path: Path):
"""A stale sibling manager should adopt auth.json rotation before reusing the old RT."""
expired = _make_jwt(int(time.time()) - 60)
new_access = _make_jwt(int(time.time()) + 3600)
auth_file = _make_codex_auth_file(tmp_path, expired, refresh_token="rt-old")
first = CodexAuthManager.from_file(auth_file)
sibling = CodexAuthManager.from_file(auth_file)
refresh_resp = _refresh_response(200, {"access_token": new_access, "refresh_token": "rt-new"})
with patch.object(first._http_client, "post", return_value=refresh_resp):
first.refresh_tokens(reason="test")
def unexpected_post(*args, **kwargs):
raise AssertionError("stale sibling should not call refresh endpoint with old refresh_token")
with patch.object(sibling._http_client, "post", new=unexpected_post):
sibling.refresh_tokens(reason="test", force=True)
assert sibling.access_token == new_access
assert sibling.refresh_token == "rt-new"
def test_parallel_auth_managers_share_one_refresh_for_same_auth_file(tmp_path: Path):
"""Separate managers in one process should single-flight per canonical auth path."""
expired = _make_jwt(int(time.time()) - 60)
new_access = _make_jwt(int(time.time()) + 3600)
auth_file = _make_codex_auth_file(tmp_path, expired, refresh_token="rt-old")
managers = [CodexAuthManager.from_file(auth_file), CodexAuthManager.from_file(auth_file)]
call_count = 0
call_count_lock = threading.Lock()
def fake_post(*args, **kwargs):
nonlocal call_count
with call_count_lock:
call_count += 1
time.sleep(0.02)
return _refresh_response(200, {"access_token": new_access, "refresh_token": "rt-new"})
patches = [patch.object(manager._http_client, "post", new=fake_post) for manager in managers]
for patcher in patches:
patcher.start()
try:
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [executor.submit(manager.refresh_tokens, "test") for manager in managers]
for future in futures:
future.result()
finally:
for patcher in patches:
patcher.stop()
assert call_count == 1
assert [manager.access_token for manager in managers] == [new_access, new_access]
assert [manager.refresh_token for manager in managers] == ["rt-new", "rt-new"]
# ---------------------------------------------------------------------------
# Reactive 401 retry on the request path
# ---------------------------------------------------------------------------
@@ -422,6 +481,7 @@ async def test_call_reactively_refreshes_on_401_and_retries(tmp_path: Path):
refresh_resp = _refresh_response(200, {"access_token": new_access, "refresh_token": "rt-new"})
call_count = {"refresh": 0, "post": 0}
sent_headers: list[httpx.Headers] = []
# Sync mock for the auth manager's HTTP client (used for token refresh).
def fake_refresh_post(*args, **kwargs):
@@ -431,6 +491,7 @@ async def test_call_reactively_refreshes_on_401_and_retries(tmp_path: Path):
# Async mock for the LLM's HTTP client (used for backend calls).
async def fake_backend_post(url, **kwargs):
call_count["post"] += 1
sent_headers.append(httpx.Headers(kwargs["headers"]))
if call_count["post"] == 1:
raise httpx.HTTPStatusError("401", request=MagicMock(), response=fail_response)
return success_resp
@@ -451,6 +512,10 @@ async def test_call_reactively_refreshes_on_401_and_retries(tmp_path: Path):
assert call_count["refresh"] == 1
assert call_count["post"] == 2 # one 401, one success after refresh
assert llm.access_token == new_access
assert sent_headers[0]["Authorization"] == f"Bearer {fresh}"
assert sent_headers[1]["Authorization"] == f"Bearer {new_access}"
for header_name in ("Content-Type", "OpenAI-Account-ID", "User-Agent", "Origin", "originator"):
assert sent_headers[1][header_name] == sent_headers[0][header_name]
@pytest.mark.asyncio
@@ -0,0 +1,63 @@
"""Regression tests for Codex request identity headers."""
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from hindsight_api.engine.providers.codex_llm import CodexLLM
def build_llm() -> CodexLLM:
with (
patch.object(CodexLLM, "_load_codex_auth", return_value=("token", "account")),
patch.object(CodexLLM, "_load_codex_refresh_token", return_value=None),
):
return CodexLLM(
provider="openai-codex",
api_key="ignored",
base_url="https://chatgpt.com/backend-api",
model="gpt-5.6-luna",
)
def assert_codex_request_identity(headers: httpx.Headers) -> None:
assert headers["originator"] == "codex_cli_rs"
assert headers["User-Agent"] == "codex_cli_rs/0.0.0 (Hindsight)"
@pytest.mark.asyncio
async def test_call_sends_codex_request_identity() -> None:
llm = build_llm()
response = MagicMock()
response.raise_for_status.return_value = None
with (
patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post,
patch.object(llm, "_parse_sse_stream", new_callable=AsyncMock, return_value="ok"),
):
mock_post.return_value = response
await llm.call(messages=[{"role": "user", "content": "hello"}], max_retries=0)
assert_codex_request_identity(mock_post.call_args.kwargs["headers"])
@pytest.mark.asyncio
async def test_call_with_tools_sends_codex_request_identity() -> None:
llm = build_llm()
response = MagicMock()
response.status_code = 200
response.raise_for_status.return_value = None
with (
patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post,
patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock, return_value=(None, [])),
):
mock_post.return_value = response
await llm.call_with_tools(
messages=[{"role": "user", "content": "hello"}],
tools=[],
max_retries=0,
)
assert_codex_request_identity(mock_post.call_args.kwargs["headers"])
@@ -0,0 +1,188 @@
"""
Regression tests for Codex structured output (issue #2504).
Before the fix, ``CodexLLM.call(strict_schema=True)`` was a dead no-op: structured
output always went through prompt-injected schema + raw ``json.loads`` on the
model's free-form text. Escape-heavy content (code, serial/CLI commands, Windows
paths, regexes) makes weaker models emit invalid ``\\escape`` sequences, so every
parse attempt fails and retain/consolidation burn all retries and fail.
The fix:
- ``strict_schema=True`` routes structured output through a single forced function
tool (constrained decoding into the response schema).
- The non-strict fallback now repairs invalid ``\\escape`` sequences before giving up.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import BaseModel
from hindsight_api.engine.providers.codex_llm import (
CodexLLM,
_repair_invalid_json_escapes,
)
from hindsight_api.engine.response_models import LLMToolCall
class _Fact(BaseModel):
fact: str
def build_llm() -> CodexLLM:
with patch.object(CodexLLM, "_load_codex_auth", return_value=("token", "account")):
return CodexLLM(
provider="openai-codex",
api_key="ignored",
base_url="https://chatgpt.com/backend-api",
model="gpt-5.4-mini",
)
# ---------------------------------------------------------------------------
# _repair_invalid_json_escapes — pure unit tests
# ---------------------------------------------------------------------------
def test_repair_fixes_invalid_escape_in_json():
# `\d` and `\s` are not valid JSON escapes; raw json.loads fails.
broken = r'{"fact": "regex \d+\s matches digits"}'
import json
with pytest.raises(json.JSONDecodeError):
json.loads(broken)
repaired = _repair_invalid_json_escapes(broken)
assert json.loads(repaired) == {"fact": r"regex \d+\s matches digits"}
def test_repair_preserves_valid_escapes():
import json
valid = r'{"fact": "line1\nline2\ttab \"quoted\" \\backslash é"}'
# Already valid — repair must not corrupt it.
assert json.loads(_repair_invalid_json_escapes(valid)) == json.loads(valid)
def test_repair_handles_windows_paths():
import json
# Uses path segments whose first char isn't a valid JSON escape letter
# (b/f/n/r/t/u), where the repair is unambiguous.
broken = r'{"path": "C:\Windows\System32\app.exe"}'
assert json.loads(_repair_invalid_json_escapes(broken)) == {"path": r"C:\Windows\System32\app.exe"}
def test_repair_handles_trailing_backslash():
# A lone trailing backslash must be escaped, not dropped.
assert _repair_invalid_json_escapes("abc\\") == "abc\\\\"
# ---------------------------------------------------------------------------
# strict_schema forced-tool path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_strict_schema_uses_forced_function_tool():
llm = build_llm()
response = MagicMock()
response.raise_for_status.return_value = None
tool_call = LLMToolCall(id="call-1", name="structured_response", arguments={"fact": "the sky is blue"})
with patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post:
mock_post.return_value = response
with patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock) as mock_parse:
mock_parse.return_value = (None, [tool_call])
result = await llm.call(
messages=[{"role": "user", "content": "The sky is blue"}],
response_format=_Fact,
strict_schema=True,
max_retries=0,
)
sent_payload = mock_post.call_args.kwargs["json"]
sent_headers = mock_post.call_args.kwargs["headers"]
# Forced tool wired into the request payload.
assert sent_payload["tool_choice"] == {"type": "function", "name": "structured_response"}
assert len(sent_payload["tools"]) == 1
assert sent_payload["tools"][0]["name"] == "structured_response"
assert sent_payload["parallel_tool_calls"] is False
assert sent_headers["originator"] == "codex_cli_rs"
assert sent_headers["User-Agent"] == "codex_cli_rs/0.0.0 (Hindsight)"
# No prompt-injected schema in the instructions.
assert "You must respond with valid JSON" not in sent_payload["instructions"]
assert isinstance(result, _Fact)
assert result.fact == "the sky is blue"
@pytest.mark.asyncio
async def test_strict_schema_skip_validation_returns_dict():
llm = build_llm()
response = MagicMock()
response.raise_for_status.return_value = None
tool_call = LLMToolCall(id="c", name="structured_response", arguments={"fact": "x"})
with patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post:
mock_post.return_value = response
with patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock) as mock_parse:
mock_parse.return_value = (None, [tool_call])
result = await llm.call(
messages=[{"role": "user", "content": "hi"}],
response_format=_Fact,
strict_schema=True,
skip_validation=True,
max_retries=0,
)
assert result == {"fact": "x"}
@pytest.mark.asyncio
async def test_strict_schema_retries_when_forced_tool_missing():
llm = build_llm()
response = MagicMock()
response.raise_for_status.return_value = None
with patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post:
mock_post.return_value = response
# Model returns no tool call at all — should raise after retries exhausted.
with patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock) as mock_parse:
mock_parse.return_value = ("some prose", [])
with pytest.raises(RuntimeError, match="structured_response"):
await llm.call(
messages=[{"role": "user", "content": "hi"}],
response_format=_Fact,
strict_schema=True,
max_retries=0,
)
# ---------------------------------------------------------------------------
# Non-strict fallback: escape repair keeps the retry storm from happening
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_non_strict_repairs_invalid_escapes_without_retrying():
llm = build_llm()
response = MagicMock()
response.raise_for_status.return_value = None
# Escape-heavy content the model would emit as invalid JSON.
escape_heavy = r'{"fact": "run rig-control \d serial \s command"}'
with patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post:
mock_post.return_value = response
with patch.object(llm, "_parse_sse_stream", new_callable=AsyncMock) as mock_parse:
mock_parse.return_value = escape_heavy
result = await llm.call(
messages=[{"role": "user", "content": "coding transcript"}],
response_format=_Fact,
strict_schema=False,
max_retries=3,
)
# Parsed on the first attempt (no retry storm): the SSE stream was read once.
assert mock_post.await_count == 1
assert isinstance(result, _Fact)
assert result.fact == r"run rig-control \d serial \s command"
@@ -122,6 +122,77 @@ def test_retain_structured_chunk_size_reads_from_env():
assert config.retain_structured_chunk_size == 9000
def test_fail_on_extraction_errors_defaults_to_false(monkeypatch):
"""Silent-success behavior is preserved by default (issue #2700)."""
from hindsight_api.config import ENV_FAIL_ON_EXTRACTION_ERRORS, HindsightConfig
monkeypatch.delenv(ENV_FAIL_ON_EXTRACTION_ERRORS, raising=False)
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
config = HindsightConfig.from_env()
assert config.fail_on_extraction_errors is False
def test_fail_on_extraction_errors_reads_true_from_env(monkeypatch):
"""The opt-in escape hatch parses truthy values from the environment."""
from hindsight_api.config import ENV_FAIL_ON_EXTRACTION_ERRORS, HindsightConfig
monkeypatch.setenv(ENV_FAIL_ON_EXTRACTION_ERRORS, "true")
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
config = HindsightConfig.from_env()
assert config.fail_on_extraction_errors is True
def test_llm_ollama_num_ctx_defaults_to_none(monkeypatch):
"""Unset Ollama num_ctx override lets Ollama use its model/server default."""
from hindsight_api.config import ENV_LLM_OLLAMA_NUM_CTX, HindsightConfig
monkeypatch.delenv(ENV_LLM_OLLAMA_NUM_CTX, raising=False)
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
config = HindsightConfig.from_env()
assert config.llm_ollama_num_ctx is None
def test_llm_ollama_num_ctx_keeps_direct_construction_default():
"""Direct HindsightConfig construction should not require the new field."""
from dataclasses import fields
from hindsight_api.config import HindsightConfig
config_field = next(item for item in fields(HindsightConfig) if item.name == "llm_ollama_num_ctx")
assert config_field.default is None
assert config_field.kw_only
def test_llm_ollama_num_ctx_reads_positive_int(monkeypatch):
"""The native Ollama context override is parsed as a positive integer."""
from hindsight_api.config import ENV_LLM_OLLAMA_NUM_CTX, HindsightConfig
monkeypatch.setenv(ENV_LLM_OLLAMA_NUM_CTX, "65536")
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
config = HindsightConfig.from_env()
assert config.llm_ollama_num_ctx == 65536
def test_llm_ollama_num_ctx_rejects_non_positive_values(monkeypatch):
"""Zero would be accepted by neither Ollama nor downstream range logic."""
from hindsight_api.config import ENV_LLM_OLLAMA_NUM_CTX, HindsightConfig
monkeypatch.setenv(ENV_LLM_OLLAMA_NUM_CTX, "0")
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
with pytest.raises(ValueError, match=ENV_LLM_OLLAMA_NUM_CTX):
HindsightConfig.from_env()
def test_retain_structured_chunk_size_can_be_less_than_chunk_size():
"""Structured-chunk cap can be smaller than the retain chunk target."""
from hindsight_api.config import HindsightConfig
@@ -706,3 +777,61 @@ def test_gemini_service_tier_empty_env_is_unset(monkeypatch):
config = HindsightConfig.from_env()
assert config.llm_gemini_service_tier is None
def test_operation_retention_defaults(monkeypatch):
from hindsight_api.config import (
ENV_OPERATION_CLEANUP_BATCH_SIZE,
ENV_OPERATION_RETENTION_DAYS,
HindsightConfig,
)
monkeypatch.delenv(ENV_OPERATION_RETENTION_DAYS, raising=False)
monkeypatch.delenv(ENV_OPERATION_CLEANUP_BATCH_SIZE, raising=False)
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
config = HindsightConfig.from_env()
assert config.operation_retention_days == 30
assert config.operation_cleanup_batch_size == 1000
assert "operation_retention_days" in HindsightConfig.get_static_fields()
assert "operation_cleanup_batch_size" in HindsightConfig.get_static_fields()
def test_operation_retention_env_overrides(monkeypatch):
from hindsight_api.config import (
ENV_OPERATION_CLEANUP_BATCH_SIZE,
ENV_OPERATION_RETENTION_DAYS,
HindsightConfig,
)
monkeypatch.setenv(ENV_OPERATION_RETENTION_DAYS, "0")
monkeypatch.setenv(ENV_OPERATION_CLEANUP_BATCH_SIZE, "37")
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
config = HindsightConfig.from_env()
assert config.operation_retention_days == 0
assert config.operation_cleanup_batch_size == 37
@pytest.mark.parametrize("raw", ["-1", "not-an-int"])
def test_operation_retention_rejects_invalid_values(monkeypatch, raw):
from hindsight_api.config import ENV_OPERATION_RETENTION_DAYS, HindsightConfig
monkeypatch.setenv(ENV_OPERATION_RETENTION_DAYS, raw)
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
with pytest.raises(ValueError, match=ENV_OPERATION_RETENTION_DAYS):
HindsightConfig.from_env()
@pytest.mark.parametrize("raw", ["0", "-1", "not-an-int"])
def test_operation_cleanup_batch_size_requires_positive_integer(monkeypatch, raw):
from hindsight_api.config import ENV_OPERATION_CLEANUP_BATCH_SIZE, HindsightConfig
monkeypatch.setenv(ENV_OPERATION_CLEANUP_BATCH_SIZE, raw)
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
with pytest.raises(ValueError, match=ENV_OPERATION_CLEANUP_BATCH_SIZE):
HindsightConfig.from_env()
@@ -5,13 +5,18 @@ guard the fix in CI — unlike the real-LLM integration test, which only trigger
the path stochastically.
"""
import logging
import types
import uuid
from dataclasses import dataclass
from unittest.mock import AsyncMock, patch
import pytest
from hindsight_api.engine.consolidation.consolidator import (
_DEDUP_PROMPT,
_dedup_active,
_dedup_decision_from_response,
_dedup_reconcile_create,
_dedup_reconcile_update,
_DedupDecision,
@@ -124,7 +129,7 @@ async def test_dedup_no_twin_above_threshold_returns_none() -> None:
async def test_dedup_llm_keep_does_not_merge() -> None:
kwargs, conn, llm = _ctx()
llm.call.return_value = _DedupDecision(action="keep", reason="different language")
llm.call.return_value = '{"action": "keep", "text": "", "reason": "different language"}'
with _patch_embed(), _patch_probe([_obs("Uzbek content on YouTube is described as very rich.", 0.98)]):
result = await _dedup_reconcile_create(**kwargs)
assert result is None
@@ -142,10 +147,91 @@ async def test_dedup_llm_missing_action_defaults_to_keep() -> None:
conn.execute.assert_not_called() # missing action is a conservative no-merge
def test_dedup_decision_accepts_exact_valid_actions() -> None:
assert _DedupDecision(action="merge").action == "merge"
assert _DedupDecision(action="keep").action == "keep"
def test_dedup_decision_invalid_action_defaults_to_keep(caplog) -> None:
with caplog.at_level(logging.WARNING):
decision = _DedupDecision(action="need_input", reason="model asked for more context")
assert decision.action == "keep"
assert "need_input" in caplog.text
assert "defaulting to keep" in caplog.text
@pytest.mark.parametrize(
("raw", "expected"),
[
# Case / whitespace variants of the CORRECT verdict are recovered via
# normalize, not discarded — a genuine merge must not become a missed merge.
("Merge", "merge"),
(" MERGE ", "merge"),
("keep\n", "keep"),
("KEEP", "keep"),
# Unrecognized / non-str values still degrade to keep (unchanged fail-safe;
# the warning path is covered by the dedicated tests below).
("await", "keep"),
("unknown", "keep"),
(None, "keep"),
(123, "keep"),
],
)
def test_dedup_decision_normalizes_action_case_and_whitespace(raw: object, expected: str) -> None:
assert _DedupDecision(action=raw).action == expected
def test_dedup_decision_non_scalar_action_defaults_to_keep(caplog) -> None:
with caplog.at_level(logging.WARNING):
list_decision = _DedupDecision(action=[])
dict_decision = _DedupDecision(action={"value": "merge"})
assert list_decision.action == "keep"
assert dict_decision.action == "keep"
assert "defaulting to keep" in caplog.text
def test_dedup_decision_accepts_raw_json_and_dict_responses() -> None:
raw_merge = '{"action": "merge", "text": "Merged observation.", "reason": "same fact"}'
raw_keep = {"action": "keep", "text": "", "reason": "different fact"}
merge_decision = _dedup_decision_from_response(raw_merge)
keep_decision = _dedup_decision_from_response(raw_keep)
assert merge_decision.action == "merge"
assert merge_decision.text == "Merged observation."
assert keep_decision.action == "keep"
assert keep_decision.text == ""
def test_dedup_decision_legacy_raw_text_defaults_to_keep(caplog) -> None:
with caplog.at_level(logging.WARNING):
decision = _dedup_decision_from_response('action="merge" text="Merged observation."')
assert decision.action == "keep"
assert decision.reason == "invalid structured response"
assert "Invalid consolidation dedup response" in caplog.text
def test_dedup_prompt_contract_requests_json_not_key_value() -> None:
prompt = _DEDUP_PROMPT.format(new="The agent checked health at 14:07.", existing="Health was checked.")
assert '{"action": "merge", "text": "...", "reason": "..."}' in prompt
assert '{"action": "keep", "text": "", "reason": "..."}' in prompt
assert '"text" to an empty string' in prompt
assert "Do NOT use key=value" in prompt
assert 'respond action="merge"' not in prompt
assert "{new}" not in prompt
assert "{existing}" not in prompt
async def test_dedup_llm_merge_folds_into_twin() -> None:
kwargs, conn, llm = _ctx()
kwargs["create_source_ids"] = [uuid.uuid4(), uuid.uuid4()]
llm.call.return_value = _DedupDecision(action="merge", text="Uzbek content on YouTube is very rich.")
llm.call.return_value = (
'{"action": "merge", "text": "Uzbek content on YouTube is very rich.", "reason": "same fact"}'
)
with _patch_embed(), _patch_probe([_obs("Uzbek content on YouTube is described as very rich.", 0.99)]):
result = await _dedup_reconcile_create(**kwargs)
assert result == _TWIN_ID # merged into the twin; caller skips the CREATE
@@ -78,6 +78,39 @@ async def test_patch_invalidate_and_revert_over_http(api_client, memory):
await memory.delete_bank(bank_id, request_context=RequestContext())
@pytest.mark.asyncio
async def test_patch_clears_occurred_dates_with_explicit_null(api_client, memory):
bank_id = f"curation-http-clear-dates-{uuid.uuid4().hex[:8]}"
mem_id = await _insert_fact(memory, bank_id, "Release v1.2 happened on Monday.")
pool = await memory._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
UPDATE memory_units
SET occurred_start = '2024-01-15T10:30:00Z',
occurred_end = '2024-01-15T11:00:00Z'
WHERE id = $1
""",
uuid.UUID(mem_id),
)
resp = await api_client.patch(
f"/v1/default/banks/{bank_id}/memories/{mem_id}",
json={"occurred_start": None, "occurred_end": None},
)
assert resp.status_code == 200, resp.text
assert resp.json()["occurred_start"] is None
assert resp.json()["occurred_end"] is None
resp = await api_client.get(f"/v1/default/banks/{bank_id}/memories/{mem_id}")
assert resp.status_code == 200
assert resp.json()["occurred_start"] is None
assert resp.json()["occurred_end"] is None
await memory.delete_bank(bank_id, request_context=RequestContext())
@pytest.mark.asyncio
async def test_patch_not_found_returns_404(api_client, memory):
bank_id = f"curation-http-404-{uuid.uuid4().hex[:8]}"
+203 -2
View File
@@ -4,8 +4,7 @@ Unit tests that verify the abstraction interfaces work correctly
without requiring a live database connection.
"""
import json
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, patch
import pytest
@@ -592,6 +591,37 @@ class TestConfig:
assert DEFAULT_DATABASE_BACKEND == "postgresql"
# ---------------------------------------------------------------------------
# Entity expansion CTE tests
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("ops_module", "ops_class", "limit_clause"),
[
("hindsight_api.engine.db.ops_postgresql", "PostgreSQLOps", "LIMIT 7"),
("hindsight_api.engine.db.ops_oracle", "OracleOps", "FETCH FIRST 7 ROWS ONLY"),
],
)
def test_entity_expansion_filters_fact_type_before_per_entity_cap(
ops_module: str, ops_class: str, limit_clause: str
) -> None:
"""The cap is per entity *and target fact type*, preventing mixed types from
exhausting a target type's candidate budget before the outer query sees it.
"""
from importlib import import_module
ops = getattr(import_module(ops_module), ops_class)()
cte = ops.build_entity_expansion_cte("memory_units", "unit_entities", 7)
lateral_start = cte.index("CROSS JOIN LATERAL")
lateral_end = cte.index(") t", lateral_start)
lateral_query = cte[lateral_start:lateral_end]
assert "mu_target.fact_type = $2" in lateral_query
assert lateral_query.index("mu_target.fact_type = $2") < lateral_query.index(limit_clause)
# ---------------------------------------------------------------------------
# OracleOps unit tests (mock DatabaseConnection, no live DB)
# ---------------------------------------------------------------------------
@@ -748,6 +778,85 @@ class TestOracleOpsInsertFactsBatch:
assert rows_data[0][13] == []
# ---------------------------------------------------------------------------
# PostgreSQL search_vector handling (insert). Since the curation archive drops
# search_vector (#2503), the insert is the single place it is populated, and
# pg_search_vector_expr is its one source of truth (shared with revert recompute).
# ---------------------------------------------------------------------------
class TestPostgreSQLSearchVector:
@staticmethod
def _cfg(ext: str, lang: str = "english"):
from types import SimpleNamespace
return SimpleNamespace(text_search_extension=ext, text_search_extension_native_language=lang)
@pytest.mark.parametrize(
"ext,needle",
[
("native", "to_tsvector('english'::regconfig,"),
("vchord", "::bm25_catalog.bm25vector"),
],
)
def test_expr_builds_vector_for_vector_backends(self, ext, needle):
from hindsight_api.engine.db.ops_postgresql import pg_search_vector_expr
expr = pg_search_vector_expr(self._cfg(ext))
assert expr is not None and needle in expr
# Always built from the same three carried columns.
assert "COALESCE(text, '')" in expr and "COALESCE(text_signals, '')" in expr
@pytest.mark.parametrize("ext", ["pgroonga", "pg_textsearch", "pg_search"])
def test_expr_none_for_base_column_backends(self, ext):
from hindsight_api.engine.db.ops_postgresql import pg_search_vector_expr
# These index the base text columns directly; search_vector stays empty.
assert pg_search_vector_expr(self._cfg(ext)) is None
def test_expr_accepts_custom_column_refs(self):
from hindsight_api.engine.db.ops_postgresql import pg_search_vector_expr
expr = pg_search_vector_expr(self._cfg("native"), text_col="mu.text", context_col="mu.context")
assert "COALESCE(mu.text, '')" in expr and "COALESCE(mu.context, '')" in expr
async def _insert_query(self, ext: str) -> str:
from hindsight_api.engine.db.ops_postgresql import PostgreSQLOps
conn = AsyncMock(spec=DatabaseConnection)
conn.fetch = AsyncMock(return_value=[{"id": "00000000-0000-0000-0000-000000000001"}])
batch = dict(
bank_id="b",
fact_texts=["t"],
embeddings=["[0.1]"],
event_dates=[None],
occurred_starts=[None],
occurred_ends=[None],
mentioned_ats=[None],
contexts=["c"],
fact_types=["world"],
metadata_jsons=["{}"],
chunk_ids=[None],
document_ids=[None],
tags_list=[""],
observation_scopes_list=[None],
text_signals_list=[None],
)
with patch("hindsight_api.config.get_config", return_value=self._cfg(ext)):
await PostgreSQLOps().insert_facts_batch(conn=conn, **batch)
return conn.fetch.call_args.args[0]
@pytest.mark.asyncio
@pytest.mark.parametrize("ext", ["native", "vchord"])
async def test_insert_includes_search_vector_column(self, ext):
assert "search_vector" in await self._insert_query(ext)
@pytest.mark.asyncio
@pytest.mark.parametrize("ext", ["pgroonga", "pg_textsearch", "pg_search"])
async def test_insert_omits_search_vector_column(self, ext):
assert "search_vector" not in await self._insert_query(ext)
# ---------------------------------------------------------------------------
# normalize_schema tests
# ---------------------------------------------------------------------------
@@ -769,3 +878,95 @@ class TestNormalizeSchema:
assert backend.normalize_schema("public") is None
assert backend.normalize_schema("tenant_abc") == "tenant_abc"
assert backend.normalize_schema(None) is None
# ---------------------------------------------------------------------------
# OracleBackend._set_session_schema regression
# ---------------------------------------------------------------------------
class TestOracleSetSessionSchema:
"""Regression coverage for _set_session_schema (no live Oracle required)."""
@pytest.mark.asyncio
async def test_does_not_await_synchronous_cursor_close(self):
"""A non-public schema is applied without awaiting the sync cursor.close().
oracledb's AsyncCursor.close() is synchronous (returns None), so
``await cursor.close()`` raised "object NoneType can't be used in
'await' expression" on every acquire() under a non-public schema —
breaking the DB health check and all memory operations on Oracle.
Reproduced with a fake cursor whose close() is synchronous, exactly
like oracledb: this test fails (TypeError) against the buggy code and
passes once the erroneous await is removed.
"""
from hindsight_api.engine import memory_engine
from hindsight_api.engine.db.oracle import OracleBackend
executed: list[str] = []
closed = {"count": 0}
class _FakeAsyncCursor:
async def execute(self, sql: str) -> None:
executed.append(sql)
async def fetchone(self):
# SESSION_USER lookup used to cache the connection's default schema.
return ("APP_USER",)
def close(self) -> None: # synchronous, like oracledb.AsyncCursor.close
closed["count"] += 1
class _FakeConn:
def cursor(self) -> "_FakeAsyncCursor":
return _FakeAsyncCursor()
backend = OracleBackend()
token = memory_engine._current_schema.set("TENANT_X")
try:
await backend._set_session_schema(_FakeConn())
finally:
memory_engine._current_schema.reset(token)
assert closed["count"] == 1
assert any('ALTER SESSION SET CURRENT_SCHEMA = "TENANT_X"' in s for s in executed)
@pytest.mark.asyncio
async def test_public_schema_resets_to_default_schema(self):
"""The default ``public`` schema resets a pooled Oracle session to its default.
Oracle pooled connections retain ``CURRENT_SCHEMA`` across checkouts, so a
connection previously used for a tenant schema would still point at that
tenant unless the ``public`` acquisition explicitly resets it (#2708). The
reset applies ``ALTER SESSION SET CURRENT_SCHEMA`` to the cached SESSION_USER,
and the synchronous ``cursor.close()`` is not awaited.
"""
from hindsight_api.engine import memory_engine
from hindsight_api.engine.db.oracle import OracleBackend
executed: list[str] = []
closed = {"count": 0}
class _FakeAsyncCursor:
async def execute(self, sql: str) -> None:
executed.append(sql)
async def fetchone(self):
return ("APP_USER",)
def close(self) -> None: # synchronous, like oracledb.AsyncCursor.close
closed["count"] += 1
class _FakeConn:
def cursor(self) -> "_FakeAsyncCursor":
return _FakeAsyncCursor()
backend = OracleBackend()
token = memory_engine._current_schema.set("public")
try:
await backend._set_session_schema(_FakeConn())
finally:
memory_engine._current_schema.reset(token)
assert closed["count"] == 1
assert any('ALTER SESSION SET CURRENT_SCHEMA = "APP_USER"' in s for s in executed)
@@ -10,13 +10,11 @@ Verifies that:
import asyncio
import logging
import os
from datetime import datetime, timezone
import pytest
import pytest_asyncio
from hindsight_api import RequestContext
from hindsight_api.engine.task_backend import SyncTaskBackend
logger = logging.getLogger(__name__)
@@ -396,3 +394,74 @@ async def test_concurrent_upserts_no_duplicates(memory_no_llm, request_context):
finally:
await memory_no_llm.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_append_mode_preserves_document_metadata_projection(memory_no_llm, request_context):
"""Append retains should keep item metadata visible through document APIs."""
bank_id = f"test_append_metadata_{_ts()}"
document_id = "append-metadata-doc"
try:
await memory_no_llm.retain_batch_async(
bank_id=bank_id,
contents=[
{
"content": "first turn from the agent session",
"context": "agent conversation",
"document_id": document_id,
"metadata": {
"source": "hermes",
"platform": "weixin",
"session_id": document_id,
"turn_index": "1",
},
"tags": ["source:hermes", "scope:local-agent", f"session:{document_id}"],
"update_mode": "append",
}
],
request_context=request_context,
)
await memory_no_llm.retain_batch_async(
bank_id=bank_id,
contents=[
{
"content": "second turn from the agent session",
"context": "agent conversation",
"document_id": document_id,
"metadata": {
"source": "hermes",
"platform": "weixin",
"session_id": document_id,
"turn_index": "2",
},
"tags": ["source:hermes", "scope:local-agent", f"session:{document_id}"],
"update_mode": "append",
}
],
request_context=request_context,
)
doc = await memory_no_llm.get_document(document_id, bank_id, request_context=request_context)
assert doc is not None
assert doc["document_metadata"] == {
"source": "hermes",
"platform": "weixin",
"session_id": document_id,
"turn_index": "2",
}
assert doc["retain_params"]["metadata"] == doc["document_metadata"]
assert doc["retain_params"]["context"] == "agent conversation"
listed = await memory_no_llm.list_documents(
bank_id,
tags=["source:hermes"],
request_context=request_context,
)
listed_doc = next(item for item in listed["items"] if item["id"] == document_id)
assert listed_doc["document_metadata"] == doc["document_metadata"]
assert listed_doc["retain_params"]["metadata"] == doc["document_metadata"]
finally:
await memory_no_llm.delete_bank(bank_id, request_context=request_context)
@@ -567,6 +567,57 @@ async def test_full_roundtrip_integrity(memory, request_context):
await memory.delete_bank(dst, request_context=request_context)
@pytest.mark.asyncio
async def test_transfer_preserves_legacy_causal_links(memory, request_context):
"""Legacy causal edges survive export/import without becoming retain inputs."""
src = _unique_bank("transfer_legacy_causal_src")
dst = _unique_bank("transfer_legacy_causal_dst")
legacy_types = ("causes", "enables", "prevents")
try:
await _retain(
memory,
src,
"Alice completed the design. Bob began implementation after the design.",
request_context,
"doc-legacy-causal",
)
units = await memory.list_memory_units(src, fact_type="world", request_context=request_context)
assert len(units["items"]) >= 2
from_unit_id = uuid.UUID(str(units["items"][0]["id"]))
to_unit_id = uuid.UUID(str(units["items"][1]["id"]))
from_text = units["items"][0]["text"]
to_text = units["items"][1]["text"]
backend = await memory._get_backend()
async with acquire_with_retry(backend) as conn:
await conn.executemany(
f"INSERT INTO {fq_table('memory_links')} "
"(from_unit_id, to_unit_id, link_type, entity_id, bank_id, weight) "
"VALUES ($1, $2, $3, NULL, $4, 1.0)",
[(from_unit_id, to_unit_id, link_type, src) for link_type in legacy_types],
)
archive = await memory.export_documents_async(src, request_context)
await _import(memory, dst, archive, request_context)
async with acquire_with_retry(backend) as conn:
imported_types = await conn.fetch(
f"SELECT ml.link_type, source.text AS source_text, target.text AS target_text "
f"FROM {fq_table('memory_links')} ml "
f"JOIN {fq_table('memory_units')} source ON source.id = ml.from_unit_id "
f"JOIN {fq_table('memory_units')} target ON target.id = ml.to_unit_id "
"WHERE ml.bank_id = $1 AND ml.link_type = ANY($2)",
dst,
list(legacy_types),
)
assert {(row["link_type"], row["source_text"], row["target_text"]) for row in imported_types} == {
(link_type, from_text, to_text) for link_type in legacy_types
}
finally:
await memory.delete_bank(src, request_context=request_context)
await memory.delete_bank(dst, request_context=request_context)
@pytest.mark.asyncio
async def test_export_import_observations(memory, request_context):
"""With include_observations, observations transfer and their sources re-link."""
@@ -9,12 +9,87 @@ BaseException'), which happened when last_error was only set in the
BadRequestError handler and not for non-dict JSON responses.
"""
import json
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
def test_output_retry_split_preserves_conversation_array_boundaries():
"""OutputTooLong retry splitting must keep conversation chunks valid JSON arrays."""
from hindsight_api.engine.retain.fact_extraction import _split_chunk_for_output_retry
turns = [
{"role": "user", "content": "alpha"},
{"role": "assistant", "content": "bravo"},
{"role": "user", "content": "charlie"},
{"role": "assistant", "content": "delta"},
]
split = _split_chunk_for_output_retry(json.dumps(turns))
assert split is not None
first, second = split
assert json.loads(first) == turns[:2]
assert json.loads(second) == turns[2:]
def test_output_retry_split_divides_single_oversized_turn_content():
"""A lone oversized conversation turn is split inside content and rewrapped."""
from hindsight_api.engine.retain.fact_extraction import _split_chunk_for_output_retry
turn = {"role": "user", "content": "abcdefghijklmnopqrstuvwxyz", "name": "casey"}
split = _split_chunk_for_output_retry(json.dumps([turn]))
assert split is not None
first, second = split
first_turn = json.loads(first)[0]
second_turn = json.loads(second)[0]
assert first_turn["role"] == "user"
assert second_turn["role"] == "user"
assert first_turn["name"] == "casey"
assert second_turn["name"] == "casey"
assert first_turn["content"] + second_turn["content"] == turn["content"]
def test_output_retry_split_returns_none_when_no_progress_possible():
"""Pathological tiny chunks should be dropped instead of recursively retried."""
from hindsight_api.engine.retain.fact_extraction import _split_chunk_for_output_retry
assert _split_chunk_for_output_retry("x") is None
assert _split_chunk_for_output_retry(json.dumps([{"role": "user", "content": ""}])) is None
@pytest.mark.asyncio
async def test_output_too_long_drops_unsplittable_subchunk_without_recursing():
"""If a chunk cannot be reduced further, auto-split exits gracefully."""
from hindsight_api.engine.llm_wrapper import OutputTooLongError
from hindsight_api.engine.retain.fact_extraction import _extract_facts_with_auto_split
config = _make_config(llm_max_retries=1)
llm_config = _make_llm_config(mock_response={})
with patch(
"hindsight_api.engine.retain.fact_extraction._extract_facts_from_chunk",
side_effect=OutputTooLongError("too long"),
) as extract:
facts, usage = await _extract_facts_with_auto_split(
chunk="x",
chunk_index=0,
total_chunks=1,
event_date=datetime(2023, 1, 1, tzinfo=timezone.utc),
context="",
llm_config=llm_config,
config=config,
agent_name="agent",
)
assert facts == []
assert extract.call_count == 1
def _make_config(llm_max_retries: int = 3, retain_llm_max_retries: int | None = None):
"""Build a minimal HindsightConfig for fact extraction tests."""
from hindsight_api.config import HindsightConfig
@@ -60,8 +135,8 @@ async def test_non_dict_json_all_retries_raises():
config = _make_config(llm_max_retries=3, retain_llm_max_retries=None)
# Mock: always returns a list (non-dict), which is invalid
llm_config = _make_llm_config(mock_response=[{"invalid": "response"}])
# Mock: always returns a list containing a non-dict item, which is invalid.
llm_config = _make_llm_config(mock_response=["invalid response"])
with patch(
"hindsight_api.engine.retain.fact_extraction._build_extraction_prompt_and_schema",
@@ -82,6 +157,50 @@ async def test_non_dict_json_all_retries_raises():
assert llm_config.call.call_count == 3
@pytest.mark.asyncio
async def test_top_level_fact_list_is_accepted_without_retry():
"""
Some lax-JSON models return the facts array directly instead of wrapping it
in {"facts": [...]}. A top-level list of dict-shaped facts is recoverable
and should not burn retries.
"""
from hindsight_api.engine.retain.fact_extraction import _extract_facts_from_chunk
config = _make_config(llm_max_retries=3, retain_llm_max_retries=None)
llm_config = _make_llm_config(
mock_response=[
{
"what": "Alice visited Paris",
"when": "2023",
"where": "Paris",
"who": "Alice",
"why": "vacation",
"fact_type": "world",
"fact_kind": "conversation",
}
]
)
with patch(
"hindsight_api.engine.retain.fact_extraction._build_extraction_prompt_and_schema",
return_value=("system prompt", MagicMock()),
):
facts, _usage = await _extract_facts_from_chunk(
chunk="Alice visited Paris in 2023.",
chunk_index=0,
total_chunks=1,
event_date=datetime(2023, 1, 1, tzinfo=timezone.utc),
context="travel notes",
llm_config=llm_config,
config=config,
agent_name="test-agent",
)
assert llm_config.call.call_count == 1
assert len(facts) == 1
assert "Alice visited Paris" in facts[0].fact
@pytest.mark.asyncio
async def test_non_dict_json_with_default_max_retries_raises():
"""
@@ -103,18 +103,22 @@ def test_body_translation_maps_roles_and_generation_config():
assert gc["temperature"] == 0.1
assert gc["maxOutputTokens"] == 2048
assert gc["responseMimeType"] == "application/json"
# strict=True -> grammar-enforced via responseJsonSchema
# grammar-enforced via responseJsonSchema
assert gc["responseJsonSchema"] == {"type": "object", "properties": {"facts": {"type": "array"}}}
# schema is also appended as a textual hint (mirrors the sync call path)
assert "valid JSON matching this schema" in req["systemInstruction"]["parts"][0]["text"]
def test_body_translation_omits_response_json_schema_when_not_strict():
def test_body_translation_grammar_enforces_schema_without_strict():
# #2699: Gemini always grammar-enforces via its native response_schema, so the
# batch path must set responseJsonSchema even when strict is absent/False
# (the interactive path already does). Otherwise batch requests at default
# config (HINDSIGHT_API_LLM_STRICT_SCHEMA=False) only get a textual schema hint
# and intermittently emit malformed JSON, dropping every fact in the chunk.
req = GeminiLLM._openai_body_to_gemini_request(_openai_request("c", strict=False)["body"])
gc = req["generationConfig"]
# Non-strict still forces JSON output, but does not grammar-enforce the schema
assert gc["responseMimeType"] == "application/json"
assert "responseJsonSchema" not in gc
assert gc["responseJsonSchema"] == {"type": "object", "properties": {"facts": {"type": "array"}}}
def test_assistant_role_maps_to_model():
@@ -0,0 +1,61 @@
"""Regression tests for Link Expansion's final graph score."""
from contextlib import asynccontextmanager
import math
from types import SimpleNamespace
import pytest
from hindsight_api.engine.search import link_expansion_retrieval
from hindsight_api.engine.search.link_expansion_retrieval import LinkExpansionRetriever
from hindsight_api.engine.search.types import RetrievalResult
def _row(fact_id: str, score: float, fact_type: str) -> dict[str, str | float]:
"""Create the subset of an expansion query row needed by RetrievalResult."""
return {"id": fact_id, "text": fact_id, "fact_type": fact_type, "score": score}
@pytest.mark.asyncio
async def test_activation_preserves_additive_score_across_fact_types(monkeypatch):
"""Graph merge order must match Link Expansion's additive per-type score."""
retriever = LinkExpansionRetriever()
@asynccontextmanager
async def fake_acquire_with_retry(_pool):
yield object()
async def fake_expand_combined(_conn, _seed_ids, fact_type, _budget, *, ops):
if fact_type == "world":
# Convergent semantic and causal signals make this fact's total
# score higher, despite its raw entity count being only 1.
return [_row("a", 1.0, fact_type)], [_row("a", 0.9, fact_type)], [_row("a", 0.3, fact_type)]
return [_row("b", 2.0, fact_type)], [_row("b", 0.7, fact_type)], []
monkeypatch.setattr(link_expansion_retrieval, "acquire_with_retry", fake_acquire_with_retry)
monkeypatch.setattr(retriever, "_expand_combined", fake_expand_combined)
pool = SimpleNamespace(ops=object())
world_results, _ = await retriever.retrieve(
pool,
query_embedding_str="unused",
bank_id="bank",
fact_type="world",
budget=2,
semantic_seeds=[RetrievalResult(id="seed-world", text="seed", fact_type="world")],
)
experience_results, _ = await retriever.retrieve(
pool,
query_embedding_str="unused",
bank_id="bank",
fact_type="experience",
budget=2,
semantic_seeds=[RetrievalResult(id="seed-experience", text="seed", fact_type="experience")],
)
combined = world_results + experience_results
combined.sort(key=lambda result: result.activation or 0.0, reverse=True)
assert [result.id for result in combined] == ["a", "b"]
assert world_results[0].activation == pytest.approx(math.tanh(0.5) + 0.9 + 0.3)
assert experience_results[0].activation == pytest.approx(math.tanh(1.0) + 0.7)
@@ -182,7 +182,7 @@ def test_batch_request_body_strict_follows_config(strict):
from hindsight_api.engine.retain.fact_extraction import _build_request_body
llm_config = SimpleNamespace(model="gpt-4o-mini", provider="openai", _provider_impl=SimpleNamespace())
config = SimpleNamespace(retain_max_completion_tokens=None, llm_strict_schema=strict)
config = SimpleNamespace(retain_max_completion_tokens=None, llm_strict_schema=strict, llm_temperature_retain=None)
# provider != "openai" service-tier branch skipped via _provider_impl without attr
llm_config._provider_impl.openai_service_tier = None
+127
View File
@@ -9,6 +9,7 @@ success/error paths, and the HTTP read API (list / stats / tokens).
import asyncio
import json
import logging
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
@@ -799,3 +800,129 @@ async def test_real_llm_retain_and_consolidation_traced(memory_real_llm):
assert by_op["consolidation"][0]["metadata"].get("source_memory_ids"), (
"consolidation trace missing source_memory_ids"
)
# ── recorder: shutdown / pre-init race conditions ─────────────────────────────
class _UninitializedBackend:
"""Mimics a DB backend before initialize() or after shutdown(): the object
exists but the internal asyncpg pool is None, so acquiring raises."""
_pool: object | None = None
async def acquire(self):
raise RuntimeError("PostgreSQLBackend is not initialized. Call initialize() first.")
class _ClosingBackend:
"""Mimics a backend whose pool is mid-shutdown: the pool object exists but
asyncpg raises InterfaceError('pool is closing') on acquire."""
_pool = object() # not None, passes the getattr guard
async def acquire(self):
raise Exception("pool is closing")
class _UnexpectedErrorBackend:
"""Mimics a backend with an unexpected (non-shutdown) error."""
_pool = object()
async def acquire(self):
raise RuntimeError("connection refused: some other error")
def _make_record(scope: str = "verification") -> LLMRequestRecord:
return LLMRequestRecord(
provider="test",
model="test-model",
scope=scope,
status="success",
started_at=datetime.now(timezone.utc),
ended_at=datetime.now(timezone.utc),
)
@pytest.mark.asyncio
async def test_safe_write_pool_none_skips_quietly(caplog):
"""pool_getter returning None should skip at debug, never warn."""
recorder = LLMTraceRecorder(
pool_getter=lambda: None,
schema_getter=lambda: "public",
enabled=True,
allowed_scopes=[],
)
with caplog.at_level(logging.DEBUG, logger="hindsight_api.engine.llm_trace"):
await recorder._safe_write(_make_record())
warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
assert not warnings, f"expected no warning for pool=None, got: {[r.message for r in warnings]}"
@pytest.mark.asyncio
async def test_safe_write_backend_pool_none_skips_quietly(caplog):
"""Backend exists but its internal _pool is None (post-shutdown) — should
skip at debug via the getattr guard, never warn."""
recorder = LLMTraceRecorder(
pool_getter=lambda: _UninitializedBackend(),
schema_getter=lambda: "public",
enabled=True,
allowed_scopes=[],
)
with caplog.at_level(logging.DEBUG, logger="hindsight_api.engine.llm_trace"):
await recorder._safe_write(_make_record())
warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
assert not warnings, f"expected no warning for backend._pool=None, got: {[r.message for r in warnings]}"
assert any("not initialized" in r.message or "pool not" in r.message for r in caplog.records)
@pytest.mark.asyncio
async def test_safe_write_pool_closing_downgrades_to_debug(caplog):
"""asyncpg InterfaceError('pool is closing') during acquire should be
downgraded to debug, not warned."""
recorder = LLMTraceRecorder(
pool_getter=lambda: _ClosingBackend(),
schema_getter=lambda: "public",
enabled=True,
allowed_scopes=[],
)
with caplog.at_level(logging.DEBUG, logger="hindsight_api.engine.llm_trace"):
await recorder._safe_write(_make_record())
warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
assert not warnings, f"expected no warning for pool-is-closing, got: {[r.message for r in warnings]}"
assert any("shutdown race" in r.message for r in caplog.records)
@pytest.mark.asyncio
async def test_safe_write_unexpected_error_still_warns(caplog):
"""Non-shutdown errors should still produce a WARNING."""
recorder = LLMTraceRecorder(
pool_getter=lambda: _UnexpectedErrorBackend(),
schema_getter=lambda: "public",
enabled=True,
allowed_scopes=[],
)
with caplog.at_level(logging.DEBUG, logger="hindsight_api.engine.llm_trace"):
await recorder._safe_write(_make_record())
warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
assert len(warnings) == 1, f"expected exactly 1 warning for unexpected error, got: {[r.message for r in warnings]}"
@pytest.mark.asyncio
async def test_attach_memory_ids_pool_none_skips_quietly(caplog):
"""_attach_memory_ids with _pool=None should skip at debug, never warn."""
recorder = LLMTraceRecorder(
pool_getter=lambda: _UninitializedBackend(),
schema_getter=lambda: "public",
enabled=True,
allowed_scopes=[],
)
with caplog.at_level(logging.DEBUG, logger="hindsight_api.engine.llm_trace"):
await recorder._attach_memory_ids(
bank_id="test-bank",
trace_id="test-trace",
patch={"memory_ids": ["m1"]},
)
warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
assert not warnings, f"expected no warning for attach with _pool=None, got: {[r.message for r in warnings]}"
+194 -1
View File
@@ -1,6 +1,199 @@
import pytest
from hindsight_api.engine.llm_wrapper import sanitize_llm_output
from hindsight_api.engine.llm_wrapper import create_llm_provider, sanitize_llm_output
def test_create_llm_provider_preserves_positional_timeout_compatibility():
"""The new Ollama knob must not steal the old positional timeout slot."""
impl = create_llm_provider(
"ollama",
"",
"",
"llama3.2",
"low",
None,
None,
None,
None,
None,
None,
None,
None,
None,
False,
None,
None,
7.5,
)
assert impl.timeout == 7.5
assert impl.ollama_num_ctx is None
def test_llm_provider_preserves_positional_timeout_compatibility():
"""The new Ollama knob must not steal the old positional timeout slot."""
from hindsight_api.engine.llm_wrapper import LLMProvider
provider = LLMProvider(
"ollama",
"",
"",
"llama3.2",
"low",
None,
None,
None,
None,
False,
None,
None,
None,
None,
None,
None,
None,
7.5,
)
assert provider.timeout == 7.5
assert provider.ollama_num_ctx is None
def test_llm_provider_threads_ollama_num_ctx_to_provider_impl():
"""LLMProvider carries the native Ollama context override to the implementation."""
from hindsight_api.engine.llm_wrapper import LLMProvider
provider = LLMProvider(
provider="ollama",
api_key="",
base_url="",
model="llama3.2",
ollama_num_ctx=65536,
)
assert provider.ollama_num_ctx == 65536
assert provider._provider_impl.ollama_num_ctx == 65536
@pytest.mark.parametrize("bad_value", [0, -1, 1.5, "65536", True])
def test_llm_provider_rejects_invalid_ollama_num_ctx(bad_value):
"""Direct callers should fail before sending invalid Ollama request options."""
from hindsight_api.engine.llm_wrapper import LLMProvider
with pytest.raises(ValueError, match="ollama_num_ctx"):
LLMProvider(
provider="ollama",
api_key="",
base_url="",
model="llama3.2",
ollama_num_ctx=bad_value,
)
@pytest.mark.parametrize("bad_value", [0, -1, 1.5, "65536", True])
def test_openai_compatible_llm_rejects_invalid_ollama_num_ctx(bad_value):
"""The provider implementation also validates direct construction."""
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
with pytest.raises(ValueError, match="ollama_num_ctx"):
OpenAICompatibleLLM(
provider="ollama",
api_key="",
base_url="",
model="llama3.2",
ollama_num_ctx=bad_value,
)
def test_llm_provider_from_env_reads_ollama_num_ctx(monkeypatch):
"""Direct env construction uses the same optional positive-int parser."""
from hindsight_api.config import ENV_LLM_OLLAMA_NUM_CTX, clear_config_cache
from hindsight_api.engine.llm_wrapper import LLMProvider
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "ollama")
monkeypatch.setenv(ENV_LLM_OLLAMA_NUM_CTX, "32768")
clear_config_cache()
provider = LLMProvider.from_env()
assert provider.ollama_num_ctx == 32768
assert provider._provider_impl.ollama_num_ctx == 32768
clear_config_cache()
@pytest.mark.asyncio
async def test_native_ollama_omits_num_ctx_unless_configured(monkeypatch):
"""Native Ollama calls should not override the model context window by default."""
from pydantic import BaseModel
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
class Answer(BaseModel):
ok: bool
class FakeResponse:
def raise_for_status(self):
return None
def json(self):
return {"message": {"content": '{"ok": true}'}}
calls = []
class FakeAsyncClient:
def __init__(self, **kwargs):
self.kwargs = kwargs
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
async def post(self, url, json, headers):
calls.append({"url": url, "json": json, "headers": headers})
return FakeResponse()
monkeypatch.setattr("hindsight_api.engine.providers.openai_compatible_llm.httpx.AsyncClient", FakeAsyncClient)
default_provider = OpenAICompatibleLLM(
provider="ollama",
api_key="",
base_url="",
model="llama3.2",
)
await default_provider._call_ollama_native(
messages=[{"role": "user", "content": "ping"}],
response_format=Answer,
max_completion_tokens=None,
temperature=None,
max_retries=0,
initial_backoff=1,
max_backoff=1,
skip_validation=False,
)
configured_provider = OpenAICompatibleLLM(
provider="ollama",
api_key="",
base_url="",
model="llama3.2",
ollama_num_ctx=65536,
)
await configured_provider._call_ollama_native(
messages=[{"role": "user", "content": "ping"}],
response_format=Answer,
max_completion_tokens=None,
temperature=None,
max_retries=0,
initial_backoff=1,
max_backoff=1,
skip_validation=False,
)
assert "num_ctx" not in calls[0]["json"]["options"]
assert calls[0]["json"]["options"]["num_batch"] == 512
assert calls[1]["json"]["options"]["num_ctx"] == 65536
@pytest.mark.parametrize(
@@ -182,3 +182,45 @@ async def test_schemas_with_expired_rows(memory: MemoryEngine):
# Disabled retention (days <= 0): always empty.
disabled = await conn.fetch("SELECT * FROM public.schemas_with_expired_rows('audit_log', 'started_at', 0)")
assert len(disabled) == 0
def _load_all_schema_migration():
"""Import the #2638 all-schema-run install migration by path."""
path = (
Path(__file__).resolve().parent.parent
/ "hindsight_api/alembic/versions/f2a4b6c8d0e2_maintenance_routines_all_schema_runs.py"
)
spec = importlib.util.spec_from_file_location("_maint_routines_all_schema", path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_routines_install_on_non_public_schema_run(monkeypatch):
"""Regression for #2638: a single-tenant deploy migrated into a non-``public``
schema must still create the shared ``public.*`` routines.
The runtime migrates only the configured schema, so ``target_schema`` is
never falsy/``public`` and the earlier public-only-gated migrations skipped
creation, leaving the maintenance loop logging ``function public. does not
exist`` forever. ``f2a4b6c8d0e2`` installs unconditionally (guarded by a
transaction-scoped advisory lock), so ``_pg_upgrade`` issues the
``CREATE OR REPLACE`` regardless of ``target_schema``.
"""
migration = _load_all_schema_migration()
executed: list[str] = []
monkeypatch.setattr(migration.op, "execute", lambda sql: executed.append(str(sql)))
migration._pg_upgrade()
joined = "\n".join(executed)
# Serialized against concurrent per-schema migration processes.
assert "pg_advisory_xact_lock" in joined
# Both routines (re)created in public — this is what a non-public run failed to do.
assert "CREATE OR REPLACE FUNCTION public.banks_needing_consolidation" in joined
assert "CREATE OR REPLACE FUNCTION public.schemas_with_expired_rows" in joined
# And crucially: no target_schema gate — the module must not reintroduce the
# public-only guard (``_should_install_public_routines``) that caused #2638.
assert not hasattr(migration, "_should_install_public_routines")
@@ -63,3 +63,23 @@ def test_utf8_stream_info_skips_non_utf8_text():
latin1 = "café".encode("latin-1") # 0xe9, invalid as standalone UTF-8
assert MarkitdownParser._utf8_stream_info(latin1, "a.txt") is None
def test_utf8_stream_info_accepts_non_bytes_buffer():
"""file_data may arrive as a buffer-protocol object that is not a Python
``bytes`` (e.g. a memoryview or a native/Rust-backed buffer) and therefore
has no ``.decode``. The UTF-8 probe must coerce via ``bytes()`` instead of
assuming concrete ``bytes``, else every text file fails to parse with
``'...' object has no attribute 'decode'``.
"""
# memoryview is a buffer-protocol object with no ``.decode`` and, unlike a
# PEP 688 ``__buffer__`` class, ``bytes(memoryview)`` works on every
# supported Python version — a portable stand-in for the native buffer the
# storage layer returns.
buf = memoryview("über".encode("utf-8"))
assert not hasattr(buf, "decode") # precondition: would hit the original AttributeError
info = MarkitdownParser._utf8_stream_info(buf, "a.txt")
assert info is not None
assert info.charset == "utf-8"
@@ -6,6 +6,7 @@ These tests cover the move semantics, lossless revert (incl. entity
associations), edit, the guards, listing, and recall exclusion.
"""
import json
import uuid
from unittest.mock import AsyncMock, patch
@@ -21,21 +22,29 @@ from hindsight_api.engine.retain import embedding_processing
async def _insert_memory(
conn, memory: MemoryEngine, bank_id: str, text: str, fact_type: str = "experience"
conn,
memory: MemoryEngine,
bank_id: str,
text: str,
fact_type: str = "experience",
metadata: dict | None = None,
) -> uuid.UUID:
"""Insert a live memory unit with a real embedding, bypassing the LLM pipeline."""
mem_id = uuid.uuid4()
emb = await embedding_processing.generate_embeddings_batch(memory.embeddings, [text])
await conn.execute(
"""
INSERT INTO memory_units (id, bank_id, text, fact_type, embedding, event_date, created_at, updated_at, consolidated_at)
VALUES ($1, $2, $3, $4, $5::vector, NOW(), NOW(), NOW(), NOW())
INSERT INTO memory_units (
id, bank_id, text, fact_type, embedding, event_date, metadata, created_at, updated_at, consolidated_at
)
VALUES ($1, $2, $3, $4, $5::vector, NOW(), $6::jsonb, NOW(), NOW(), NOW())
""",
mem_id,
bank_id,
text,
fact_type,
str(emb[0]),
json.dumps(metadata or {}),
)
return mem_id
@@ -101,11 +110,12 @@ async def _archive_row(conn, mem_id: uuid.UUID) -> dict | None:
return dict(row) if row else None
async def _archive_has_embedding_column(conn) -> bool:
async def _archive_has_column(conn, column: str) -> bool:
return bool(
await conn.fetchval(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = 'invalidated_memory_units' AND column_name = 'embedding'"
"WHERE table_name = 'invalidated_memory_units' AND column_name = $1",
column,
)
)
@@ -174,9 +184,12 @@ class TestInvalidate:
arch = await _archive_row(conn, m1)
assert arch is not None, "row must be in the archive"
assert arch["invalidation_reason"] == "decommissioned"
assert not await _archive_has_embedding_column(conn), (
assert not await _archive_has_column(conn, "embedding"), (
"archive is cold storage; the schema drops the embedding column (#2209)"
)
assert not await _archive_has_column(conn, "search_vector"), (
"archive is cold storage with no index; the schema drops search_vector (#2503)"
)
assert await _link_count(conn, m1) == 0, "links cascade-pruned on move"
assert str(obs_id) not in await _obs_ids(conn, bank_id), "derived observation removed"
assert await _consolidated_at(conn, m2) is None, "surviving source reset for re-consolidation"
@@ -216,6 +229,10 @@ class TestInvalidate:
assert e1 in await _entity_ids_for(conn, m1), "entity associations restored on revert"
reverted_emb = await conn.fetchval("SELECT embedding FROM memory_units WHERE id = $1", m1)
assert reverted_emb is not None, "embedding recomputed on revert (archive keeps none)"
# Native backend (test default) stores a real tsvector; it must be rebuilt on
# revert so the reverted fact is keyword-searchable again (archive keeps none, #2503).
reverted_sv = await conn.fetchval("SELECT search_vector FROM memory_units WHERE id = $1", m1)
assert reverted_sv is not None, "search_vector recomputed on revert (archive keeps none)"
await memory.delete_bank(bank_id, request_context=request_context)
@@ -261,6 +278,10 @@ class TestEdit:
pool = await memory._get_pool()
async with pool.acquire() as conn:
m1 = await _insert_memory(conn, memory, bank_id, "The assistant visited Paris in 2023.")
await conn.execute(
"UPDATE memory_units SET search_vector = to_tsvector('english'::regconfig, text) WHERE id = $1",
m1,
)
obs_id = await _insert_observation(conn, bank_id, "The assistant went to Paris.", [m1])
with (
@@ -279,9 +300,17 @@ class TestEdit:
assert result["state"] == "valid"
async with pool.acquire() as conn:
assert await _in_live(conn, m1), "edited row stays live"
row = dict(await conn.fetchrow("SELECT text, consolidated_at FROM memory_units WHERE id = $1", m1))
row = dict(
await conn.fetchrow(
"SELECT text, consolidated_at, search_vector::text AS search_vector "
"FROM memory_units WHERE id = $1",
m1,
)
)
assert row["text"] == "The user visited Paris in 2023."
assert row["consolidated_at"] is None, "edited memory re-consolidates"
assert "'assist'" not in row["search_vector"], "old text must not stay in native FTS search_vector"
assert "'user'" in row["search_vector"], "new text must refresh native FTS search_vector"
assert str(obs_id) not in await _obs_ids(conn, bank_id), "stale observation re-derived"
await memory.delete_bank(bank_id, request_context=request_context)
@@ -472,6 +501,47 @@ class TestGuardsAndListing:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_list_and_get_memory_units_include_metadata(
self, memory: MemoryEngine, request_context: RequestContext
):
bank_id = f"test-curation-metadata-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
metadata = {"source": "slack", "channel": "engineering", "thread_id": "T123"}
pool = await memory._get_pool()
async with pool.acquire() as conn:
mem_id = await _insert_memory(conn, memory, bank_id, "Fact with metadata.", metadata=metadata)
live = (await memory.list_memory_units(bank_id, request_context=request_context))["items"]
live_item = next(item for item in live if item["id"] == str(mem_id))
assert live_item["metadata"] == metadata
detail = await memory.get_memory_unit(bank_id, str(mem_id), request_context=request_context)
assert detail is not None
assert detail["metadata"] == metadata
with (
patch.object(memory, "submit_async_consolidation", new=AsyncMock()),
patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()),
):
await memory.update_memory_unit(
bank_id, str(mem_id), state="invalidated", reason="stale", request_context=request_context
)
invalid = (await memory.list_memory_units(bank_id, state="invalidated", request_context=request_context))[
"items"
]
assert invalid[0]["id"] == str(mem_id)
assert invalid[0]["metadata"] == metadata
invalid_detail = await memory.get_memory_unit(bank_id, str(mem_id), request_context=request_context)
assert invalid_detail is not None
assert invalid_detail["state"] == "invalidated"
assert invalid_detail["metadata"] == metadata
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_list_filters_by_document(self, memory: MemoryEngine, request_context: RequestContext):
bank_id = f"test-curation-doc-{uuid.uuid4().hex[:8]}"
@@ -0,0 +1,99 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
def _make_minimax(extra_body=None) -> OpenAICompatibleLLM:
return OpenAICompatibleLLM(
provider="minimax",
api_key="test-key",
base_url="",
model="MiniMax-M3",
extra_body=extra_body,
)
def _text_response(content: str = "ok"):
return SimpleNamespace(
error=None,
usage=None,
choices=[
SimpleNamespace(
finish_reason="stop",
message=SimpleNamespace(content=content, tool_calls=None, refusal=None),
)
],
)
def _tool_response():
tool_call = SimpleNamespace(
id="call_minimax_123",
function=SimpleNamespace(name="recall", arguments='{"query": "Project Rin"}'),
)
return SimpleNamespace(
error=None,
usage=None,
choices=[
SimpleNamespace(
finish_reason="tool_calls",
message=SimpleNamespace(content=None, tool_calls=[tool_call], refusal=None),
)
],
)
@pytest.mark.asyncio
async def test_minimax_call_disables_thinking_by_default():
llm = _make_minimax()
llm._client.chat.completions.create = AsyncMock(return_value=_text_response())
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
await llm.call(messages=[{"role": "user", "content": "hi"}], max_retries=0)
assert llm._client.chat.completions.create.call_args.kwargs["extra_body"] == {"thinking": {"type": "disabled"}}
@pytest.mark.asyncio
async def test_minimax_call_preserves_configured_thinking_extra_body():
llm = _make_minimax(extra_body={"thinking": {"type": "enabled"}, "reasoning_split": True})
llm._client.chat.completions.create = AsyncMock(return_value=_text_response())
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
await llm.call(messages=[{"role": "user", "content": "hi"}], max_retries=0)
assert llm._client.chat.completions.create.call_args.kwargs["extra_body"] == {
"thinking": {"type": "enabled"},
"reasoning_split": True,
}
@pytest.mark.asyncio
async def test_minimax_tool_call_disables_thinking_by_default():
llm = _make_minimax()
llm._client.chat.completions.create = AsyncMock(return_value=_tool_response())
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
await llm.call_with_tools(
messages=[{"role": "user", "content": "Search memory."}],
tools=[
{
"type": "function",
"function": {
"name": "recall",
"description": "Recall memories",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
}
],
max_retries=0,
)
assert llm._client.chat.completions.create.call_args.kwargs["extra_body"] == {"thinking": {"type": "disabled"}}
@@ -10,12 +10,18 @@ Covers:
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from hindsight_api.engine.consolidation.prompts import build_batch_consolidation_prompt
from hindsight_api.engine.prompt_utils import output_language_directive
from hindsight_api.engine.reflect.prompts import build_final_system_prompt
from hindsight_api.engine.retain.fact_extraction import _build_extraction_prompt_and_schema
from hindsight_api.engine.search import retrieval as retrieval_mod
from hindsight_api.engine.search.retrieval import tokenize_query
from hindsight_api.engine.sql.postgresql import PostgreSQLDialect
def _baseline_config() -> MagicMock:
@@ -165,3 +171,75 @@ def test_configurable_bm25_language_migration_chains_off_head():
src = target.read_text()
assert 'revision: str = "p4q5r6s7t8u9"' in src
assert 'down_revision: str | Sequence[str] | None = "86f7a033d372"' in src
# ---------------------------------------------------------------------------
# BM25 query term cap
# ---------------------------------------------------------------------------
def test_postgresql_native_bm25_caps_raw_terms_preserving_order():
query = "Alpha beta alpha, gamma delta beta epsilon"
tokens = tokenize_query(query)
assert PostgreSQLDialect().prepare_bm25_text(tokens, query, max_query_terms=3) == "alpha | beta | alpha"
def test_postgresql_native_bm25_zero_cap_keeps_existing_unlimited_behavior():
query = "Alpha beta alpha"
tokens = tokenize_query(query)
assert PostgreSQLDialect().prepare_bm25_text(tokens, query, max_query_terms=0) == "alpha | beta | alpha"
def test_postgresql_extension_bm25_keeps_raw_query_text():
query = "Alpha beta alpha, gamma delta beta epsilon"
tokens = tokenize_query(query)
assert (
PostgreSQLDialect().prepare_bm25_text(tokens, query, text_search_extension="vchord", max_query_terms=3) == query
)
@pytest.mark.asyncio
async def test_combined_retrieval_uses_default_bm25_cap_for_legacy_config(monkeypatch):
class FakeDialect:
max_query_terms: int | None = None
def build_semantic_arm(self, **kwargs):
return "SELECT 'semantic' AS source"
def build_bm25_arm(self, **kwargs):
return "SELECT 'bm25' AS source"
def prepare_bm25_text(self, tokens, query_text, *, text_search_extension="native", max_query_terms=None):
self.max_query_terms = max_query_terms
return " | ".join(tokens)
class FakeConn:
backend_type = "postgresql"
async def fetch(self, query, *params):
return []
fake_dialect = FakeDialect()
legacy_config = SimpleNamespace(
semantic_min_similarity=0.0,
bm25_min_score=0.0,
text_search_extension="native",
text_search_extension_native_language="english",
)
monkeypatch.setattr(retrieval_mod, "get_config", lambda: legacy_config)
monkeypatch.setattr(retrieval_mod, "create_sql_dialect", lambda backend: fake_dialect)
result = await retrieval_mod.retrieve_semantic_bm25_combined(
FakeConn(),
"[0.0]",
"alpha beta",
"bank-1",
["observation"],
5,
)
assert result == {"observation": ([], [])}
assert fake_dialect.max_query_terms == 0
@@ -0,0 +1,48 @@
"""Regression: observation_history write for a since-deleted observation is skipped.
Under parallel (or same-batch delete-then-update) consolidation, one write path
can remove an observation from ``memory_units`` before another writes its
``observation_history`` snapshot. The INSERT then trips
``observation_history_observation_id_fkey``. Because consolidation runs in
autocommit (no enclosing transaction), catching the FK violation and skipping
the best-effort history row is safe the connection stays usable and the
current observation state remains the source of truth.
Regression for #2597 / #2506: before the fix this raised
``asyncpg.ForeignKeyViolationError`` and failed the whole consolidation task.
"""
import uuid
import pytest
from hindsight_api.engine.consolidation.consolidator import (
_append_observation_history,
_ObservationHistorySnapshot,
)
from hindsight_api.engine.db_utils import acquire_with_retry
@pytest.mark.asyncio
async def test_append_history_for_missing_observation_is_skipped(memory, request_context):
"""A history write targeting an absent observation is skipped, not fatal."""
bank_id = f"test-obs-history-fk-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id, request_context=request_context)
snapshot = _ObservationHistorySnapshot(
previous_text="old",
previous_tags=[],
previous_occurred_start=None,
previous_occurred_end=None,
previous_mentioned_at=None,
new_source_memory_ids=[],
)
# Never inserted into memory_units, so the FK target is absent.
missing_observation_id = str(uuid.uuid4())
pool = await memory._get_pool()
async with acquire_with_retry(pool) as conn:
# Pre-fix this raised asyncpg.ForeignKeyViolationError; the fix skips it.
await _append_observation_history(conn, bank_id, missing_observation_id, snapshot, max_entries=10)
# Autocommit: the failed INSERT did not poison the connection.
assert await conn.fetchval("SELECT 1") == 1
@@ -1,10 +1,13 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, patch
import pytest
from pydantic import BaseModel
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM, ProviderResponseError
from hindsight_api.engine.providers.openai_compatible_llm import (
OpenAICompatibleLLM,
ProviderResponseError,
)
class SimpleJsonResponse(BaseModel):
@@ -70,6 +73,42 @@ async def test_json_object_call_strips_gemma_thought_tags_before_parsing():
assert result.ok is True
@pytest.mark.asyncio
@pytest.mark.parametrize("model", ["qwen/qwen3.6-35b-a3b", "openai/gpt-oss-120b"])
async def test_openrouter_verification_uses_larger_reasoning_safe_budget(model: str):
llm = OpenAICompatibleLLM(
provider="openrouter",
api_key="test-key",
base_url="",
model=model,
)
create = AsyncMock(return_value=_response(content="ok"))
llm._client.chat.completions.create = create
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
await llm.verify_connection()
sent = create.call_args.kwargs
assert sent["model"] == model
assert sent["messages"] == [{"role": "user", "content": "Say 'ok'"}]
assert sent["max_tokens"] == 512
assert "max_completion_tokens" not in sent
@pytest.mark.asyncio
async def test_verification_uses_larger_budget_for_other_compatible_gateways():
llm = _llm()
create = AsyncMock(return_value=_response(content="ok"))
llm._client.chat.completions.create = create
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
await llm.verify_connection()
sent = create.call_args.kwargs
assert sent["max_tokens"] == 512
assert "max_completion_tokens" not in sent
@pytest.mark.asyncio
async def test_error_payload_with_no_choices_raises_clear_provider_error_without_retry():
llm = _llm()
@@ -0,0 +1,32 @@
from pathlib import Path
MIGRATION = (
Path(__file__).resolve().parent.parent
/ "hindsight_api"
/ "alembic"
/ "versions"
/ "a8c1e4f7b0d3_add_operation_retention_indexes.py"
)
def test_operation_retention_migration_follows_current_head_and_covers_both_dialects():
source = MIGRATION.read_text()
assert 'down_revision: str | Sequence[str] | None = "f2a4b6c8d0e2"' in source
assert "def _pg_upgrade()" in source
assert "def _oracle_upgrade()" in source
assert "run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)" in source
def test_operation_retention_migration_adds_cleanup_and_newest_first_indexes_with_downgrade():
source = MIGRATION.read_text()
assert "idx_async_operations_terminal_cleanup" in source
assert "(updated_at, operation_id)" in source
assert "WHERE status IN ('completed', 'failed', 'cancelled')" in source
assert "(updated_at, operation_id, status)" in source
assert "idx_async_operations_bank_created_desc" in source
assert "(bank_id, created_at DESC)" in source
assert "def _pg_downgrade()" in source
assert "def _oracle_downgrade()" in source
assert source.count("DROP INDEX") >= 4
@@ -8,6 +8,7 @@ Regression tests:
- Retry now accepts both 'failed' and 'cancelled' operations.
"""
import asyncio
import uuid
from datetime import datetime
@@ -206,6 +207,58 @@ async def test_retry_cancelled_operation(api_client, memory, test_bank_id):
assert response.json()["status"] == "pending"
@pytest.mark.asyncio
@pytest.mark.parametrize("terminal_status", ["failed", "cancelled"])
async def test_fresh_terminal_operation_keeps_payload_and_remains_retryable(
api_client, memory, test_bank_id, terminal_status
):
"""Retry depends on the original task payload remaining present throughout retention."""
pool = memory._pool
await _ensure_bank(pool, test_bank_id)
op_id = await _insert_operation(pool, test_bank_id, terminal_status)
raw_before = await pool.fetchrow(
"SELECT status, task_payload FROM async_operations WHERE operation_id = $1",
uuid.UUID(op_id),
)
assert raw_before["status"] == terminal_status
assert raw_before["task_payload"] is not None
response = await api_client.post(f"/v1/default/banks/{test_bank_id}/operations/{op_id}/retry")
assert response.status_code == 200
raw_after = await pool.fetchrow(
"SELECT status, task_payload FROM async_operations WHERE operation_id = $1",
uuid.UUID(op_id),
)
assert raw_after["status"] == "pending"
assert raw_after["task_payload"] is not None
@pytest.mark.asyncio
async def test_retry_does_not_acknowledge_operation_deleted_by_cleanup(api_client, memory, test_bank_id):
"""A pruning winner must turn a concurrent retry into 404, never false 200."""
pool = memory._pool
await _ensure_bank(pool, test_bank_id)
op_id = await _insert_operation(pool, test_bank_id, "failed")
op_uuid = uuid.UUID(op_id)
async with pool.acquire() as conn:
async with conn.transaction():
await conn.fetchrow(
"SELECT operation_id FROM async_operations WHERE operation_id = $1 FOR UPDATE",
op_uuid,
)
retry_task = asyncio.create_task(
api_client.post(f"/v1/default/banks/{test_bank_id}/operations/{op_id}/retry")
)
await asyncio.sleep(0)
await conn.execute("DELETE FROM async_operations WHERE operation_id = $1", op_uuid)
response = await retry_task
assert response.status_code == 404
@pytest.mark.asyncio
async def test_retry_rejects_non_retriable_statuses(api_client, memory, test_bank_id):
"""POST /operations/{id}/retry should reject pending, processing, and completed operations."""
@@ -649,6 +649,71 @@ def test_query_analyzer_chinese_rolling_windows(query_analyzer, query, start, en
assert analysis.temporal_constraint.end_date.date() == end.date()
def test_query_analyzer_chinese_rolling_year_underflow_returns_no_constraint(query_analyzer):
"""Impossible Chinese rolling windows should not block retrieval."""
reference_date = datetime(1, 1, 15, 12, 0, 0)
analysis = query_analyzer.analyze("过去一年做了什么", reference_date)
assert analysis.temporal_constraint is None
@pytest.mark.parametrize(
("query", "reference_date"),
[
("去年今天做了什么", datetime(1, 1, 15, 12, 0, 0)),
("大前年今天做了什么", datetime(1, 1, 15, 12, 0, 0)),
("去年昨天做了什么", datetime(1, 1, 15, 12, 0, 0)),
("昨晚做了什么", datetime(1, 1, 1, 12, 0, 0)),
("前晚做了什么", datetime(1, 1, 1, 12, 0, 0)),
],
)
def test_query_analyzer_chinese_fixed_day_underflow_returns_no_constraint(
query_analyzer,
query,
reference_date,
):
"""Impossible Chinese fixed-day shifts should not block retrieval."""
analysis = query_analyzer.analyze(query, reference_date)
assert analysis.temporal_constraint is None
def test_query_analyzer_chinese_rolling_year_low_safe_boundary_still_extracts(query_analyzer):
"""Valid low-year Chinese rolling windows should keep their constraint."""
reference_date = datetime(2, 1, 15, 12, 0, 0)
analysis = query_analyzer.analyze("过去一年做了什么", reference_date)
assert analysis.temporal_constraint is not None
assert analysis.temporal_constraint.start_date.date() == datetime(1, 1, 15).date()
assert analysis.temporal_constraint.end_date.date() == datetime(2, 1, 15).date()
def test_query_analyzer_chinese_fixed_day_low_safe_boundary_still_extracts(query_analyzer):
"""Valid low-year Chinese fixed-day shifts should keep their constraint."""
reference_date = datetime(2, 1, 15, 12, 0, 0)
analysis = query_analyzer.analyze("去年今天做了什么", reference_date)
assert analysis.temporal_constraint is not None
assert analysis.temporal_constraint.start_date.date() == datetime(1, 1, 15).date()
assert analysis.temporal_constraint.end_date.date() == datetime(1, 1, 15).date()
def test_query_analyzer_period_valueerror_still_surfaces(query_analyzer, monkeypatch):
"""Only impossible Chinese date shifts degrade to no constraint."""
from hindsight_api.engine import query_analyzer as query_analyzer_module
def fail_extract_period(query, reference_date):
raise ValueError("synthetic extraction bug")
monkeypatch.setattr(query_analyzer_module, "extract_period", fail_extract_period)
with pytest.raises(ValueError, match="synthetic extraction bug"):
query_analyzer.analyze("past week", datetime(2025, 1, 15, 12, 0, 0))
@pytest.mark.parametrize("query", ["三两天前提到的菜是什么"])
def test_query_analyzer_chinese_exact_relative_boundaries(query_analyzer, query):
"""Test malformed Chinese numerals are not truncated into exact relative rules."""
+16 -1
View File
@@ -65,12 +65,14 @@ class TestRecallConfigFields:
"""Hierarchical config fields for internal recall."""
def test_fields_exist_on_dataclass(self):
from hindsight_api.config import HindsightConfig
from hindsight_api.config import DEFAULT_BM25_MAX_QUERY_TERMS, HindsightConfig
names = {f.name for f in dataclasses.fields(HindsightConfig)}
assert "recall_include_chunks" in names
assert "recall_max_tokens" in names
assert "recall_chunks_max_tokens" in names
assert "bm25_max_query_terms" in names
assert HindsightConfig.__dataclass_fields__["bm25_max_query_terms"].default == DEFAULT_BM25_MAX_QUERY_TERMS
def test_fields_are_configurable(self):
from hindsight_api.config import HindsightConfig
@@ -82,6 +84,7 @@ class TestRecallConfigFields:
def test_default_values(self):
from hindsight_api.config import (
DEFAULT_BM25_MAX_QUERY_TERMS,
DEFAULT_RECALL_CHUNKS_MAX_TOKENS,
DEFAULT_RECALL_INCLUDE_CHUNKS,
DEFAULT_RECALL_MAX_TOKENS,
@@ -90,9 +93,11 @@ class TestRecallConfigFields:
assert DEFAULT_RECALL_INCLUDE_CHUNKS is True
assert DEFAULT_RECALL_MAX_TOKENS == 2048
assert DEFAULT_RECALL_CHUNKS_MAX_TOKENS == 1000
assert DEFAULT_BM25_MAX_QUERY_TERMS == 0
def test_env_var_constants(self):
from hindsight_api.config import (
ENV_BM25_MAX_QUERY_TERMS,
ENV_RECALL_CHUNKS_MAX_TOKENS,
ENV_RECALL_INCLUDE_CHUNKS,
ENV_RECALL_MAX_TOKENS,
@@ -101,6 +106,7 @@ class TestRecallConfigFields:
assert ENV_RECALL_INCLUDE_CHUNKS == "HINDSIGHT_API_RECALL_INCLUDE_CHUNKS"
assert ENV_RECALL_MAX_TOKENS == "HINDSIGHT_API_RECALL_MAX_TOKENS"
assert ENV_RECALL_CHUNKS_MAX_TOKENS == "HINDSIGHT_API_RECALL_CHUNKS_MAX_TOKENS"
assert ENV_BM25_MAX_QUERY_TERMS == "HINDSIGHT_API_BM25_MAX_QUERY_TERMS"
@patch.dict(
"os.environ",
@@ -108,6 +114,7 @@ class TestRecallConfigFields:
"HINDSIGHT_API_RECALL_INCLUDE_CHUNKS": "false",
"HINDSIGHT_API_RECALL_MAX_TOKENS": "777",
"HINDSIGHT_API_RECALL_CHUNKS_MAX_TOKENS": "333",
"HINDSIGHT_API_BM25_MAX_QUERY_TERMS": "24",
},
)
def test_from_env_reads_overrides(self):
@@ -117,6 +124,14 @@ class TestRecallConfigFields:
assert config.recall_include_chunks is False
assert config.recall_max_tokens == 777
assert config.recall_chunks_max_tokens == 333
assert config.bm25_max_query_terms == 24
@patch.dict("os.environ", {"HINDSIGHT_API_BM25_MAX_QUERY_TERMS": "-1"})
def test_from_env_rejects_negative_bm25_max_query_terms(self):
from hindsight_api.config import HindsightConfig
with pytest.raises(ValueError, match="HINDSIGHT_API_BM25_MAX_QUERY_TERMS must be >= 0"):
HindsightConfig.from_env()
class TestMentalModelTriggerRecallFields:
@@ -5,6 +5,7 @@ import uuid
import pytest
from hindsight_api.engine.reflect.agent import _execute_tool, _summarize_input
from hindsight_api.engine.reflect.tools import _document_metadata_from_retain_params, tool_expand
@@ -118,3 +119,203 @@ def test_document_metadata_from_retain_params_accepts_json_strings() -> None:
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
async def _unexpected_tool_call(*_args):
raise AssertionError("unexpected tool callback")
@pytest.mark.asyncio
async def test_execute_tool_treats_string_none_max_tokens_as_default() -> None:
"""Some providers emit JSON null as the string "None" in tool calls."""
captured: dict[str, object] = {}
async def search_observations(query: str, max_tokens: int) -> dict[str, object]:
captured["query"] = query
captured["max_tokens"] = max_tokens
return {"observations": []}
result = await _execute_tool(
"search_observations",
{"query": "deployment failures", "max_tokens": "None"},
_unexpected_tool_call,
search_observations,
_unexpected_tool_call,
_unexpected_tool_call,
)
assert result == {"observations": []}
assert captured == {"query": "deployment failures", "max_tokens": 5000}
@pytest.mark.asyncio
@pytest.mark.parametrize("bad_limit", ["bogus", float("inf")])
async def test_execute_tool_returns_error_for_invalid_integer_limit(bad_limit) -> None:
"""Malformed integer limits should be a tool error, not an exception."""
result = await _execute_tool(
"search_observations",
{"query": "deployment failures", "max_tokens": bad_limit},
_unexpected_tool_call,
_unexpected_tool_call,
_unexpected_tool_call,
_unexpected_tool_call,
)
assert result == {"error": "max_tokens must be an integer or null-like value"}
@pytest.mark.asyncio
async def test_execute_tool_preserves_search_mental_models_max_results_values() -> None:
"""Only token limits have minimums; max_results keeps the prior pass-through behavior."""
captured: dict[str, object] = {}
async def search_mental_models(query: str, max_results: int) -> dict[str, object]:
captured["query"] = query
captured["max_results"] = max_results
return {"mental_models": []}
result = await _execute_tool(
"search_mental_models",
{"query": "deployment failures", "max_results": -1},
search_mental_models,
_unexpected_tool_call,
_unexpected_tool_call,
_unexpected_tool_call,
)
assert result == {"mental_models": []}
assert captured == {"query": "deployment failures", "max_results": -1}
@pytest.mark.asyncio
async def test_execute_tool_preserves_falsey_values_as_default_sentinel() -> None:
"""The old `or default` behavior treated falsey limit values as omitted."""
captured: dict[str, object] = {}
async def search_mental_models(query: str, max_results: int) -> dict[str, object]:
captured["mental_model_max_results"] = max_results
return {"mental_models": []}
async def search_observations(query: str, max_tokens: int) -> dict[str, object]:
captured["observation_max_tokens"] = max_tokens
return {"observations": []}
async def recall(query: str, max_tokens: int, max_chunk_tokens: int) -> dict[str, object]:
captured["recall_max_tokens"] = max_tokens
captured["recall_max_chunk_tokens"] = max_chunk_tokens
return {"memories": []}
await _execute_tool(
"search_mental_models",
{"query": "deployment failures", "max_results": 0},
search_mental_models,
search_observations,
recall,
_unexpected_tool_call,
)
await _execute_tool(
"search_observations",
{"query": "deployment failures", "max_tokens": 0},
search_mental_models,
search_observations,
recall,
_unexpected_tool_call,
)
await _execute_tool(
"recall",
{"query": "deployment failures", "max_tokens": 0, "max_chunk_tokens": 0},
search_mental_models,
search_observations,
recall,
_unexpected_tool_call,
)
await _execute_tool(
"search_observations",
{"query": "deployment failures", "max_tokens": False},
search_mental_models,
search_observations,
recall,
_unexpected_tool_call,
)
await _execute_tool(
"search_observations",
{"query": "deployment failures", "max_tokens": []},
search_mental_models,
search_observations,
recall,
_unexpected_tool_call,
)
await _execute_tool(
"search_observations",
{"query": "deployment failures", "max_tokens": {}},
search_mental_models,
search_observations,
recall,
_unexpected_tool_call,
)
assert captured == {
"mental_model_max_results": 5,
"observation_max_tokens": 5000,
"recall_max_tokens": 2048,
"recall_max_chunk_tokens": 1000,
}
@pytest.mark.asyncio
async def test_execute_tool_treats_null_like_recall_limits_as_defaults() -> None:
"""Null-like string limits should not crash reflect tool execution."""
captured: dict[str, object] = {}
async def recall(query: str, max_tokens: int, max_chunk_tokens: int) -> dict[str, object]:
captured["query"] = query
captured["max_tokens"] = max_tokens
captured["max_chunk_tokens"] = max_chunk_tokens
return {"memories": []}
result = await _execute_tool(
"recall",
{"query": "incident notes", "max_tokens": "null", "max_chunk_tokens": ""},
_unexpected_tool_call,
_unexpected_tool_call,
recall,
_unexpected_tool_call,
)
assert result == {"memories": []}
assert captured == {"query": "incident notes", "max_tokens": 2048, "max_chunk_tokens": 1000}
def test_summarize_input_never_raises_for_invalid_tool_limit_strings() -> None:
"""Trace logging must not turn a recoverable tool error into HTTP 500."""
assert (
_summarize_input(
"search_observations",
{"query": "deployment failures", "max_tokens": "None"},
)
== "(query='deployment failures', max_tokens=5000)"
)
assert (
_summarize_input(
"recall",
{"query": "deployment failures", "max_tokens": "bogus", "max_chunk_tokens": "null"},
)
== "(query='deployment failures', max_tokens=invalid:'bogus', max_chunk_tokens=1000)"
)
assert (
_summarize_input(
"search_observations",
{"query": "deployment failures", "max_tokens": float("inf")},
)
== "(query='deployment failures', max_tokens=invalid:inf)"
)
assert (
_summarize_input(
"search_observations",
{"query": None, "max_tokens": "None"},
)
== "(query='', max_tokens=5000)"
)
+3 -5
View File
@@ -1923,7 +1923,7 @@ async def test_causal_links_creation(memory, request_context):
"""
Test that causal links are created between facts with causal relationships.
Causal links connect facts where one causes, enables, or prevents another.
Retain represents causal links with the canonical ``caused_by`` type.
Note: This depends on LLM extracting causal relationships, which may be non-deterministic.
"""
bank_id = f"test_causal_links_{datetime.now(timezone.utc).timestamp()}"
@@ -1955,7 +1955,7 @@ async def test_causal_links_creation(memory, request_context):
SELECT from_unit_id, to_unit_id, link_type, weight
FROM memory_links
WHERE from_unit_id::text = ANY($1)
AND link_type IN ('causes', 'caused_by', 'enables', 'prevents')
AND link_type = 'caused_by'
ORDER BY link_type, weight DESC
""",
unit_ids,
@@ -1974,9 +1974,7 @@ async def test_causal_links_creation(memory, request_context):
logger.info(
f" Link: {from_id[:8]}... -> {to_id[:8]}... ({link_type}, weight: {link['weight']:.2f})"
)
assert link["link_type"] in ["causes", "caused_by", "enables", "prevents"], (
f"Causal link type must be valid, got '{link['link_type']}'"
)
assert link["link_type"] == "caused_by"
assert 0.0 <= link["weight"] <= 1.0, "Weight should be between 0 and 1"
logger.info("Causal links created successfully:")
@@ -1,6 +1,6 @@
"""Tests for _strip_code_fences helper in OpenAI-compatible LLM provider."""
import pytest
import json
from hindsight_api.engine.providers.openai_compatible_llm import _strip_code_fences
@@ -36,6 +36,22 @@ class TestStripCodeFences:
result = _strip_code_fences(content)
assert '{"facts": []}' in result
def test_inner_backticks_preserved(self):
"""Inner triple-backticks inside a JSON string value must not truncate the JSON.
Regression for the fact-extraction case where an extracted fact describes
code-fence behavior, so the JSON payload itself contains a literal
```` ```json ```` the old split-based stripper matched that inner
occurrence and cut the JSON mid-string.
"""
import json
content = '```json\n{"facts": [{"what": "the model wraps output in ```json fences"}]}\n```'
result = _strip_code_fences(content)
assert result == '{"facts": [{"what": "the model wraps output in ```json fences"}]}'
parsed = json.loads(result)
assert parsed["facts"][0]["what"] == "the model wraps output in ```json fences"
def test_no_fences_no_change(self):
"""Content without any backticks passes through."""
content = "Just some text without fences"
@@ -53,12 +69,25 @@ class TestStripCodeFences:
assert '"line2"' in result
assert "```" not in result
def test_malformed_fence_returns_original(self):
"""Malformed fences (missing closing) return something parseable."""
def test_missing_closing_fence_recovers_json(self):
"""A fence with no closing ``` still recovers the JSON via the outer-span fallback."""
content = '```json\n{"facts": []}'
result = _strip_code_fences(content)
# Should attempt to strip and return best effort
assert json.loads(result) == {"facts": []}
def test_prose_wrapped_json_recovered(self):
"""JSON surrounded by prose (no usable fence) is recovered by the fallback."""
content = 'Sure! Here is the result:\n{"facts": [{"what": "x"}]}\nLet me know if that helps.'
result = _strip_code_fences(content)
assert json.loads(result) == {"facts": [{"what": "x"}]}
def test_non_json_fence_left_for_retry(self):
"""A fenced block that is not JSON yields no valid candidate; content is returned unchanged."""
content = "```\nnot json at all\n```"
result = _strip_code_fences(content)
# No parseable JSON anywhere -> caller sees the stripped body (still a str), never crashes.
assert isinstance(result, str)
assert "not json at all" in result
def test_minimax_style_response(self):
"""Real-world MiniMax response format."""
@@ -10,10 +10,12 @@ to a full scan + disk-spilling sort while dropping the most relevant in-window m
These are pure mechanics (no LLM), so they assert directly.
"""
from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta
import pytest
import hindsight_api.engine.search.retrieval as retrieval_module
from hindsight_api.engine.search.retrieval import _select_with_temporal_coverage, retrieve_temporal_combined
from hindsight_api.engine.task_backend import fq_table
@@ -167,3 +169,47 @@ async def test_temporal_recall_covers_window_range(memory):
assert {apr, jul, octo} <= ids
selected_months = {r.mentioned_at.month for r in results["world"] if r.mentioned_at}
assert len(selected_months) >= 3
@pytest.mark.asyncio
async def test_min_semantic_does_not_tighten_temporal_seed_threshold(monkeypatch):
"""min_scores.semantic filters the semantic arm, not temporal entry-point seeds."""
start = datetime(2025, 1, 1, tzinfo=UTC)
end = datetime(2025, 2, 1, tzinfo=UTC)
temporal_thresholds: list[float] = []
@asynccontextmanager
async def fake_acquire_with_retry(pool):
yield object()
async def fake_semantic_bm25_combined(*args, **kwargs):
return {"world": ([], [])}
async def fake_temporal_combined(*args, **kwargs):
temporal_thresholds.append(kwargs["semantic_threshold"])
return {"world": []}
class FakeGraphRetriever:
async def retrieve(self, **kwargs):
return [], None
monkeypatch.setattr(retrieval_module, "acquire_with_retry", fake_acquire_with_retry)
monkeypatch.setattr(retrieval_module, "retrieve_semantic_bm25_combined", fake_semantic_bm25_combined)
monkeypatch.setattr(retrieval_module, "retrieve_temporal_combined", fake_temporal_combined)
monkeypatch.setattr(
"hindsight_api.engine.search.temporal_extraction.extract_temporal_constraint",
lambda *args, **kwargs: (start, end),
)
await retrieval_module.retrieve_all_fact_types_parallel(
object(),
query_text="what happened in January?",
query_embedding_str=_QUERY,
bank_id="test_temporal_min_semantic_decoupling",
fact_types=["world"],
thinking_budget=10,
graph_retriever=FakeGraphRetriever(),
min_semantic=0.5,
)
assert temporal_thresholds == [0.1]
+784 -2
View File
@@ -13,6 +13,9 @@ Tests cover:
import asyncio
import json
import uuid
from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -92,12 +95,13 @@ class TestWorkerOperationMetrics:
poller = WorkerPoller(backend=MagicMock(), worker_id="w-test", executor=executor)
# Stub terminal-state handlers so _execute_task_inner never touches the DB.
poller._mark_completed = AsyncMock()
poller._mark_failed = AsyncMock()
poller._defer_operation = AsyncMock()
poller._schedule_retry = AsyncMock()
return poller
async def _run(self, executor, task_type="batch_retain"):
async def _run_with_poller(self, executor, task_type="batch_retain"):
from hindsight_api.worker.poller import ClaimedTask
poller = self._make_poller(executor)
@@ -109,6 +113,10 @@ class TestWorkerOperationMetrics:
collector = MagicMock()
with patch("hindsight_api.worker.poller.get_metrics_collector", return_value=collector):
await poller._execute_task_inner(task)
return collector, poller, task
async def _run(self, executor, task_type="batch_retain"):
collector, _poller, _task = await self._run_with_poller(executor, task_type=task_type)
return collector
@pytest.mark.asyncio
@@ -123,7 +131,8 @@ class TestWorkerOperationMetrics:
hindsight_async_operations{status="failed"} gauge, which reads each
operation's final DB status.
"""
collector = await self._run(AsyncMock()) # executor returns normally
collector, poller, task = await self._run_with_poller(AsyncMock()) # executor returns normally
poller._mark_completed.assert_awaited_once_with(task.operation_id, task.schema)
collector.record_operation_result.assert_called_once()
call = collector.record_operation_result.call_args
assert call.args[0] == "retain" # batch_retain normalised
@@ -164,6 +173,69 @@ class TestWorkerOperationMetrics:
collector.record_operation_result.assert_not_called()
class _AsyncContext:
def __init__(self, value):
self.value = value
async def __aenter__(self):
return self.value
async def __aexit__(self, exc_type, exc, tb):
return False
class _CompletionConn:
def __init__(self, execute_result):
self.execute_result = execute_result
self.execute_calls = []
def transaction(self):
return _AsyncContext(self)
async def execute(self, query, *args):
self.execute_calls.append((query, args))
return self.execute_result
class _CompletionBackend:
def __init__(self, conn):
self.conn = conn
def acquire(self):
return _AsyncContext(self.conn)
class TestWorkerMarkCompleted:
def _make_poller(self, execute_result):
from hindsight_api.worker import WorkerPoller
conn = _CompletionConn(execute_result)
poller = WorkerPoller(backend=_CompletionBackend(conn), worker_id="w-test", executor=AsyncMock())
poller._maybe_update_parent_operation = AsyncMock()
return poller, conn
@pytest.mark.asyncio
async def test_mark_completed_updates_only_processing_rows_and_updates_parent(self):
poller, conn = self._make_poller("UPDATE 1")
await poller._mark_completed("op-1", schema=None)
query, args = conn.execute_calls[0]
assert "status = 'completed'" in query
assert "WHERE operation_id = $1 AND status = 'processing'" in query
assert args == ("op-1",)
poller._maybe_update_parent_operation.assert_awaited_once_with("op-1", None, conn)
@pytest.mark.asyncio
async def test_mark_completed_does_not_overwrite_terminal_rows(self):
poller, conn = self._make_poller("UPDATE 0")
await poller._mark_completed("op-1", schema=None)
assert conn.execute_calls
poller._maybe_update_parent_operation.assert_not_awaited()
def test_all_operation_types_have_slot_reservation_config():
"""Every operation_type used in memory_engine must be listed in
WORKER_SLOT_RESERVATION_TYPES so it can be reserved via env var.
@@ -3747,3 +3819,713 @@ class TestConsolidationBankPriority:
high_pending_op,
)
assert row["status"] == "pending"
class TestTerminalOperationRetention:
"""Terminal operation rows remain intact until bounded worker cleanup expires them."""
@staticmethod
async def _insert_operation(
pool,
*,
operation_id: uuid.UUID,
bank_id: str,
status: str,
updated_at: datetime,
marker: str,
) -> None:
await pool.execute(
"""
INSERT INTO async_operations
(operation_id, bank_id, operation_type, status, task_payload, result_metadata,
created_at, updated_at)
VALUES ($1, $2, 'retain', $3, $4::jsonb, $5::jsonb, $6, $6)
""",
operation_id,
bank_id,
status,
json.dumps({"marker": marker, "contents": ["payload must survive until expiry"]}),
json.dumps({"marker": marker, "debug": "metadata must share the same TTL"}),
updated_at,
)
@pytest.mark.asyncio
async def test_prune_terminal_operations_is_bounded_oldest_first_and_preserves_live_rows(
self, pool, backend, clean_operations
):
bank_id = f"test-worker-retention-{uuid.uuid4().hex[:8]}"
await _ensure_bank(pool, bank_id)
# Keep this cutoff far outside normal test data so the schema-wide
# cleanup method cannot consume another concurrently running test's rows.
cutoff = datetime(2000, 1, 1, tzinfo=UTC)
id_prefix = uuid.uuid4().int & ~0xFFFF
oldest_id = uuid.UUID(int=id_prefix + 1)
tied_first_id = uuid.UUID(int=id_prefix + 2)
tied_second_id = uuid.UUID(int=id_prefix + 3)
newer_expired_id = uuid.UUID(int=id_prefix + 4)
fresh_failed_id = uuid.UUID(int=id_prefix + 5)
fresh_cancelled_id = uuid.UUID(int=id_prefix + 6)
old_pending_id = uuid.UUID(int=id_prefix + 7)
old_processing_id = uuid.UUID(int=id_prefix + 8)
rows = [
(oldest_id, "completed", cutoff - timedelta(days=4), "expired-completed"),
(tied_first_id, "failed", cutoff - timedelta(days=3), "expired-failed"),
(tied_second_id, "cancelled", cutoff - timedelta(days=3), "expired-cancelled"),
(newer_expired_id, "completed", cutoff - timedelta(days=2), "expired-newer"),
(fresh_failed_id, "failed", cutoff + timedelta(days=1), "fresh-failed"),
(fresh_cancelled_id, "cancelled", cutoff + timedelta(days=1), "fresh-cancelled"),
(old_pending_id, "pending", cutoff - timedelta(days=10), "old-pending"),
(old_processing_id, "processing", cutoff - timedelta(days=10), "old-processing"),
]
for operation_id, status, updated_at, marker in rows:
await self._insert_operation(
pool,
operation_id=operation_id,
bank_id=bank_id,
status=status,
updated_at=updated_at,
marker=marker,
)
raw_before = await pool.fetch(
"""
SELECT operation_id, task_payload->>'marker' AS payload_marker,
result_metadata->>'marker' AS metadata_marker
FROM async_operations
WHERE bank_id = $1
""",
bank_id,
)
assert {row["payload_marker"] for row in raw_before} == {marker for *_, marker in rows}
assert {row["metadata_marker"] for row in raw_before} == {marker for *_, marker in rows}
async with backend.acquire() as conn:
async with conn.transaction():
deleted = await backend.ops.prune_terminal_operations(
conn,
"async_operations",
cutoff,
batch_size=2,
)
assert deleted == 2
raw_after_first_batch = await pool.fetch(
"""
SELECT operation_id, status, task_payload->>'marker' AS payload_marker,
result_metadata->>'marker' AS metadata_marker
FROM async_operations
WHERE bank_id = $1
ORDER BY operation_id
""",
bank_id,
)
remaining_ids = {row["operation_id"] for row in raw_after_first_batch}
assert oldest_id not in remaining_ids
assert tied_first_id not in remaining_ids
assert tied_second_id in remaining_ids
assert newer_expired_id in remaining_ids
async with backend.acquire() as conn:
async with conn.transaction():
deleted = await backend.ops.prune_terminal_operations(
conn,
"async_operations",
cutoff,
batch_size=100,
)
assert deleted == 2
raw_final = await pool.fetch(
"""
SELECT operation_id, status, task_payload->>'marker' AS payload_marker,
result_metadata->>'marker' AS metadata_marker
FROM async_operations
WHERE bank_id = $1
ORDER BY operation_id
""",
bank_id,
)
assert {(row["status"], row["payload_marker"], row["metadata_marker"]) for row in raw_final} == {
("failed", "fresh-failed", "fresh-failed"),
("cancelled", "fresh-cancelled", "fresh-cancelled"),
("pending", "old-pending", "old-pending"),
("processing", "old-processing", "old-processing"),
}
@pytest.mark.asyncio
async def test_concurrent_pruning_is_safe_and_idempotent(self, pool, backend, clean_operations):
bank_id = f"test-worker-retention-concurrent-{uuid.uuid4().hex[:8]}"
await _ensure_bank(pool, bank_id)
cutoff = datetime(2000, 1, 1, tzinfo=UTC)
operation_ids = [uuid.uuid4() for _ in range(25)]
for index, operation_id in enumerate(operation_ids):
await self._insert_operation(
pool,
operation_id=operation_id,
bank_id=bank_id,
status=("completed", "failed", "cancelled")[index % 3],
updated_at=cutoff - timedelta(minutes=25 - index),
marker=f"expired-{index}",
)
async def prune(batch_size: int) -> int:
async with backend.acquire() as conn:
async with conn.transaction():
return await backend.ops.prune_terminal_operations(
conn,
"async_operations",
cutoff,
batch_size=batch_size,
)
first_counts = await asyncio.gather(prune(10), prune(10))
assert sum(first_counts) == 20
assert await pool.fetchval("SELECT COUNT(*) FROM async_operations WHERE bank_id = $1", bank_id) == 5
second_counts = await asyncio.gather(prune(10), prune(10))
assert sum(second_counts) == 5
assert await pool.fetchval("SELECT COUNT(*) FROM async_operations WHERE bank_id = $1", bank_id) == 0
assert await prune(10) == 0
@pytest.mark.asyncio
async def test_pruning_cancelled_child_cancels_parent_before_deletion_and_releases_siblings_later(
self, pool, backend, clean_operations
):
bank_id = f"test-worker-retention-parent-{uuid.uuid4().hex[:8]}"
await _ensure_bank(pool, bank_id)
cutoff = datetime(2000, 1, 1, tzinfo=UTC)
parent_id = uuid.uuid4()
failed_child_id = uuid.uuid4()
completed_child_id = uuid.uuid4()
cancelled_child_id = uuid.uuid4()
standalone_id = uuid.uuid4()
await pool.execute(
"""
INSERT INTO async_operations
(operation_id, bank_id, operation_type, status, result_metadata, created_at, updated_at)
VALUES ($1, $2, 'batch_retain', 'pending', '{}'::jsonb, $3, $3)
""",
parent_id,
bank_id,
cutoff - timedelta(days=5),
)
for child_id, status in (
(failed_child_id, "failed"),
(completed_child_id, "completed"),
(cancelled_child_id, "cancelled"),
):
await pool.execute(
"""
INSERT INTO async_operations
(operation_id, bank_id, operation_type, status, task_payload, result_metadata,
created_at, updated_at)
VALUES ($1, $2, 'retain', $3, '{}'::jsonb, $4::jsonb, $5, $5)
""",
child_id,
bank_id,
status,
json.dumps({"parent_operation_id": str(parent_id)}),
cutoff - timedelta(days=4),
)
await self._insert_operation(
pool,
operation_id=standalone_id,
bank_id=bank_id,
status="completed",
updated_at=cutoff - timedelta(days=3),
marker="standalone",
)
cleanup_started_at = await pool.fetchval("SELECT now()")
async with backend.acquire() as conn:
async with conn.transaction():
deleted = await backend.ops.prune_terminal_operations(conn, "async_operations", cutoff, batch_size=100)
assert deleted == 2
parent = await pool.fetchrow(
"""
SELECT status, updated_at, completed_at, error_message
FROM async_operations
WHERE operation_id = $1
""",
parent_id,
)
assert parent["status"] == "cancelled"
assert parent["updated_at"] >= cleanup_started_at
assert parent["completed_at"] >= cleanup_started_at
assert parent["error_message"] == "Cancelled because a child operation was cancelled"
assert (
await pool.fetchval("SELECT COUNT(*) FROM async_operations WHERE operation_id = $1", failed_child_id) == 1
)
assert (
await pool.fetchval("SELECT COUNT(*) FROM async_operations WHERE operation_id = $1", completed_child_id)
== 1
)
assert (
await pool.fetchval("SELECT COUNT(*) FROM async_operations WHERE operation_id = $1", cancelled_child_id)
== 0
)
assert await pool.fetchval("SELECT COUNT(*) FROM async_operations WHERE operation_id = $1", standalone_id) == 0
await pool.execute(
"UPDATE async_operations SET updated_at = $2 WHERE operation_id = $1",
parent_id,
cutoff - timedelta(days=1),
)
async with backend.acquire() as conn:
async with conn.transaction():
deleted = await backend.ops.prune_terminal_operations(conn, "async_operations", cutoff, batch_size=100)
assert deleted == 1
assert await pool.fetchval("SELECT COUNT(*) FROM async_operations WHERE operation_id = $1", parent_id) == 0
assert (
await pool.fetchval("SELECT COUNT(*) FROM async_operations WHERE operation_id = $1", failed_child_id) == 1
)
assert (
await pool.fetchval("SELECT COUNT(*) FROM async_operations WHERE operation_id = $1", completed_child_id)
== 1
)
async with backend.acquire() as conn:
async with conn.transaction():
deleted = await backend.ops.prune_terminal_operations(conn, "async_operations", cutoff, batch_size=100)
assert deleted == 2
assert (
await pool.fetchval("SELECT COUNT(*) FROM async_operations WHERE operation_id = $1", failed_child_id) == 0
)
assert (
await pool.fetchval("SELECT COUNT(*) FROM async_operations WHERE operation_id = $1", completed_child_id)
== 0
)
@pytest.mark.asyncio
async def test_postgresql_pruning_reconciles_pending_same_bank_parent_before_child_delete(self):
from hindsight_api.engine.db.ops_postgresql import PostgreSQLOps
operation_id = uuid.uuid4()
calls = []
conn = MagicMock()
async def fetch(query, *args):
calls.append(("fetch", query))
return [{"operation_id": operation_id}]
async def execute(query, *args):
calls.append(("execute", query))
conn.fetch = AsyncMock(side_effect=fetch)
conn.execute = AsyncMock(side_effect=execute)
deleted = await PostgreSQLOps().prune_terminal_operations(
conn,
"async_operations",
datetime(2000, 1, 1, tzinfo=UTC),
batch_size=100,
)
assert deleted == 1
candidate_query = " ".join(conn.fetch.await_args_list[0].args[0].split())
reconciliation_query = " ".join(conn.execute.await_args.args[0].split())
delete_query = " ".join(conn.fetch.await_args_list[1].args[0].split())
assert "candidate_operation.status = 'cancelled' OR NOT EXISTS" in candidate_query
assert "parent.operation_id = CASE" in candidate_query
assert "UPDATE async_operations parent SET status = 'cancelled'" in reconciliation_query
assert "parent.status = 'pending'" in reconciliation_query
assert "candidate_operation.bank_id = parent.bank_id" in reconciliation_query
assert "candidate_operation.status = 'cancelled'" in reconciliation_query
assert "parent.operation_id = CASE" in reconciliation_query
assert "~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'" in reconciliation_query
assert "completed_at = COALESCE(parent.completed_at, now())" in reconciliation_query
assert "error_message = COALESCE(" in reconciliation_query
assert "DELETE FROM async_operations" in delete_query
assert [kind for kind, _ in calls] == ["fetch", "execute", "fetch"]
@pytest.mark.asyncio
async def test_oracle_pruning_clamps_candidate_batch_to_in_list_limit(self):
from hindsight_api.engine.db.ops_oracle import ORACLE_IN_LIST_LIMIT, OracleOps
candidate_ids = [uuid.uuid4() for _ in range(ORACLE_IN_LIST_LIMIT)]
rows = [{"operation_id": operation_id, "result_metadata": {}} for operation_id in candidate_ids]
conn = MagicMock()
conn.fetch = AsyncMock(side_effect=[rows, rows])
conn.execute = AsyncMock()
deleted = await OracleOps().prune_terminal_operations(
conn,
"async_operations",
datetime(2000, 1, 1, tzinfo=UTC),
batch_size=ORACLE_IN_LIST_LIMIT + 500,
)
assert deleted == ORACLE_IN_LIST_LIMIT
assert conn.fetch.await_args_list[0].args[-1] == ORACLE_IN_LIST_LIMIT
assert len(conn.fetch.await_args_list[1].args[1]) == ORACLE_IN_LIST_LIMIT
assert conn.execute.await_count == 2
assert len(conn.execute.await_args_list[0].args[1]) == ORACLE_IN_LIST_LIMIT
assert len(conn.execute.await_args_list[1].args[1]) == ORACLE_IN_LIST_LIMIT
@pytest.mark.asyncio
async def test_oracle_pruning_reconciles_pending_same_bank_parent_before_child_delete(self):
from hindsight_api.engine.db.ops_oracle import OracleOps
from hindsight_api.engine.db.oracle import _rewrite_pg_to_oracle
standalone_id = uuid.uuid4()
candidates = [{"operation_id": standalone_id}]
calls = []
conn = MagicMock()
async def fetch(query, *args):
calls.append(("fetch", query))
if len(conn.fetch.await_args_list) == 1:
return candidates
return [{"operation_id": standalone_id}]
async def execute(query, *args):
calls.append(("execute", query))
conn.fetch = AsyncMock(side_effect=fetch)
conn.execute = AsyncMock(side_effect=execute)
deleted = await OracleOps().prune_terminal_operations(
conn,
"async_operations",
datetime(2000, 1, 1, tzinfo=UTC),
batch_size=100,
)
assert deleted == 1
candidate_query = conn.fetch.await_args_list[0].args[0]
lock_query = conn.fetch.await_args_list[1].args[0]
reconciliation_query = conn.execute.await_args_list[0].args[0]
delete_query = conn.execute.await_args_list[1].args[0]
safe_parent_lookup = (
"parent.operation_id = CASE WHEN REGEXP_LIKE( JSON_VALUE( "
"candidate_operation.result_metadata, '$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR ), "
"'^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$' ) "
"THEN HEXTORAW(REPLACE( JSON_VALUE( candidate_operation.result_metadata, "
"'$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR ), '-', '' )) ELSE NULL END"
)
for query in (candidate_query, lock_query):
compact_query = " ".join(query.split())
assert "candidate_operation.status = 'cancelled' OR NOT EXISTS" in compact_query
assert safe_parent_lookup in compact_query
assert "RAWTOHEX" not in compact_query
compact_reconciliation_query = " ".join(reconciliation_query.split())
assert "UPDATE async_operations parent SET status = 'cancelled'" in compact_reconciliation_query
assert "parent.status = 'pending'" in compact_reconciliation_query
assert "candidate_operation.bank_id = parent.bank_id" in compact_reconciliation_query
assert "candidate_operation.status = 'cancelled'" in compact_reconciliation_query
assert safe_parent_lookup in compact_reconciliation_query
assert "completed_at = COALESCE(parent.completed_at, now())" in compact_reconciliation_query
assert "error_message = COALESCE(" in compact_reconciliation_query
assert "RAWTOHEX" not in compact_reconciliation_query
candidate_oracle_query = _rewrite_pg_to_oracle(candidate_query).query
lock_oracle_query = _rewrite_pg_to_oracle(lock_query).query
reconciliation_oracle_query = _rewrite_pg_to_oracle(reconciliation_query).query
assert "LIMIT" not in candidate_oracle_query
assert "FETCH FIRST :2 ROWS ONLY" in candidate_oracle_query
for query in (candidate_oracle_query, lock_oracle_query):
compact_query = " ".join(query.split())
assert "candidate_operation.status = 'cancelled' OR NOT EXISTS" in compact_query
assert safe_parent_lookup in compact_query
assert "RAWTOHEX" not in compact_query
compact_reconciliation_oracle_query = " ".join(reconciliation_oracle_query.split())
assert "parent.status = 'pending'" in compact_reconciliation_oracle_query
assert "candidate_operation.bank_id = parent.bank_id" in compact_reconciliation_oracle_query
assert safe_parent_lookup in compact_reconciliation_oracle_query
assert "SYSTIMESTAMP" in compact_reconciliation_oracle_query
assert "RAWTOHEX" not in compact_reconciliation_oracle_query
assert "FOR UPDATE OF candidate_operation.operation_id SKIP LOCKED" in lock_oracle_query
assert conn.fetch.await_args_list[1].args[1] == [standalone_id]
assert conn.execute.await_args_list[0].args[1] == [standalone_id]
assert conn.execute.await_args_list[1].args[1] == [standalone_id]
assert "DELETE FROM async_operations" in delete_query
assert [kind for kind, _ in calls] == ["fetch", "fetch", "execute", "execute"]
@pytest.mark.asyncio
async def test_oracle_pruning_filters_parent_blocked_children_before_batch_limit(self):
from hindsight_api.engine.db.ops_oracle import OracleOps
parent_ids = [uuid.uuid4(), uuid.uuid4()]
child_ids = [uuid.uuid4(), uuid.uuid4()]
eligible_id = uuid.uuid4()
blocked_candidates = [
{
"operation_id": child_id,
"result_metadata": {"parent_operation_id": str(parent_id)},
}
for child_id, parent_id in zip(child_ids, parent_ids, strict=True)
]
conn = MagicMock()
async def fetch(query, *args):
if "LIMIT $2" in query:
if "NOT EXISTS" in query:
return [{"operation_id": eligible_id}]
return blocked_candidates
if "operation_id = ANY($1)" in query:
return [{"operation_id": operation_id} for operation_id in args[0]]
raise AssertionError(f"Unexpected query: {query}")
conn.fetch = AsyncMock(side_effect=fetch)
conn.execute = AsyncMock()
deleted = await OracleOps().prune_terminal_operations(
conn,
"async_operations",
datetime(2000, 1, 1, tzinfo=UTC),
batch_size=2,
)
assert deleted == 1
candidate_query = conn.fetch.await_args_list[0].args[0]
assert candidate_query.index("NOT EXISTS") < candidate_query.index("LIMIT $2")
assert conn.execute.await_count == 2
assert conn.execute.await_args_list[0].args[1] == [eligible_id]
assert conn.execute.await_args_list[1].args[1] == [eligible_id]
@pytest.mark.asyncio
async def test_oracle_backend_resets_default_schema_on_pooled_connection(self):
from hindsight_api.engine.db.oracle import OracleBackend
from hindsight_api.engine.memory_engine import _current_schema
backend = OracleBackend()
backend._default_schema = "APP_USER"
cursor = MagicMock()
cursor.execute = AsyncMock()
conn = MagicMock()
conn.cursor.return_value = cursor
tenant_token = _current_schema.set("TENANT_A")
try:
await backend._set_session_schema(conn)
finally:
_current_schema.reset(tenant_token)
cursor.execute.assert_awaited_with('ALTER SESSION SET CURRENT_SCHEMA = "TENANT_A"')
cursor.execute.reset_mock()
default_token = _current_schema.set(None)
try:
await backend._set_session_schema(conn)
finally:
_current_schema.reset(default_token)
cursor.execute.assert_awaited_once_with('ALTER SESSION SET CURRENT_SCHEMA = "APP_USER"')
assert cursor.close.call_count == 2
@pytest.mark.asyncio
async def test_oracle_backend_discovers_session_user_before_first_schema_switch(self):
from hindsight_api.engine.db.oracle import OracleBackend
from hindsight_api.engine.memory_engine import _current_schema
backend = OracleBackend()
cursor = MagicMock()
cursor.execute = AsyncMock()
cursor.fetchone = AsyncMock(return_value=("APP_USER",))
conn = MagicMock()
conn.cursor.return_value = cursor
schema_token = _current_schema.set(None)
try:
await backend._set_session_schema(conn)
finally:
_current_schema.reset(schema_token)
assert [call.args[0] for call in cursor.execute.await_args_list] == [
"SELECT SYS_CONTEXT('USERENV', 'SESSION_USER') FROM DUAL",
'ALTER SESSION SET CURRENT_SCHEMA = "APP_USER"',
]
assert backend._default_schema == "APP_USER"
class TestWorkerOperationCleanupScheduling:
@staticmethod
def _make_backend(prune_side_effect=None):
conn = MagicMock()
@asynccontextmanager
async def transaction():
yield conn
conn.transaction = transaction
backend = MagicMock()
backend.ops.prune_terminal_operations = AsyncMock(side_effect=prune_side_effect, return_value=0)
@asynccontextmanager
async def acquire():
yield conn
backend.acquire = acquire
return backend
@staticmethod
def _tenant_extension(*schemas: str):
extension = MagicMock()
extension.list_tenants = AsyncMock(return_value=[SimpleNamespace(schema=schema) for schema in schemas])
return extension
@pytest.mark.asyncio
async def test_disabled_retention_does_not_discover_schemas_or_touch_db(self):
from hindsight_api.worker import WorkerPoller
backend = self._make_backend()
tenant_extension = self._tenant_extension("public", "tenant_a")
poller = WorkerPoller(
backend=backend,
worker_id="cleanup-disabled",
executor=AsyncMock(),
tenant_extension=tenant_extension,
operation_retention_days=0,
operation_cleanup_batch_size=1000,
)
await poller._cleanup_terminal_operations_if_due()
tenant_extension.list_tenants.assert_not_awaited()
backend.ops.prune_terminal_operations.assert_not_awaited()
@pytest.mark.asyncio
async def test_cleanup_binds_each_tenant_schema_before_acquiring_connection(self):
from hindsight_api.engine.memory_engine import _current_schema
from hindsight_api.worker import WorkerPoller
seen_schemas: list[str | None] = []
backend = self._make_backend()
original_acquire = backend.acquire
@asynccontextmanager
async def recording_acquire():
seen_schemas.append(_current_schema.get())
async with original_acquire() as conn:
yield conn
backend.acquire = recording_acquire
poller = WorkerPoller(
backend=backend,
worker_id="cleanup-schema-context",
executor=AsyncMock(),
tenant_extension=self._tenant_extension("public", "tenant_a", "tenant_b"),
operation_retention_days=30,
operation_cleanup_batch_size=1000,
)
await poller._cleanup_terminal_operations_if_due()
cleanup_task = poller._operation_cleanup_task
assert cleanup_task is not None
await cleanup_task
assert seen_schemas == [None, "tenant_a", "tenant_b"]
@pytest.mark.asyncio
async def test_cleanup_visits_every_schema_and_isolates_one_schema_failure(self, caplog):
from hindsight_api.worker import WorkerPoller
async def prune(_conn, table, _cutoff, *, batch_size):
assert batch_size == 17
if "tenant_bad" in table:
raise RuntimeError("tenant cleanup failed")
return 1
backend = self._make_backend(prune)
poller = WorkerPoller(
backend=backend,
worker_id="cleanup-tenants",
executor=AsyncMock(),
tenant_extension=self._tenant_extension("public", "tenant_bad", "tenant_good"),
operation_retention_days=30,
operation_cleanup_batch_size=17,
)
await poller._cleanup_terminal_operations_if_due()
cleanup_task = poller._operation_cleanup_task
assert cleanup_task is not None
await cleanup_task
tables = [call.args[1] for call in backend.ops.prune_terminal_operations.await_args_list]
assert tables == ["async_operations", '"tenant_bad".async_operations', '"tenant_good".async_operations']
assert "tenant cleanup failed" in caplog.text
@pytest.mark.asyncio
async def test_monotonic_due_guard_limits_cleanup_to_once_per_minute(self):
from hindsight_api.worker import WorkerPoller
backend = self._make_backend()
poller = WorkerPoller(
backend=backend,
worker_id="cleanup-guard",
executor=AsyncMock(),
tenant_extension=self._tenant_extension("public"),
operation_retention_days=30,
operation_cleanup_batch_size=1000,
)
await poller._cleanup_terminal_operations_if_due()
first_task = poller._operation_cleanup_task
assert first_task is not None
await first_task
await poller._cleanup_terminal_operations_if_due()
assert poller._operation_cleanup_task is first_task
assert backend.ops.prune_terminal_operations.await_count == 1
@pytest.mark.asyncio
async def test_slow_cleanup_measures_next_interval_from_completion(self):
from hindsight_api.worker import WorkerPoller
backend = self._make_backend()
poller = WorkerPoller(
backend=backend,
worker_id="cleanup-slow-cycle",
executor=AsyncMock(),
tenant_extension=self._tenant_extension("public"),
operation_retention_days=30,
operation_cleanup_batch_size=1000,
)
await poller._cleanup_terminal_operations_if_due()
cleanup_task = poller._operation_cleanup_task
assert cleanup_task is not None
await cleanup_task
completed_at = poller._last_operation_cleanup_monotonic
await poller._cleanup_terminal_operations_if_due()
assert poller._last_operation_cleanup_monotonic == completed_at
assert backend.ops.prune_terminal_operations.await_count == 1
@pytest.mark.asyncio
async def test_cleanup_runs_in_background_without_blocking_task_claiming(self):
from hindsight_api.worker import WorkerPoller
cleanup_started = asyncio.Event()
release_cleanup = asyncio.Event()
async def prune(_conn, _table, _cutoff, *, batch_size):
assert batch_size == 1000
cleanup_started.set()
await release_cleanup.wait()
return 0
poller = WorkerPoller(
backend=self._make_backend(prune),
worker_id="cleanup-background",
executor=AsyncMock(),
tenant_extension=self._tenant_extension("public"),
operation_retention_days=30,
operation_cleanup_batch_size=1000,
)
await asyncio.wait_for(poller._cleanup_terminal_operations_if_due(), timeout=0.1)
await asyncio.wait_for(cleanup_started.wait(), timeout=0.1)
assert poller._operation_cleanup_task is not None
assert not poller._operation_cleanup_task.done()
poller.claim_batch = AsyncMock(return_value=[])
await asyncio.wait_for(poller.claim_batch(), timeout=0.1)
release_cleanup.set()
cleanup_task = poller._operation_cleanup_task
assert cleanup_task is not None
await cleanup_task
+46 -9
View File
@@ -10,6 +10,26 @@ use serde::{Deserialize, Serialize};
use serde_json;
use std::collections::HashMap;
const DEFAULT_CLI_USER_AGENT: &str = concat!("hindsight-cli/", env!("CARGO_PKG_VERSION"));
fn default_headers(api_key: Option<&str>) -> Result<reqwest::header::HeaderMap> {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::USER_AGENT,
reqwest::header::HeaderValue::from_static(DEFAULT_CLI_USER_AGENT),
);
if let Some(key) = api_key {
let auth_value = format!("Bearer {}", key);
headers.insert(
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_str(&auth_value)?,
);
}
Ok(headers)
}
/// Convert a progenitor client error into an anyhow error that includes the
/// HTTP response body. Without this, errors render as
/// "Unexpected Response: Response { ... }" with no body, hiding validation
@@ -112,15 +132,7 @@ impl ApiClient {
let mut client_builder =
reqwest::Client::builder().timeout(std::time::Duration::from_secs(120));
if let Some(key) = api_key {
let mut headers = reqwest::header::HeaderMap::new();
let auth_value = format!("Bearer {}", key);
headers.insert(
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_str(&auth_value)?,
);
client_builder = client_builder.default_headers(headers);
}
client_builder = client_builder.default_headers(default_headers(api_key.as_deref())?);
let http_client = client_builder.build()?;
@@ -1309,6 +1321,31 @@ pub use types::{
mod tests {
use super::*;
#[test]
fn default_headers_set_cli_user_agent_without_api_key() {
let headers = default_headers(None).unwrap();
assert_eq!(
headers.get(reqwest::header::USER_AGENT).unwrap(),
DEFAULT_CLI_USER_AGENT,
);
assert!(!headers.contains_key(reqwest::header::AUTHORIZATION));
}
#[test]
fn default_headers_keep_authorization_with_cli_user_agent() {
let headers = default_headers(Some("hsk_test")).unwrap();
assert_eq!(
headers.get(reqwest::header::USER_AGENT).unwrap(),
DEFAULT_CLI_USER_AGENT,
);
assert_eq!(
headers.get(reqwest::header::AUTHORIZATION).unwrap(),
"Bearer hsk_test",
);
}
#[test]
fn test_operation_deserialize() {
let json = r#"{
+6 -2
View File
@@ -2286,8 +2286,9 @@ paths:
- Operations
get:
description: "Get the status of a specific async operation. Returns 'pending',\
\ 'completed', or 'failed'. Completed operations are removed from storage,\
\ so 'completed' means the operation finished successfully."
\ 'processing', 'completed', 'failed', or 'cancelled'. Completed operations\
\ remain queryable with their payload for the configured retention window\
\ and are pruned afterward."
operationId: get_operation_status
parameters:
- explode: false
@@ -6339,6 +6340,9 @@ components:
date: 2024-01-15T10:30:00Z
entities: "Alice (PERSON), Google (ORGANIZATION)"
id: 550e8400-e29b-41d4-a716-446655440000
metadata:
channel: engineering
source: slack
text: Alice works at Google on the AI team
type: world
limit: 100
+1 -1
View File
@@ -176,7 +176,7 @@ func (r ApiGetOperationStatusRequest) Execute() (*OperationStatusResponse, *http
/*
GetOperationStatus Get operation status
Get the status of a specific async operation. Returns 'pending', 'completed', or 'failed'. Completed operations are removed from storage, so 'completed' means the operation finished successfully.
Get the status of a specific async operation. Returns 'pending', 'processing', 'completed', 'failed', or 'cancelled'. Completed operations remain queryable with their payload for the configured retention window and are pruned afterward.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@@ -592,7 +592,7 @@ class Hindsight:
disposition_empathy: Deprecated. Use update_bank_config(disposition_empathy=...) instead.
disposition: Deprecated. Use update_bank_config(disposition_skepticism=...) instead.
retain_mission: Steers what gets extracted during retain(). Injected alongside built-in rules.
retain_extraction_mode: Fact extraction mode: 'concise' (default), 'verbose', or 'custom'.
retain_extraction_mode: Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'.
retain_custom_instructions: Custom extraction prompt (only active when mode is 'custom').
retain_chunk_size: Target maximum characters for each content chunk during retain.
retain_structured_chunk_size: Maximum characters for a single JSONL line or conversation
@@ -731,7 +731,7 @@ class Hindsight:
disposition_empathy: Deprecated. Use update_bank_config(disposition_empathy=...) instead.
disposition: Deprecated. Use update_bank_config(disposition_skepticism=...) instead.
retain_mission: Steers what gets extracted during retain(). Injected alongside built-in rules.
retain_extraction_mode: Fact extraction mode: 'concise' (default), 'verbose', or 'custom'.
retain_extraction_mode: Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'.
retain_custom_instructions: Custom extraction prompt (only active when mode is 'custom').
retain_chunk_size: Target maximum characters for each content chunk during retain.
retain_structured_chunk_size: Maximum characters for a single JSONL line or conversation
@@ -357,7 +357,7 @@ class OperationsApi:
) -> OperationStatusResponse:
"""Get operation status
Get the status of a specific async operation. Returns 'pending', 'completed', or 'failed'. Completed operations are removed from storage, so 'completed' means the operation finished successfully.
Get the status of a specific async operation. Returns 'pending', 'processing', 'completed', 'failed', or 'cancelled'. Completed operations remain queryable with their payload for the configured retention window and are pruned afterward.
:param bank_id: (required)
:type bank_id: str
@@ -437,7 +437,7 @@ class OperationsApi:
) -> ApiResponse[OperationStatusResponse]:
"""Get operation status
Get the status of a specific async operation. Returns 'pending', 'completed', or 'failed'. Completed operations are removed from storage, so 'completed' means the operation finished successfully.
Get the status of a specific async operation. Returns 'pending', 'processing', 'completed', 'failed', or 'cancelled'. Completed operations remain queryable with their payload for the configured retention window and are pruned afterward.
:param bank_id: (required)
:type bank_id: str
@@ -517,7 +517,7 @@ class OperationsApi:
) -> RESTResponseType:
"""Get operation status
Get the status of a specific async operation. Returns 'pending', 'completed', or 'failed'. Completed operations are removed from storage, so 'completed' means the operation finished successfully.
Get the status of a specific async operation. Returns 'pending', 'processing', 'completed', 'failed', or 'cancelled'. Completed operations remain queryable with their payload for the configured retention window and are pruned afterward.
:param bank_id: (required)
:type bank_id: str
@@ -877,7 +877,7 @@ export const cancelOperation = <ThrowOnError extends boolean = false>(
/**
* Get operation status
*
* Get the status of a specific async operation. Returns 'pending', 'completed', or 'failed'. Completed operations are removed from storage, so 'completed' means the operation finished successfully.
* Get the status of a specific async operation. Returns 'pending', 'processing', 'completed', 'failed', or 'cancelled'. Completed operations remain queryable with their payload for the configured retention window and are pruned afterward.
*/
export const getOperationStatus = <ThrowOnError extends boolean = false>(
options: Options<GetOperationStatusData, ThrowOnError>
@@ -453,7 +453,7 @@ export type BankTemplateConfig = {
/**
* Retain Extraction Mode
*
* Fact extraction mode: 'concise' (default), 'verbose', or 'custom'
* Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'
*/
retain_extraction_mode?: string | null;
/**
@@ -1080,7 +1080,7 @@ export type CreateBankRequest = {
/**
* Retain Extraction Mode
*
* Fact extraction mode: 'concise' (default), 'verbose', or 'custom'.
* Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'.
*/
retain_extraction_mode?: string | null;
/**
+1 -1
View File
@@ -500,7 +500,7 @@ export class HindsightClient {
dispositionEmpathy?: number;
/** Steers what gets extracted during retain(). Injected alongside built-in rules. */
retainMission?: string;
/** Fact extraction mode: 'concise' (default), 'verbose', or 'custom'. */
/** Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'. */
retainExtractionMode?: string;
/** Custom extraction prompt (only active when retainExtractionMode is 'custom'). */
retainCustomInstructions?: string;
+1 -4
View File
@@ -11,6 +11,7 @@
"public"
],
"scripts": {
"prebuild": "npm run build -w @vectorize-io/hindsight-client",
"dev": "next dev --turbopack -p ${PORT:-9999}",
"build": "NODE_ENV=production next build && npm run build:standalone",
"build:standalone": "rm -rf standalone && SERVER_JS=$(find .next/standalone -path '*/node_modules' -prune -o -name 'server.js' -print | head -1) && test -n \"$SERVER_JS\" || (echo 'Error: server.js not found in .next/standalone - standalone build failed' && exit 1) && STANDALONE_ROOT=$(dirname \"$SERVER_JS\") && cp -r \"$STANDALONE_ROOT\" standalone && cp -r .next/standalone/node_modules standalone/node_modules && mkdir -p standalone/.next && cp -r .next/static standalone/.next/static && mkdir -p standalone/public && (cp -r public/* standalone/public/ 2>/dev/null || true)",
@@ -38,14 +39,12 @@
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-visually-hidden": "^1.2.5",
"@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/typography": "^0.5.19",
"@types/cytoscape": "^3.21.9",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
@@ -55,8 +54,6 @@
"cmdk": "^1.1.1",
"cron-parser": "^5.6.1",
"cronstrue": "^3.21.0",
"cytoscape": "^3.33.1",
"cytoscape-fcose": "^2.2.0",
"eslint": "^9.39.1",
"eslint-config-next": "^16.0.1",
"lucide-react": "^0.553.0",
@@ -8,6 +8,8 @@ export async function GET(request: Request, { params }: { params: Promise<{ bank
const { searchParams } = new URL(request.url);
const tags = searchParams.getAll("tags");
const tagsMatch = searchParams.get("tags_match");
const limit = searchParams.get("limit");
const offset = searchParams.get("offset");
if (!bankId) {
return NextResponse.json(
@@ -26,6 +28,12 @@ export async function GET(request: Request, { params }: { params: Promise<{ bank
if (tagsMatch) {
queryParams.append("tags_match", tagsMatch);
}
if (limit) {
queryParams.append("limit", limit);
}
if (offset) {
queryParams.append("offset", offset);
}
const url = dataplaneBankUrl(
bankId,
@@ -17,13 +17,14 @@ export async function GET(request: NextRequest) {
);
}
const q = searchParams.get("q") || undefined;
const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined;
const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : undefined;
const response = await sdk.listDocuments({
client: lowLevelClient,
path: { bank_id: bankId },
query: { limit, offset },
query: { q, limit, offset },
});
return respondWithSdk(response, "Failed to fetch documents", { request });
}
@@ -505,7 +505,11 @@ function BankSelectorInner() {
aria-expanded={open}
className="w-[250px] justify-between font-bold border-2 border-primary hover:bg-accent"
>
<span className="truncate">{currentBank || tNavBank("select")}</span>
<span className="truncate">
{bankInfos.find((b) => b.bank_id === currentBank)?.name ||
currentBank ||
tNavBank("select")}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@@ -555,8 +559,11 @@ function BankSelectorInner() {
isSelected ? "opacity-100" : "opacity-0"
)}
/>
<span className="truncate flex-1 font-medium" title={bank.bank_id}>
{bank.bank_id}
<span
className="truncate flex-1 font-medium"
title={bank.name || bank.bank_id}
>
{bank.name || bank.bank_id}
</span>
<button
type="button"
@@ -4,7 +4,7 @@ import { useRef, useEffect, useCallback, useMemo, useState } from "react";
import type { CSSProperties } from "react";
import { useTranslations } from "next-intl";
import { prepare, layout, prepareWithSegments, layoutWithLines } from "@chenglou/pretext";
import type { GraphData, GraphNode, GraphLink } from "./graph-2d";
import type { GraphData, GraphNode, GraphLink } from "./graph-data";
// ============================================================================
// Types
@@ -21,6 +21,12 @@ interface PreparedNode {
/** Color derived from link count (heat gradient) */
heatColor: string;
linkCount: number;
/**
* Per-node phase in [0, 2π), derived from the id hash. Desynchronizes the
* ambient drift + pulse so the field breathes organically instead of in
* lockstep. Precomputed here so the animation loop stays trig-only.
*/
phase: number;
}
// ============================================================================
@@ -372,6 +378,7 @@ export function Constellation({
// so the grouping reads at a glance; otherwise it keeps the heat gradient.
heatColor: centroid ? color : heat,
linkCount: lc,
phase: ((Math.abs(seed) % 1000) / 1000) * Math.PI * 2,
};
});
@@ -448,15 +455,24 @@ export function Constellation({
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
// Screen positions
// Ambient-motion clock (seconds).
const time = (typeof performance !== "undefined" ? performance.now() : 0) / 1000;
// Drift amplitude in world units — nodes slowly wander around their home
// position so the whole field visibly breathes.
const DRIFT_AMP = 16;
// Screen positions (with a slow per-node ambient drift baked in, so links —
// which read straight from screenX/screenY below — follow for free).
const screenX = new Float32Array(preparedNodes.length);
const screenY = new Float32Array(preparedNodes.length);
const visible = new Uint8Array(preparedNodes.length);
for (let i = 0; i < preparedNodes.length; i++) {
const n = preparedNodes[i];
const sx = cx + n.wx * zoom;
const sy = cy + n.wy * zoom;
const driftX = DRIFT_AMP * Math.sin(time * 0.6 + n.phase);
const driftY = DRIFT_AMP * Math.cos(time * 0.5 + n.phase * 1.3);
const sx = cx + (n.wx + driftX) * zoom;
const sy = cy + (n.wy + driftY) * zoom;
screenX[i] = sx;
screenY[i] = sy;
visible[i] = sx > -margin && sx < W + margin && sy > -margin && sy < H + margin ? 1 : 0;
@@ -508,11 +524,39 @@ export function Constellation({
ctx.moveTo(ax, ay);
ctx.quadraticCurveTo(midX, midY, bx, by);
ctx.stroke();
// A small bead of light travels the curve from the hovered node outward,
// so connections read as live signal paths rather than static lines.
{
// Phase-offset per link so beads don't march in lockstep. Travel runs
// from the hovered node (u=0) toward its neighbor (u=1).
const fromHovered = link.a === hoverIndex;
const raw = (time * 0.22 + (li % 13) / 13) % 1;
const u = fromHovered ? raw : 1 - raw;
const iu = 1 - u;
// Point on the quadratic Bézier at parameter u.
const px = iu * iu * ax + 2 * iu * u * midX + u * u * bx;
const py = iu * iu * ay + 2 * iu * u * midY + u * u * by;
ctx.globalAlpha = 0.9 * (0.4 + 0.6 * Math.sin(u * Math.PI)); // fade at the ends
ctx.fillStyle = link.color;
ctx.shadowColor = link.color;
ctx.shadowBlur = 6;
ctx.beginPath();
ctx.arc(px, py, 2, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
// Restore the stroke state the loop's next iteration expects.
ctx.globalAlpha = 0.5;
ctx.lineWidth = 1.5;
}
linksDrawn++;
}
ctx.globalAlpha = 1;
} else {
const baseAlpha = 0.06 + Math.min(zoom * 0.04, 0.1);
// Faint, slow breathing across the whole web so idle links feel alive
// without flickering (one global sine, not per-link — stays calm).
const shimmer = 1 + 0.18 * Math.sin(time * 0.6);
const baseAlpha = (0.06 + Math.min(zoom * 0.04, 0.1)) * shimmer;
ctx.lineWidth = 0.4;
for (const link of linksWithIndices) {
@@ -661,11 +705,18 @@ export function Constellation({
// Size varies slightly by link count — subtle range like star magnitudes.
// When nodeSizeFn is provided (e.g. entities view), it overrides linkCount
// sizing so dots can scale by an external weight like co-occurrence count.
const baseR = nodeSizeFn ? nodeSizeFn(n.node) : 2.5 + Math.min(n.linkCount * 0.15, 2.5);
const rawR = nodeSizeFn ? nodeSizeFn(n.node) : 2.5 + Math.min(n.linkCount * 0.15, 2.5);
// Gentle pulse — each dot "breathes" in size, out of phase with its
// neighbors, so the field twinkles like a living star map.
const pulse = 1 + 0.13 * Math.sin(time * 1.05 + n.phase);
const baseR = rawR * pulse;
const r = Math.max(1.5, baseR * Math.min(zoom, 2));
// Opacity varies — fewer links = dimmer, more links = brighter
const baseAlpha = 0.45 + Math.min(n.linkCount * 0.03, 0.5);
// Opacity varies — fewer links = dimmer, more links = brighter. A brightness
// twinkle (offset from the size pulse) makes even tiny dots read as alive,
// where a radius pulse alone would be imperceptible.
const twinkleAlpha = 0.82 + 0.18 * Math.sin(time * 1.4 + n.phase * 2.1);
const baseAlpha = (0.45 + Math.min(n.linkCount * 0.03, 0.5)) * twinkleAlpha;
// Dot — star-like: heat-gradient color, varied size & opacity
ctx.beginPath();
@@ -679,12 +730,14 @@ export function Constellation({
}
ctx.fill();
// Soft glow halo for brighter stars (high link count)
// Soft glow halo for brighter stars (high link count) — the halo twinkles
// a little (out of phase with the dot's pulse) so hubs feel radiant.
if (n.linkCount > 3 && !isHovered && hoverIndex < 0) {
const twinkle = 1 + 0.25 * Math.sin(time * 0.9 + n.phase * 1.7);
ctx.beginPath();
ctx.arc(sx, sy, r * 2, 0, Math.PI * 2);
ctx.fillStyle = n.heatColor;
ctx.globalAlpha = 0.06 + Math.min(n.linkCount * 0.005, 0.08);
ctx.globalAlpha = (0.06 + Math.min(n.linkCount * 0.005, 0.08)) * twinkle;
ctx.fill();
}
@@ -1119,9 +1172,17 @@ export function Constellation({
canvas.addEventListener("mouseup", handleMouseUp);
canvas.addEventListener("mouseleave", handleMouseLeave);
// The canvas also changes size when its container reflows (e.g. the side
// panel opening/closing) with no window "resize" event. Observe the element
// so the backing store is re-measured — otherwise CSS stretches the old
// bitmap and the text/dots look squeezed.
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => resize()) : null;
ro?.observe(canvas);
return () => {
cancelAnimationFrame(animRef.current);
window.removeEventListener("resize", handleResize);
ro?.disconnect();
canvas.removeEventListener("wheel", handleWheel);
canvas.removeEventListener("mousemove", handleMouseMove);
canvas.removeEventListener("mousedown", handleMouseDown);
@@ -16,11 +16,9 @@ import {
ChevronsRight,
Settings2,
Eye,
EyeOff,
RefreshCw,
CheckCircle,
Clock,
Network,
List,
Search,
Layers,
@@ -33,8 +31,6 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import {
Select,
@@ -43,16 +39,15 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { MemoryDetailPanel } from "./memory-detail-panel";
import { MemoryDetailModal } from "./memory-detail-modal";
import { Graph2D, convertHindsightGraphData, GraphNode } from "./graph-2d";
import { convertHindsightGraphData, GraphNode } from "./graph-data";
import { Constellation } from "./constellation";
import { TagFilterInput } from "./tag-filter-input";
import { ObservationScopeFilter, ObservationScope } from "./observation-scope-filter";
import { ScatterChart, Plus, FileText } from "lucide-react";
type FactType = "world" | "experience" | "observation";
type ViewMode = "graph" | "table" | "timeline" | "constellation";
type ViewMode = "table" | "timeline" | "constellation";
// Categorical palette for coloring observation scopes (exact tag sets) when
// "Group by scope" clusters the constellation. Distinct, reasonably separable hues.
@@ -105,7 +100,6 @@ export function DataView({
const [scopes, setScopes] = useState<ObservationScope[]>([]);
const [selectedScope, setSelectedScope] = useState<string[] | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [selectedGraphNode, setSelectedGraphNode] = useState<any>(null);
const [modalMemoryId, setModalMemoryId] = useState<string | null>(null);
// Table view: toggle between live facts (graph-fed) and invalidated facts (archive).
const [showInvalidated, setShowInvalidated] = useState(false);
@@ -132,10 +126,7 @@ export function DataView({
last_consolidated_at: string | null;
} | null>(null);
// Graph controls state
const [showLabels, setShowLabels] = useState(true);
const [maxNodes, setMaxNodes] = useState<number | undefined>(undefined);
const [showControlPanel, setShowControlPanel] = useState(true);
// Constellation controls state
const [visibleLinkTypes, setVisibleLinkTypes] = useState<Set<string>>(
new Set(["semantic", "temporal", "entity", "causal"])
);
@@ -152,17 +143,6 @@ export function DataView({
});
};
// Esc key handler to deselect graph node
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && selectedGraphNode) {
setSelectedGraphNode(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedGraphNode]);
// `silent` skips the loading spinner — used by the background consolidation
// poll so the view refreshes in place without flashing.
const loadData = async (
@@ -230,6 +210,8 @@ export function DataView({
if (showInvalidated) return invalidatedRows;
return data?.table_rows ?? [];
}, [data, showInvalidated, invalidatedRows]);
const hasActiveMemoryFilters =
searchQuery.trim().length > 0 || tagFilters.length > 0 || selectedScope !== null;
// Helper to get normalized link type
const getLinkTypeCategory = (type: string | undefined): string => {
@@ -239,7 +221,7 @@ export function DataView({
return "semantic";
};
// Convert data for Graph2D (graph data is already filtered server-side)
// Convert data for the constellation (graph data is already filtered server-side)
const graph2DData = useMemo(() => {
if (!data) return { nodes: [], links: [] };
const fullData = convertHindsightGraphData(data);
@@ -253,44 +235,11 @@ export function DataView({
return { nodes: fullData.nodes, links };
}, [data, visibleLinkTypes]);
// Calculate link stats for display
const linkStats = useMemo(() => {
let semantic = 0,
temporal = 0,
entity = 0,
causal = 0,
total = 0;
const otherTypes: Record<string, number> = {};
graph2DData.links.forEach((l) => {
total++;
const type = l.type || "unknown";
if (type === "semantic") semantic++;
else if (type === "temporal") temporal++;
else if (type === "entity") entity++;
else if (
type === "causes" ||
type === "caused_by" ||
type === "enables" ||
type === "prevents"
)
causal++;
else {
otherTypes[type] = (otherTypes[type] || 0) + 1;
}
});
return { semantic, temporal, entity, causal, total, otherTypes };
}, [graph2DData]);
// Handle node click in graph - show in panel
const handleGraphNodeClick = useCallback(
(node: GraphNode) => {
const nodeData = data?.table_rows?.find((row: any) => row.id === node.id);
if (nodeData) {
setSelectedGraphNode(nodeData);
}
},
[data]
);
const handleGraphNodeClick = useCallback((node: GraphNode) => {
// Open the memory dialog for the clicked node (same dialog the table/timeline use).
setModalMemoryId(node.id);
}, []);
// Memoized color functions to prevent graph re-initialization
// Uses brand colors: primary blue (#0074d9), teal (#009296), amber for entity, purple for causal
@@ -499,29 +448,26 @@ export function DataView({
// restarts when consolidation starts/stops, not on every tick.
const isConsolidating =
factType === "observation" && (consolidationStatus?.pending_consolidation ?? 0) > 0;
// The tick goes through a ref so each fire sees the CURRENT filters; the
// interval itself still only restarts when consolidation starts/stops. Without
// this, the closure captures the filters from arming time, and a tag/scope
// selected mid-consolidation is clobbered ~4s later by a refetch using the
// stale (usually empty) filter — the same stale-closure guard as
// bank-profile-view's polling.
const pollTickRef = useRef<() => void>(() => {});
useEffect(() => {
if (!isConsolidating || !currentBank) return;
const id = setInterval(() => {
pollTickRef.current = () => {
const { tags, match } = resolveTagQuery();
loadData(undefined, searchQuery || undefined, tags, match, true);
loadScopes();
}, 4000);
};
});
useEffect(() => {
if (!isConsolidating || !currentBank) return;
const id = setInterval(() => pollTickRef.current(), 4000);
return () => clearInterval(id);
}, [isConsolidating, currentBank]);
// Enforce 50 node limit to prevent UI instability, default to 20 or max whichever is smaller
useEffect(() => {
if (data && maxNodes === undefined) {
if (graph2DData.nodes.length > 50) {
// Always set maxNodes to 20 when we have >50 nodes (never leave as undefined)
setMaxNodes(20);
} else if (graph2DData.nodes.length > 20) {
setMaxNodes(20);
}
// If ≤20 nodes, leave maxNodes undefined to show all
}
}, [data, graph2DData.nodes.length, maxNodes]);
return (
<div>
{loading && !data ? (
@@ -529,7 +475,7 @@ export function DataView({
<RefreshCw className="w-8 h-8 mx-auto mb-3 text-muted-foreground animate-spin" />
<p className="text-muted-foreground">{t("loadingMemories")}</p>
</div>
) : data && data.total_units === 0 ? (
) : data && data.total_units === 0 && !hasActiveMemoryFilters ? (
<div className="text-center py-20">
<FileText className="w-10 h-10 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-base font-medium text-foreground mb-1">{t("noMemoriesYet")}</h3>
@@ -644,7 +590,7 @@ export function DataView({
</Button>
)}
<div className="text-sm text-muted-foreground">
{searchQuery || tagFilters.length > 0 ? (
{hasActiveMemoryFilters ? (
t("matchingMemories", { count: filteredTableRows.length })
) : data.table_rows?.length < data.total_units ? (
<span>
@@ -656,11 +602,8 @@ export function DataView({
onClick={() => {
const newLimit = Math.min(data.total_units, fetchLimit + 1000);
setFetchLimit(newLimit);
loadData(
newLimit,
searchQuery || undefined,
tagFilters.length > 0 ? tagFilters : undefined
);
const { tags, match } = resolveTagQuery();
loadData(newLimit, searchQuery || undefined, tags, match);
}}
className="ml-2 text-primary hover:underline"
>
@@ -705,11 +648,10 @@ export function DataView({
{t("pendingCount", { count: consolidationStatus.pending_consolidation })}
<button
onClick={() =>
loadData(
fetchLimit,
searchQuery || undefined,
tagFilters.length > 0 ? tagFilters : undefined
)
(() => {
const { tags, match } = resolveTagQuery();
loadData(fetchLimit, searchQuery || undefined, tags, match);
})()
}
disabled={loading}
className="ml-0.5 opacity-70 hover:opacity-100 disabled:opacity-40 transition-opacity"
@@ -734,17 +676,6 @@ export function DataView({
<ScatterChart className="w-4 h-4" />
{t("constellation")}
</button>
<button
onClick={() => setViewMode("graph")}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-1.5 ${
viewMode === "graph"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Network className="w-4 h-4" />
{t("graph")}
</button>
<button
onClick={() => setViewMode("table")}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-1.5 ${
@@ -771,244 +702,76 @@ export function DataView({
</div>
)}
{!compactMode && viewMode === "graph" && (
<div className="flex gap-0">
{/* Graph */}
<div className="flex-1 min-w-0">
<Graph2D
data={graph2DData}
height={700}
showLabels={showLabels}
onNodeClick={handleGraphNodeClick}
maxNodes={maxNodes}
nodeColorFn={nodeColorFn}
linkColorFn={linkColorFn}
/>
</div>
{/* Right Toggle Button */}
<button
onClick={() => setShowControlPanel(!showControlPanel)}
className="flex-shrink-0 w-5 h-[700px] bg-transparent hover:bg-muted/50 flex items-center justify-center transition-colors"
title={showControlPanel ? t("hidePanel") : t("showPanel")}
>
{showControlPanel ? (
<ChevronRight className="w-3 h-3 text-muted-foreground/60" />
) : (
<ChevronLeft className="w-3 h-3 text-muted-foreground/60" />
)}
</button>
{/* Right Panel - Legend/Controls OR Memory Details */}
<div
className={`${showControlPanel ? "w-80" : "w-0"} transition-all duration-300 overflow-hidden flex-shrink-0`}
>
<div className="w-80 h-[700px] bg-card border-l border-border overflow-y-auto">
{selectedGraphNode ? (
/* Memory Detail View */
<MemoryDetailPanel
memory={selectedGraphNode}
onClose={() => setSelectedGraphNode(null)}
inPanel
bankId={currentBank || undefined}
/>
) : (
/* Legend & Controls View */
<div className="p-4 space-y-5">
{/* Legend & Stats */}
<div>
<h3 className="text-sm font-semibold mb-3 text-foreground">
{t("graphTitle")}
</h3>
<div className="space-y-2">
{/* Nodes */}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: "#0074d9" }}
/>
<span className="text-foreground">{t("nodes")}</span>
</div>
<span className="font-mono text-foreground">
{Math.min(
maxNodes ?? graph2DData.nodes.length,
graph2DData.nodes.length
)}
/{graph2DData.nodes.length}
</span>
</div>
<div className="text-xs font-medium text-muted-foreground mt-2 mb-1">
{t("linksWithCount", { count: linkStats.total })}{" "}
<span className="text-muted-foreground/60">{t("clickToFilter")}</span>
</div>
<button
onClick={() => toggleLinkType("semantic")}
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
visibleLinkTypes.has("semantic")
? "hover:bg-muted"
: "opacity-40 hover:opacity-60"
}`}
>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-[#0074d9]" />
<span className="text-foreground">{t("semantic")}</span>
</div>
<span
className={`font-mono ${linkStats.semantic === 0 ? "text-destructive" : "text-foreground"}`}
>
{linkStats.semantic}
</span>
</button>
<button
onClick={() => toggleLinkType("temporal")}
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
visibleLinkTypes.has("temporal")
? "hover:bg-muted"
: "opacity-40 hover:opacity-60"
}`}
>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-[#009296]" />
<span className="text-foreground">{t("temporal")}</span>
</div>
<span
className={`font-mono ${linkStats.temporal === 0 ? "text-destructive" : "text-foreground"}`}
>
{linkStats.temporal}
</span>
</button>
<button
onClick={() => toggleLinkType("entity")}
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
visibleLinkTypes.has("entity")
? "hover:bg-muted"
: "opacity-40 hover:opacity-60"
}`}
>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-[#f59e0b]" />
<span className="text-foreground">{t("entity")}</span>
</div>
<span className="font-mono text-foreground">{linkStats.entity}</span>
</button>
<button
onClick={() => toggleLinkType("causal")}
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
visibleLinkTypes.has("causal")
? "hover:bg-muted"
: "opacity-40 hover:opacity-60"
}`}
>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-[#8b5cf6]" />
<span className="text-foreground">{t("causal")}</span>
</div>
<span
className={`font-mono ${linkStats.causal === 0 ? "text-muted-foreground" : "text-foreground"}`}
>
{linkStats.causal}
</span>
</button>
{Object.entries(linkStats.otherTypes || {}).map(([type, count]) => (
<div key={type} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground capitalize ml-6">{type}</span>
<span className="font-mono text-muted-foreground">
{count as number}
</span>
</div>
))}
</div>
</div>
<div className="border-t border-border" />
{/* Controls Section */}
<div>
<h3 className="text-sm font-semibold mb-3 text-foreground">
{t("displayTitle")}
</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="show-labels" className="text-sm text-foreground">
{t("showLabels")}
</Label>
<Switch
id="show-labels"
checked={showLabels}
onCheckedChange={setShowLabels}
/>
</div>
</div>
</div>
<div className="border-t border-border" />
{/* Limits Section */}
<div>
<h3 className="text-sm font-semibold mb-3 text-foreground">
{t("performanceTitle")}
</h3>
<div className="space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<Label className="text-sm text-foreground">{t("maxNodes")}</Label>
<span className="text-xs text-muted-foreground">
{graph2DData.nodes.length > 50
? `${maxNodes ?? 50} / ${graph2DData.nodes.length}`
: `${maxNodes ?? "All"} / ${graph2DData.nodes.length}`}
</span>
</div>
<Slider
value={[
graph2DData.nodes.length > 50
? maxNodes || 20
: maxNodes || Math.min(graph2DData.nodes.length, 20),
]}
min={10}
max={Math.min(Math.max(graph2DData.nodes.length, 10), 50)}
step={10}
onValueChange={([v]) => {
const effectiveMax = Math.min(graph2DData.nodes.length, 50);
// If we have >50 nodes, never allow "All" (undefined), cap at 50
if (graph2DData.nodes.length > 50) {
setMaxNodes(v);
} else {
// Original behavior for ≤50 nodes: allow "All" when slider reaches max
setMaxNodes(v >= effectiveMax ? undefined : v);
}
}}
className="w-full"
/>
</div>
<p className="text-xs text-muted-foreground">
{t("allLinksVisible")}
{graph2DData.nodes.length > 50 && (
<span className="block text-amber-600 dark:text-amber-400 mt-1">
{t("limitedTo50Nodes", { count: graph2DData.nodes.length })}
</span>
)}
</p>
</div>
</div>
<div className="border-t border-border" />
{/* Hint */}
<div className="text-xs text-muted-foreground/60 text-center pt-2">
{t("clickNodeForDetails")}
</div>
{(compactMode || viewMode === "constellation") && (
<div className="space-y-3">
{/* Constellation controls moved out of the old side panel to sit
inline above the graph, next to the view toggle / filters. */}
{!compactMode && (
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
{factType === "observation" && (
<div className="flex items-center gap-2">
<Layers className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">
{t("groupByScope")}
</span>
<Switch checked={groupByScope} onCheckedChange={setGroupByScope} />
</div>
)}
{!(factType === "observation" && groupByScope) && (
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-muted-foreground">
{t("colorBy")}
</span>
<Select
value={recencyBasis}
onValueChange={(v) => setRecencyBasis(v as RecencyBasis)}
>
<SelectTrigger className="h-8 w-44 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mentioned_at">{t("mentioned")}</SelectItem>
<SelectItem value="occurred_start">{t("occurredStart")}</SelectItem>
<SelectItem value="occurred_end">{t("occurredEnd")}</SelectItem>
</SelectContent>
</Select>
</div>
)}
<div className="flex items-center gap-3">
<span className="text-xs font-medium text-muted-foreground">
{t("linkTypes")}
</span>
{Object.entries({
semantic: "#0074d9",
temporal: "#009296",
entity: "#f59e0b",
causal: "#8b5cf6",
}).map(([type, color]) => (
<button
key={type}
type="button"
className="flex items-center gap-1.5"
onClick={() => toggleLinkType(type)}
>
<span
className="w-3 h-3 rounded-full"
style={{
backgroundColor: color,
opacity: visibleLinkTypes.has(type) ? 1 : 0.2,
}}
/>
<span
className={`text-xs capitalize ${visibleLinkTypes.has(type) ? "text-foreground" : "text-muted-foreground line-through"}`}
>
{type}
</span>
</button>
))}
</div>
</div>
</div>
</div>
)}
)}
{(compactMode || viewMode === "constellation") && (
<div className="flex gap-0">
<div className="flex-1 min-w-0 border border-border rounded-lg overflow-hidden">
<div className="border border-border rounded-lg overflow-hidden">
<Constellation
key={compactMode ? "compact" : "full"}
data={graph2DData}
@@ -1049,119 +812,6 @@ export function DataView({
}
/>
</div>
{/* Right Toggle Button + Panel (hidden in compact mode) */}
{!compactMode && (
<>
<button
onClick={() => setShowControlPanel(!showControlPanel)}
className="flex-shrink-0 w-5 h-[700px] bg-transparent hover:bg-muted/50 flex items-center justify-center transition-colors"
title={showControlPanel ? t("hidePanel") : t("showPanel")}
>
{showControlPanel ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronLeft className="w-3 h-3 text-muted-foreground" />
)}
</button>
{/* Right Panel — reuse the same panel as graph view */}
{showControlPanel && (
<div className="w-72 flex-shrink-0 border border-border rounded-lg bg-muted/20 overflow-y-auto h-[700px]">
{selectedGraphNode ? (
<MemoryDetailPanel
memory={selectedGraphNode}
onClose={() => setSelectedGraphNode(null)}
inPanel
bankId={currentBank || undefined}
/>
) : (
<div className="p-4 space-y-4">
<h3 className="text-sm font-semibold text-foreground">
{t("constellationViewTitle")}
</h3>
<p className="text-xs text-muted-foreground">
{t("constellationViewDescription")}
</p>
{factType === "observation" && (
<div className="flex items-center justify-between gap-2 pt-2">
<div className="flex items-center gap-1.5">
<Layers className="w-3.5 h-3.5 text-muted-foreground" />
<h4 className="text-xs font-medium text-muted-foreground">
{t("groupByScope")}
</h4>
</div>
<Switch checked={groupByScope} onCheckedChange={setGroupByScope} />
</div>
)}
{!(factType === "observation" && groupByScope) && (
<div className="space-y-2 pt-2">
<h4 className="text-xs font-medium text-muted-foreground">
{t("colorBy")}
</h4>
<Select
value={recencyBasis}
onValueChange={(v) => setRecencyBasis(v as RecencyBasis)}
>
<SelectTrigger className="h-8 w-full text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mentioned_at">{t("mentioned")}</SelectItem>
<SelectItem value="occurred_start">
{t("occurredStart")}
</SelectItem>
<SelectItem value="occurred_end">{t("occurredEnd")}</SelectItem>
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2 pt-2">
<h4 className="text-xs font-medium text-muted-foreground">
{t("linkTypes")}
</h4>
{Object.entries({
semantic: "#0074d9",
temporal: "#009296",
entity: "#f59e0b",
causal: "#8b5cf6",
}).map(([type, color]) => (
<div
key={type}
className="flex items-center gap-2 cursor-pointer"
onClick={() => toggleLinkType(type)}
>
<div
className="w-3 h-3 rounded-full"
style={{
backgroundColor: color,
opacity: visibleLinkTypes.has(type) ? 1 : 0.2,
}}
/>
<span
className={`text-xs capitalize ${visibleLinkTypes.has(type) ? "text-foreground" : "text-muted-foreground line-through"}`}
>
{type}
</span>
</div>
))}
</div>
<div className="text-xs text-muted-foreground space-y-1 pt-2">
<div>
{t("nodes")}:{" "}
<span className="text-foreground">{graph2DData.nodes.length}</span>
</div>
<div>
{t("links")}:{" "}
<span className="text-foreground">{graph2DData.links.length}</span>
</div>
</div>
</div>
)}
</div>
)}
</>
)}
</div>
)}
@@ -1396,9 +1046,7 @@ export function DataView({
})()
) : (
<div className="text-center py-12 text-muted-foreground">
{data.table_rows?.length > 0
? t("noMemoriesMatchFilter")
: t("noMemoriesFound")}
{hasActiveMemoryFilters ? t("noMemoriesMatchFilter") : t("noMemoriesFound")}
</div>
)}
</div>
@@ -22,7 +22,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { Constellation } from "./constellation";
import { convertHindsightGraphData, GraphNode } from "./graph-2d";
import { convertHindsightGraphData, GraphNode } from "./graph-data";
type EntityGraphResponse = Awaited<ReturnType<typeof client.getEntityGraph>>;
@@ -1,726 +0,0 @@
"use client";
import { useRef, useEffect, useState, useMemo } from "react";
import { useTranslations } from "next-intl";
import cytoscape from "cytoscape";
import fcose from "cytoscape-fcose";
// Register the fcose extension
cytoscape.use(fcose);
// Hook to detect dark mode
function useIsDarkMode() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const checkDark = () => {
setIsDark(document.documentElement.classList.contains("dark"));
};
checkDark();
// Watch for theme changes
const observer = new MutationObserver(checkDark);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect();
}, []);
return isDark;
}
// ============================================================================
// Types & Interfaces
// ============================================================================
export interface GraphNode {
id: string;
label?: string;
color?: string;
size?: number;
group?: string;
metadata?: Record<string, any>;
}
export interface GraphLink {
source: string;
target: string;
color?: string;
width?: number;
type?: string;
entity?: string;
weight?: number;
metadata?: Record<string, any>;
}
export interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
}
export interface Graph2DProps {
data: GraphData;
height?: number;
showLabels?: boolean;
onNodeClick?: (node: GraphNode) => void;
onNodeHover?: (node: GraphNode | null) => void;
nodeColorFn?: (node: GraphNode) => string;
nodeSizeFn?: (node: GraphNode) => number;
linkColorFn?: (link: GraphLink) => string;
linkWidthFn?: (link: GraphLink) => number;
maxNodes?: number;
}
// ============================================================================
// Default Values
// ============================================================================
// Brand colors
const BRAND_PRIMARY = "#0074d9";
const LINK_SEMANTIC = "#0074d9"; // Primary blue for semantic
const DEFAULT_NODE_COLOR = BRAND_PRIMARY;
const DEFAULT_LINK_COLOR = LINK_SEMANTIC;
const DEFAULT_LINK_WIDTH = 1;
// ============================================================================
// Component
// ============================================================================
export function Graph2D({
data,
height = 600,
showLabels = true,
onNodeClick,
onNodeHover,
nodeColorFn,
nodeSizeFn,
linkColorFn,
linkWidthFn,
maxNodes,
}: Graph2DProps) {
const t = useTranslations("graph2d");
const [containerDiv, setContainerDiv] = useState<HTMLDivElement | null>(null);
const cyRef = useRef<any>(null);
const isInitializingRef = useRef(false);
const lastDataSignatureRef = useRef<string>("");
const [_hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
const [hoveredLink, setHoveredLink] = useState<GraphLink | null>(null);
const [linkTooltipPos, setLinkTooltipPos] = useState<{ x: number; y: number } | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isMounted, setIsMounted] = useState(false);
const [isFocusMode, setIsFocusMode] = useState(false);
const isDarkMode = useIsDarkMode();
// Use refs to store callbacks and data to prevent re-renders from resetting the graph
const onNodeClickRef = useRef(onNodeClick);
const onNodeHoverRef = useRef(onNodeHover);
const fullDataRef = useRef(data);
const nodeColorFnRef = useRef(nodeColorFn);
const linkColorFnRef = useRef(linkColorFn);
const isFocusModeRef = useRef(isFocusMode);
onNodeClickRef.current = onNodeClick;
onNodeHoverRef.current = onNodeHover;
fullDataRef.current = data;
nodeColorFnRef.current = nodeColorFn;
linkColorFnRef.current = linkColorFn;
isFocusModeRef.current = isFocusMode;
// Transform and limit data - only limit nodes, show ALL links between visible nodes
const graphData = useMemo(() => {
let nodes = [...data.nodes];
// Limit nodes if needed
if (maxNodes && nodes.length > maxNodes) {
nodes = nodes.slice(0, maxNodes);
}
// Show ALL links between visible nodes (no random link limiting)
const nodeIds = new Set(nodes.map((n) => n.id));
const links = data.links.filter((l) => nodeIds.has(l.source) && nodeIds.has(l.target));
return { nodes, links };
}, [data, maxNodes]);
// Track mounting state
useEffect(() => {
setIsMounted(true);
return () => setIsMounted(false);
}, []);
// Convert to Cytoscape format
const cyElements = useMemo(() => {
// Calculate node importance based on connections
const nodeConnections = new Map<string, number>();
graphData.links.forEach((link) => {
nodeConnections.set(link.source, (nodeConnections.get(link.source) || 0) + 1);
nodeConnections.set(link.target, (nodeConnections.get(link.target) || 0) + 1);
});
const nodes = graphData.nodes.map((node) => {
const connections = nodeConnections.get(node.id) || 0;
const dynamicSize = nodeSizeFn
? nodeSizeFn(node)
: Math.max(16, Math.min(40, 16 + connections * 4)); // Smaller, more subtle sizing
return {
data: {
id: node.id,
label: node.label || node.id.substring(0, 8),
color: nodeColorFn ? nodeColorFn(node) : node.color || DEFAULT_NODE_COLOR,
size: node.size || dynamicSize,
originalNode: node,
connections: connections,
},
};
});
const edges = graphData.links.map((link, idx) => ({
data: {
id: `edge-${idx}`,
source: link.source,
target: link.target,
color: linkColorFn ? linkColorFn(link) : link.color || DEFAULT_LINK_COLOR,
width: linkWidthFn ? linkWidthFn(link) : link.width || DEFAULT_LINK_WIDTH,
type: link.type,
entity: link.entity,
weight: link.weight,
originalLink: link,
},
}));
return [...nodes, ...edges];
}, [graphData, nodeColorFn, nodeSizeFn, linkColorFn, linkWidthFn]);
// Create data signature to prevent double initialization
const dataSignature = useMemo(() => {
return JSON.stringify({
nodeCount: graphData.nodes.length,
linkCount: graphData.links.length,
nodeIds: graphData.nodes
.map((n) => n.id)
.sort()
.join(","),
showLabels,
isDarkMode,
maxNodes,
});
}, [graphData.nodes, graphData.links, showLabels, isDarkMode, maxNodes]);
// Initialize Cytoscape
useEffect(() => {
let isCancelled = false;
// Small delay to ensure container is mounted
const timeout = setTimeout(() => {
if (isCancelled || !isMounted || !containerDiv || isInitializingRef.current) return;
// Check if data has actually changed to prevent double initialization
if (lastDataSignatureRef.current === dataSignature) {
console.log("Data signature unchanged, skipping graph initialization");
return;
}
// Additional validation - check if element has dimensions
const rect = containerDiv.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
console.warn("Container has no dimensions, skipping cytoscape initialization");
setIsLoading(false);
return;
}
// Handle empty data case
if (cyElements.length === 0) {
setIsLoading(false);
return;
}
// Check if we already have a graph with the same data
if (cyRef.current && !cyRef.current.destroyed()) {
const currentNodes = cyRef.current.nodes().length;
const currentEdges = cyRef.current.edges().length;
const newNodes = cyElements.filter((el) => !(el.data as any).source).length;
const newEdges = cyElements.filter((el) => (el.data as any).source).length;
// If the element counts are the same, just update styles and skip reinitialization
if (currentNodes === newNodes && currentEdges === newEdges) {
console.log("Graph already initialized with same data, skipping reinitialization");
setIsLoading(false);
return;
}
// Clean up existing graph before creating new one
console.log("Data changed, destroying existing graph");
cyRef.current.destroy();
cyRef.current = null;
}
setIsLoading(true);
isInitializingRef.current = true;
// Theme-aware colors
const textColor = isDarkMode ? "#ffffff" : "#1f2937";
const textBgColor = isDarkMode ? "rgba(0,0,0,0.8)" : "rgba(255,255,255,0.9)";
try {
console.log("Initializing cytoscape with container:", containerDiv);
console.log("Elements count:", cyElements.length);
console.log("Sample elements:", cyElements.slice(0, 2));
// Try minimal initialization first
const cy = cytoscape({
container: containerDiv,
elements: [],
// Disable edge selection to prevent gray border on click
selectionType: "single",
userZoomingEnabled: true,
userPanningEnabled: true,
boxSelectionEnabled: false,
// Disable automatic layout on initialization
layout: { name: "preset" },
style: [
{
selector: "node",
style: {
"background-color": "data(color)",
width: "data(size)",
height: "data(size)",
label: showLabels ? "data(label)" : "",
color: textColor,
"text-valign": "bottom",
"text-halign": "center",
"font-size": "8px",
"font-weight": 500,
"text-margin-y": 3,
"text-wrap": "wrap",
"text-max-width": "80px",
"text-background-color": textBgColor,
"text-background-opacity": 0.9,
"text-background-padding": "2px",
"text-background-shape": "roundrectangle",
"border-width": 1,
"border-color": isDarkMode ? "#ffffff20" : "#00000020",
"border-opacity": 0.3,
},
},
{
selector: "node:selected",
style: {
"border-width": 3,
"border-color": "#0074d9",
"border-opacity": 1,
},
},
{
selector: "edge",
style: {
width: "data(width)",
"line-color": "data(color)",
"target-arrow-color": "data(color)",
"target-arrow-shape": "triangle",
"target-arrow-size": 6,
"curve-style": "bezier",
opacity: isDarkMode ? 0.6 : 0.7,
},
},
// Focus mode styles
{
selector: ".dimmed",
style: {
opacity: 0.2,
},
},
{
selector: ".focused",
style: {
"border-width": 4,
"border-color": "#ff6b35",
"border-opacity": 1,
"z-index": 999,
},
},
{
selector: ".connected",
style: {
"border-width": 2,
"border-color": "#0074d9",
"border-opacity": 0.8,
opacity: 1,
},
},
{
selector: "edge.connection",
style: {
width: 2,
opacity: 1,
"z-index": 100,
},
},
{
selector: "edge.connection:hover",
style: {
width: 3,
opacity: 1,
"z-index": 200,
},
},
// Disable edge selection styling
{
selector: "edge:selected",
style: {
"overlay-opacity": 0,
"overlay-color": "transparent",
"overlay-padding": 0,
},
},
],
});
cyRef.current = cy;
console.log("Cytoscape initialized successfully");
// Add elements after initialization
if (cyElements.length > 0) {
console.log("Adding elements to cytoscape");
cy.add(cyElements);
cy.layout({
name: "fcose",
quality: "default",
randomize: false,
animate: true,
animationDuration: 1500,
// Separation settings - increase to spread nodes more
nodeSeparation: 200,
idealEdgeLength: () => 250,
edgeElasticity: () => 0.05,
nestingFactor: 0.05,
gravity: 0.05, // Reduced gravity spreads nodes more
numIter: 2500,
// Overlap prevention
nodeOverlap: 30,
avoidOverlap: true,
nodeDimensionsIncludeLabels: true,
// Layout bounds - reduce padding to use more space
padding: 20,
boundingBox: undefined,
// Tiling - increase spacing between disconnected components
tile: true,
tilingPaddingVertical: 30,
tilingPaddingHorizontal: 30,
// Force more spread
uniformNodeDimensions: false,
packComponents: false, // Don't pack components tightly
}).run();
// Fit to viewport
cy.fit();
}
// Add basic interactions
cy.on("tap", "node", (evt: any) => {
const node = evt.target as cytoscape.NodeSingular;
const originalNode = node.data("originalNode") as GraphNode;
if (onNodeClickRef.current && originalNode) {
onNodeClickRef.current(originalNode);
}
});
cy.on("mouseover", "node", (evt: any) => {
const node = evt.target as cytoscape.NodeSingular;
const originalNode = node.data("originalNode") as GraphNode;
setHoveredNode(originalNode);
if (onNodeHoverRef.current && originalNode) {
onNodeHoverRef.current(originalNode);
}
if (containerDiv) containerDiv.style.cursor = "pointer";
});
cy.on("mouseout", "node", () => {
setHoveredNode(null);
if (onNodeHoverRef.current) {
onNodeHoverRef.current(null);
}
if (containerDiv) containerDiv.style.cursor = "default";
});
// Edge hover handlers - only work in focus mode and on highlighted edges
cy.on("mouseover", "edge", (evt: any) => {
const edge = evt.target;
// Only allow interaction if we're in focus mode and edge is highlighted
if (!isFocusModeRef.current || !edge.hasClass("connection")) {
return;
}
const originalLink = edge.data("originalLink") as GraphLink;
if (originalLink) {
setHoveredLink(originalLink);
// Get position for tooltip
const renderedPos = edge.renderedMidpoint();
setLinkTooltipPos({ x: renderedPos.x, y: renderedPos.y });
}
});
cy.on("mouseout", "edge", (evt: any) => {
const edge = evt.target;
// Only clear hover state if we were actually hovering a highlighted edge
if (!isFocusModeRef.current || !edge.hasClass("connection")) {
return;
}
setHoveredLink(null);
setLinkTooltipPos(null);
});
// Prevent edge selection to avoid gray border on click
cy.on("select", "edge", (evt: any) => {
evt.target.unselect();
});
// Double-click to focus on node and its connections
cy.on("dblclick", "node", (evt: any) => {
const focusedNode = evt.target as cytoscape.NodeSingular;
const focusedNodeId = focusedNode.id();
console.log("Double-clicked node:", focusedNodeId);
// Enter focus mode
setIsFocusMode(true);
// Clear any existing focus classes
cy.elements().removeClass("dimmed focused connected connection");
// Get all connected nodes and edges
const connectedElements = focusedNode.neighborhood();
const connectedNodes = connectedElements.nodes();
const connectedEdges = connectedElements.edges();
// Apply styling classes
cy.elements().addClass("dimmed"); // Dim everything first
focusedNode.removeClass("dimmed").addClass("focused"); // Highlight the focused node
connectedNodes.removeClass("dimmed").addClass("connected"); // Highlight connected nodes
connectedEdges.removeClass("dimmed").addClass("connection"); // Highlight connecting edges
// Create a collection of all relevant elements for positioning
const relevantElements = focusedNode.union(connectedElements);
// Reorient the graph to focus on this subgraph
cy.animate(
{
fit: {
eles: relevantElements,
padding: 100,
},
center: {
eles: focusedNode,
},
},
{
duration: 800,
easing: "ease-out-cubic",
}
);
});
// Click on background to reset focus
cy.on("tap", (evt: any) => {
if (evt.target === cy) {
console.log("Clicked background - resetting focus");
// Exit focus mode
setIsFocusMode(false);
// Remove all focus classes
cy.elements().removeClass("dimmed focused connected connection");
// Zoom out to show all elements
cy.animate(
{
fit: {
eles: cy.elements(),
padding: 50,
},
},
{
duration: 600,
easing: "ease-out",
}
);
}
});
setIsLoading(false);
isInitializingRef.current = false;
lastDataSignatureRef.current = dataSignature;
} catch (error) {
console.error("Error initializing cytoscape:", error);
setIsLoading(false);
isInitializingRef.current = false;
}
}, 100); // 100ms delay
return () => {
isCancelled = true;
clearTimeout(timeout);
isInitializingRef.current = false;
if (cyRef.current) {
cyRef.current.destroy();
cyRef.current = null;
}
};
}, [dataSignature, isMounted, containerDiv]);
// Handle resize
useEffect(() => {
const handleResize = () => {
if (cyRef.current) {
cyRef.current.resize();
cyRef.current.fit(undefined, 80);
}
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return (
<div
className="relative w-full rounded-lg overflow-hidden border border-border"
style={{ height }}
>
{/* Loading state */}
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-background z-10">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4" />
<p className="text-sm text-muted-foreground">{t("loading")}</p>
</div>
</div>
)}
{/* Cytoscape container */}
{isMounted && (
<div
ref={setContainerDiv}
className="w-full h-full"
style={{
backgroundImage: isDarkMode
? "radial-gradient(circle at 1px 1px, rgba(255,255,255,0.08) 1px, transparent 0)"
: "radial-gradient(circle at 1px 1px, rgba(0,0,0,0.06) 1px, transparent 0)",
backgroundSize: "20px 20px",
backgroundColor: isDarkMode ? "#0f1419" : "#f8fafc",
}}
/>
)}
{/* Empty state */}
{!isLoading && graphData.nodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground">{t("emptyState")}</p>
</div>
</div>
)}
{/* Link hover tooltip */}
{hoveredLink && linkTooltipPos && (
<div
className="absolute z-30 pointer-events-none"
style={{
left: linkTooltipPos.x,
top: linkTooltipPos.y,
transform: "translate(-50%, -100%) translateY(-8px)",
}}
>
<div
className={`px-3 py-2 rounded-lg shadow-lg text-sm ${
isDarkMode
? "bg-gray-800 text-white"
: "bg-white text-gray-900 border border-gray-200"
}`}
>
<div className="font-medium capitalize mb-1">
{(() => {
const type = hoveredLink.type || "semantic";
if (["causes", "caused_by", "enables", "prevents"].includes(type)) {
return t("linkTypeCausal", { type: type.replace("_", " ") });
}
return t("linkTypeGeneric", { type });
})()}
</div>
{hoveredLink.entity && (
<div className="text-xs opacity-80">
{t("linkTooltipEntity")} <span className="font-medium">{hoveredLink.entity}</span>
</div>
)}
{hoveredLink.weight !== undefined && (
<div className="text-xs opacity-80">
{t("linkTooltipWeight")}{" "}
<span className="font-medium">{hoveredLink.weight.toFixed(3)}</span>
</div>
)}
</div>
</div>
)}
{/* Controls hint */}
<div className="absolute bottom-4 right-4 text-xs text-muted-foreground/60 z-20">
{t("controlsHint")}
</div>
</div>
);
}
// ============================================================================
// Utility Functions
// ============================================================================
export function convertHindsightGraphData(hindsightData: {
nodes?: Array<{ data: { id: string; label?: string; color?: string } }>;
edges?: Array<{
data: {
source: string;
target: string;
color?: string;
lineStyle?: string;
linkType?: string;
entityName?: string;
weight?: number;
similarity?: number;
};
}>;
table_rows?: Array<{ id: string; text: string; entities?: string; context?: string }>;
}): GraphData {
const nodes: GraphNode[] = (hindsightData.nodes || []).map((n) => {
const tableRow = hindsightData.table_rows?.find((r) => r.id === n.data.id);
// Use memory text as label, truncated to ~40 chars
let label = n.data.label;
if (!label && tableRow?.text) {
label = tableRow.text.length > 40 ? tableRow.text.substring(0, 40) + "..." : tableRow.text;
}
if (!label) {
label = n.data.id.substring(0, 8);
}
return {
id: n.data.id,
label,
color: n.data.color,
metadata: tableRow,
};
});
const links: GraphLink[] = (hindsightData.edges || []).map((e) => ({
source: e.data.source,
target: e.data.target,
color: e.data.color,
// Use linkType directly from API, fallback to lineStyle check, default to semantic
type: e.data.linkType || (e.data.lineStyle === "dashed" ? "temporal" : "semantic"),
entity: e.data.entityName, // API returns entityName
weight: e.data.weight ?? e.data.similarity,
}));
return { nodes, links };
}

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