mirror of
https://github.com/vectorize-io/hindsight.git
synced 2026-09-14 19:31:49 +08:00
5d46f9c8c8eb4fb96f549aa63abe1191b82a7840
95 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5d46f9c8c8 |
Release v0.10.0
- Update version to 0.10.0 in all components - Regenerate OpenAPI spec and client SDKs - Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed - Python client: hindsight-clients/python - TypeScript client: hindsight-clients/typescript - hindsight-all npm wrapper: hindsight-all-npm - Rust CLI: hindsight-cli - Control Plane: hindsight-control-plane - Helm chart - Create documentation version-0.10 |
||
|
|
31a8c52db5 |
refactor: replace httpx and sync HTTP with aiohttp across production code (#4318)
Production code now talks HTTP through aiohttp only, and only asynchronously. httpx's async client is 5-12x slower per request than aiohttp at the concurrency we run embeddings/rerank/LLM calls with (local stub: 1.3k vs 6.8k req/s at c=8, 527 vs 6.3k at c=64), and the sync paths needed a thread per in-flight request. - engine/aiohttp_session.py: LoopLocal / LoopLocalSession (one session per event loop, created lazily, closed-loop entries released), per_phase_timeout (httpx-style per-phase timeouts), raise_for_status -> UpstreamHTTPError (keeps body + status_code for remote_retry), close_loop_sessions() called from MemoryEngine.close(). - Embeddings interface is async (encode/encode_query/encode_documents); remote providers use aiohttp or the SDK's async client, bounded fan-out via a per-loop semaphore instead of a thread pool. Local models stay in a worker thread. - Rerankers, Codex/Nous/xAI OAuth providers (async token refresh with a per-loop asyncio.Lock + non-blocking flock), Fireworks, Ollama-native, llama.cpp, LlamaParse and Iris parsers moved to aiohttp. - Webhook SSRF guard re-implemented as an aiohttp resolver that only returns validated addresses (no DNS cache, no redirects, no env proxies), with IP literals checked before sending. - hindsight-embed probes, supabase-tenant extension and the litellm integration's async hooks moved to aiohttp. - ruff TID251 bans httpx/requests/urllib.request/urllib3/http.client in hindsight-api-slim and hindsight-embed production code; code-review skill documents the rule (tests exempt). |
||
|
|
d9df6d2a4d |
perf(recall): three CPU cuts on the recall path (audit serialization, phase sampling, on-loop query embedding) (#4314)
* perf(api): serialize a recall's audit row once, and decode embedding batches with orjson Two costs on every recall that a CPU profile of a recall-heavy API put at ~5% of its busy CPU (450 recalls/s, 2 vCPU): - The HTTP audit wrapper built a Python dict of the whole response with model_dump(mode="json") on the request path, and the writer then re-encoded that dict with json.dumps (3.5% alone). A pydantic response now goes straight to JSON with model_dump_json(), one pass in Rust, carried on AuditEntry.response_json. Anything else still goes through _safe_json, which uses orjson when it is installed. The stored document is the same; the column is JSON, so key order and escaping are not observable. - A TEI embedding batch is a large JSON array of floats, parsed by the stdlib decoder behind response.json() (1.4%). orjson.loads(response.content) when available. orjson is optional in both places: without it the previous code path runs unchanged. * perf(metrics): opt-in sampling for recall-phase observations Every recall records ~10 phase histograms, and OTel's aggregation behind them was ~4.6% of a recall-heavy API's busy CPU. HINDSIGHT_API_RECALL_PHASE_SAMPLE_EVERY=N records 1 in N calls, sampled independently per call, so each phase's distribution -- and its percentiles -- stay unbiased; only absolute counts scale by 1/N. The default of 1 records everything, as before. * perf(embeddings): embed a recall query on the event loop instead of a worker thread A recall embeds one short string. On the thread path that costs an executor hop plus httpx's pure-Python sync stack: 1.02 ms of CPU per query, against 0.39 ms for an aiohttp request made on the loop (measured in-process against the same TEI server; identical vectors). RemoteTEIEmbeddings.aencode_query makes one attempt and returns None on any failure, so the existing thread path and its retry policy still handle every error. It also declines when uninitialized or when a test injected a client. generate_embeddings_batch sends both paths through the same alignment and vector validation. * fix(recall-perf): declare orjson, move phase sampling into config orjson was imported behind an ImportError guard but never declared, so it was not installed and both speedups (audit rows, TEI decode) were dead code; ty also failed on the unresolved import. Declare it and drop the fallbacks. HINDSIGHT_API_RECALL_PHASE_SAMPLE_EVERY was read straight from the environment in metrics.py; it is now a HindsightConfig field with docs and env-template entries, plus a sampling unit test. The TEI embedder's aiohttp session attributes are initialised in __init__ instead of via getattr. * docs: regenerate docs skill for the recall-phase sampling flag * test(tei): hold per-thread clients so a freed client's reused id can't read as shared |
||
|
|
d11371c2ba |
perf(db): skip asyncpg's release-time reset (#4259)
The pool releases a connection without asyncpg's reset query. That query runs on every release and cost ~1.9% of a recall-heavy API's busy CPU in a py-spy profile (the reset and release frames). Measured on a CPU-bound 2 vCPU API pod: 9.8 ms of CPU per request instead of 10.5, and a ceiling of 182 rps instead of 175. Safety depends on what the reset was cleaning up. Its first statement is `SELECT pg_advisory_unlock_all()`, so a session-scoped advisory lock taken on a pooled connection would now outlive its holder. Core has one session-scoped lock: the migration runner's `pg_try_advisory_lock`. It runs on SQLAlchemy's own connection, not this pool, and releases explicitly. Extensions that take session-scoped locks on the pool must release them explicitly or use transaction-scoped locks. `db_session_setup_on_acquire` stays on by default, because a transaction-mode pooler still needs session settings re-applied on every acquire. |
||
|
|
6f441b0aeb |
revert: drop free-threaded CPython 3.14 support (#4037, #4067) (#4234)
Load testing did not justify the maintenance cost of the -py3.14t target, so this removes it and the multi-loop server built on top of it. Removed outright: - `hindsight_api/_free_threading.py` and `HINDSIGHT_API_FREE_THREADING` — the guard that turned CPython's GIL-re-enable RuntimeWarning into an error. - `docker/standalone/Dockerfile.freethreaded`, `docker/freethreaded-smoke.sh`, the `test-api (free-threaded 3.14)` CI job, and the `-py3.14t` release image (including its `latest=false` carve-out in the image metadata step). - `multi_loop.py`, `HINDSIGHT_API_EVENT_LOOPS` / `--event-loops`, and `_serve_multi_loop`. Several event loops in one process is only a throughput win without the GIL; on a stock build the loops take turns, which `main.py` already warned about. Multi-loop hooks reverted with it: `run_background_tasks` on MemoryEngine and both `create_app`s, `LLMTraceRecorder.bind_loop` and its per-loop filter, and — from the #4123 follow-up — `ExtensionContext.is_primary` plus the thread-local context in `Extension`. With one loop per process the flag is permanently True and only one context is ever set, so both were dead weight on a public extension interface. `HINDSIGHT_API_MIGRATION_ISOLATION` loses its `auto` mode and now defaults to `false`. `auto` isolated only on a free-threaded interpreter, so this changes nothing for any existing deployment; `true`/`false` still force it either way. Kept, because they are real races that threads hit under the GIL too and only their rationale was free-threading-specific: the dateparser lock and the `regex>=2026.9.3` floor, one TEI HTTP client per thread, the shared bounded embeddings request pool, `bank_stats_cache`'s per-loop coalescing, and `_cross_loop.py` (still used by llm_wrapper, cross_encoder and llamacpp_llm). Their comments now stand on plain thread/loop-safety grounds. Ordinary Python 3.14 is untouched: the `build-api-python-versions` matrix still covers 3.11-3.14 and the litellm >=1.93.0 cp314 floor stays. Verified: lint.sh, ty, and the deterministic suite (8645 passed). OpenAPI and the generated clients show no drift, and the two .env.example copies stay byte-identical. |
||
|
|
aeaf4b1bdc |
fix(local-ml): floor torch at 2.11.0 so arm64 images run on ARMv8.0 CPUs (#4201)
torch 2.10.0's aarch64 wheel ships a libc10.so whose bundled mimalloc runs
an inline ARMv8.1 LSE atomic from an ELF constructor:
Program received signal SIGILL, Illegal instruction.
0xffff6f660fec in _mi_options_init () from torch/lib/libc10.so
=> ldaddal x22, x22, [x0]
#1 _mi_auto_process_init ()
Because that runs at dlopen time, `import torch` dies with SIGILL on any
CPU without LSE (ARMv8.0 — e.g. the Cortex-A57 in Annapurna AL324 NAS
boxes) before a single line of Python executes, taking the whole API down
with exit 132.
The dependency was only floored at >=2.6.0, so the lock was free to drift
onto 2.10.0 — which is what shipped in the 0.9.x arm64 images (0.8.2 had
resolved 2.12.0 and ran fine on the same hardware, which is why the
reporter's bisect pointed at the wrong release).
Verified with qemu-user `-cpu cortex-a57` (no LSE) inside the real arm64
image, disassembling torch/lib/libc10.so per version:
2.10.0 185 LSE instructions, 1 inline in _mi_options_init -> SIGILL
2.11.0 11 LSE instructions, 0 in _mi_options_init -> ok
2.12.0 11 LSE instructions, 0 in _mi_options_init -> ok
2.13.0 11 LSE instructions, 0 in _mi_options_init -> ok
The remaining 11 are the runtime-dispatched __aarch64_* outline-atomics
helpers, which are guarded by an ifunc HWCAP check and safe on ARMv8.0.
Resolution is unchanged (uv.lock stays on 2.13.0+cpu); the floor just
makes the broken release unreachable.
Fixes #4142
|
||
|
|
7e17a120e2 |
chore: update the tagline to "Agent Memory That Learns" (#4093)
The startup banner, package descriptions, docs-site tagline and llms.txt still carried "Agent Memory That Works Like Human Memory". The project's one-liner is now "Agent Memory That Learns" — align every published copy so they don't keep drifting apart. Covered: the banner printed on API startup, the four package descriptions that surface on PyPI, the two bundle READMEs, the Docusaurus tagline, the llms.txt header, and the header the llms-full.txt generator emits. Adds tests/test_banner.py — there was no coverage of banner.py at all, and the tagline is the first thing a user sees. The gradient wraps every character in an ANSI escape, so the tests strip the escapes and assert on the text that actually reads, plus the gradient endpoints and the logo-above-tagline ordering. Confirmed they fail if the tagline regresses. Claude-Session: https://claude.ai/code/session_011mnzArjiCdBxa8dh1H47Fa |
||
|
|
253c0bd053 | chore(deps): remove unused tornado dependency (#4111) | ||
|
|
a00c096ee6 |
fix(temporal): stop dateparser segfaulting the API under free-threading (#4110)
* fix(temporal): serialise entry into dateparser's process-global caches dateparser builds its locale dictionaries, split/match regexes and word lists lazily into caches shared by the whole process -- class-level on `Dictionary`, per-instance in `Locale.dictionaries` -- and guards none of them. `MemoryEngine.initialize` warms its analyzer with `run_in_executor(None, ...)`, so a process running an event loop per thread warms N analyzers on the *unbounded* default executor at once, each sweeping all 200+ locales. On free-threaded CPython that killed the API at startup for any configuration above one event loop: reproduced on the py3.14t image at exit 139 within seconds, 3/3, every trace bottoming out in `best_language` from `_find_dates`. On a GIL build the same race surfaces as an occasional `RuntimeError: dictionary changed size during iteration`. Two locks existed and neither covered the whole thing: a thread inside `_ensure_dictionary_warm` held `_warm_lock` while others mutated the same `Locale` objects through `_char_tables`, which computed its result *before* taking `_char_table_lock` and guarded only the assignment. One reproduction crashed inside the lock-guarded call for exactly that reason. - `_char_tables` now computes under its lock, double-checked, matching `_ensure_dictionary_warm`. `get_wordchars_for_detection` is not a read: on a miss it builds each locale's dictionary and writes it into `Locale.dictionaries` and dateparser's class-level regex caches. - A process-wide `_DATEPARSER_LOCK` wraps `load()` and `_find_dates`, closing the gap between the two locks. It costs nothing on the recall path, which already funnels through the single-worker executor in `search/temporal_extraction.py`; what it adds is `load()`, which does not. The guard test asserts the invariant (one thread inside the build) rather than waiting for a segfault, so it fails deterministically on 3.11 too. * deps: floor regex at 2026.9.3, which does not segfault under free-threading Transitive via dateparser, but the floor is load-bearing on a free-threaded build. With the engine fix reverted, the same probe on the py3.14t image dies at exit 139 in round 0 on regex 2025.11.3 and survives 3/3 rounds on 2026.9.3 -- same image, same code, only the wheel swapped. The floor belongs in pyproject rather than the lock alone: the free-threaded image resolves its own dependency set instead of uv.lock's, so a constraint is the only thing that reaches it. This is the second of two independent guards. The lock stops the data race; this stops that race from being a fatal signal if it is ever reachable by a path the lock does not cover. * docs: correct the comment that made the unlocked build look safe _char_tables' docstring called it "a pure function of the locale set". The result is deterministic, but the call is not side-effect free — on a miss it builds the locale dictionaries it then reads — and that sentence is why computing it outside the lock looked correct for as long as it did. |
||
|
|
828a6e55d3 |
refactor(retain): format pgvector literals without orjson, and drop orjson entirely (#4040)
* refactor(retain): format pgvector literals without orjson orjson was a single-purpose dependency: one function (`embedding_to_pgvector`), on the retain write path only, rendering an embedding as the '[0.1,...]' literal asyncpg binds to `vector`. It played no part in HTTP serialization — there is no ORJSONResponse in the API, and recall builds its query literal with plain str(). Measurement decided it. On `perf-test --suite retain --scale small` (200 items, 384d, mock LLM + pg0) the orjson calls totalled 2.9ms of a ~1.9s retain, and paired throughput runs could not tell the two implementations apart from noise (orjson 75-124 items/s, replacement 81-115). What replaces it is one C-level %-format call per vector against a per-dimension cached "[%.9g,...]" template: ~1.8x faster than the repr() generator that was already the non-finite fallback, ~5x slower than orjson. No pure-Python formatter closes that gap — orjson formats floats with SIMD Ryu in Rust. Beating it means not emitting text at all, which the benchmark now measures as `binary_pgvector` (~2 orders of magnitude faster, 2.8x fewer wire bytes) but which needs an asyncpg codec for the `vector` type and would still leave Oracle on the text path. Values are narrowed to float32 before formatting, exactly once. Applying %.9g straight to a float64 rounds twice — to nine digits, then to float32 — and the two roundings pick different neighbouring float32s for ~0.8% of values. Narrowing first makes the single remaining rounding the one PostgreSQL would have done, so stored bytes are unchanged. The new regression test samples 4,096 elements, where that rate misses by ~31. The literal is now fixed-width rather than shortest-form (0.100000001 where orjson wrote 0.1) — same float32, ~12% more SQL text. The orjson version floor stays in pyproject as a transitive-only pin (langsmith still pulls it), matching how pyjwt is handled. * build: drop orjson from the runtime dependency closure The previous commit removed the last import; this removes the package from what production resolves. orjson's only other path into the environment is langchain-text-splitters -> langchain-core -> langsmith -> orjson, and langchain has been test-only since #3756 — retain's chunker is its own streaming RecursiveCharacterTextSplitter, and langchain survives purely as the reference implementation test_chunking_matches_legacy.py diffs against (with a guard test asserting the reference is still really langchain). So the `orjson>=3.11.6` floor follows langsmith into the test extra and the dev group, exactly as the langchain-core and langsmith floors already did when langchain left the runtime. Deleting the floor outright would have dropped the unbounded-recursion DoS bound for the version langsmith pulls. uv export --no-dev -> orjson absent uv export -> orjson 3.11.7 (via langsmith, test only) uv.lock is unchanged. * test: vendor the chunking oracle and drop langchain, langsmith and orjson orjson's last path into the environment was the test extra: langchain-text-splitters -> langchain-core -> langsmith -> orjson. langchain was there for one reason — the differential chunking tests need the splitter retain used before #3756 as their oracle, and that oracle has to be genuinely the old implementation or the tests pass vacuously. So the oracle moves in-tree. tests/chunking_reference.py transcribes langchain 1.1.2's RecursiveCharacterTextSplitter (MIT), specialised to the one configuration retain ever used: chunk_overlap=0, keep_separator default "start", length_function=len, non-regex separators. The branches that configuration cannot reach are left out rather than transcribed untested. The transcription was verified against the real splitter before the dependency was removed: 20,079 comparisons — every case and size the chunking tests use, plus 20,000 seeded fuzz documents built from the fragment shapes that break splitters (empty strings, whitespace runs, CRLF, CJK, oversized tokens) — with zero mismatches. tests/test_chunking_reference_matches_langchain.py keeps that check runnable: uv run --with langchain-text-splitters pytest \ tests/test_chunking_reference_matches_langchain.py It skips by default, since langchain is deliberately absent. test_the_legacy_reference_really_is_the_old_implementation loses its live langchain call, so its anchor becomes a table of five expected splits — one per separator tier, including the per-character fallback — produced BY langchain and pasted in. A reference that quietly became the new implementation has to reproduce all five to slip through. uv export --no-dev -> no orjson, no langchain uv export -> no orjson, no langchain uv.lock loses 392 lines. |
||
|
|
bce43b8e14 |
perf(tokenizer): move token counting from quicktok to toktok-rs (#4022)
Swaps `quicktok-v1` for `toktok-rs` (vectorize-io/toktok), a Rust BPE tokenizer whose ids are byte-identical to tiktoken's, then collapses the module's interface onto the two operations the engine actually performs. The swap itself is behaviour-neutral: compared side by side over 258 texts (~250 real files from `hindsight_api/` plus the special-token and mixed-script edge cases), quicktok and toktok produce identical counts AND identical ids on all three shared encodings. No token budget, chunk boundary or truncation point moves. Interface. `_SafeEncoding` existed to force `disallowed_special=()` onto `encode()`. Every caller of the object it returned was doing either a plain `count` or `decode(encode(x)[:n])` open-coded — which is `truncate_to_tokens`. So the module's whole public surface is now `count_tokens`, `truncate_to_tokens` / `truncate_many_to_tokens`, and `BUNDLED_ENCODINGS`; the tokenizer itself is private. That keeps #1883 fixed by construction rather than by convention: every route to the raising `encode()` went through the accessor that is now `_load_encoding`. Character-boundary truncation (toktok 0.1.3). Truncation was `decode(encode(text)[:n])`, cutting on a *token* boundary. Byte-level BPE splits one character across several tokens (under o200k_base "🧠" is three), so a cut could land mid-character and decode to U+FFFD: truncate_to_tokens("hello 🧠", 2).text -> 'hello �' (before) -> 'hello ' (now) The native call also never builds ids, never decodes, and returns the original string object untouched when nothing needs cutting. `batch_truncate` replaces the Python loop in the two callers that truncate a whole list: every reranker document (both LiteLLM cross-encoders) and every embedding input. Two user-visible consequences: * `llama3` and `qwen3` are gone — quicktok bundled five vocabularies, toktok bundles three. `HINDSIGHT_API_TOKENIZER_ENCODING=llama3` now fails at the first token count with the existing "Unknown tokenizer encoding" ValueError. Docs and both env templates updated, and `BUNDLED_ENCODINGS` (which had been lying about those two) now has a test that loads every name it advertises. * A negative budget used to slice a list with a negative index, silently dropping tokens off the end; the native call would raise. It clamps to 0. Wheels: cp311-abi3 covers 3.11-3.14, so 3.14 no longer compiles from source the way quicktok did. No musllinux wheels, which is irrelevant to the shipped images (all Python stages are glibc python:3.11-slim). numpy drops to an optional extra, so the tokenizer pulls in no dependency of its own. Measured on this repo's text: 2-7x faster than tiktoken on cl100k_base, 10-16x on o200k_base; counting an 81k-token document peaks at 1 KiB vs ~3 MB. |
||
|
|
ac41cee604 |
feat(extensions): add hindsight-extensions registry and unbundle Supabase (#3988)
* feat(extensions): add hindsight-extensions registry and unbundle Supabase
Extensions were only ever bundle-able or nothing: shipping one meant putting
it in `hindsight_api.extensions.builtin`, where it becomes maintainer-owned
forever, lands in every image, and — because `extensions/__init__.py` eagerly
re-exported every implementation — drags its dependencies into core's import
graph. That pipe is why a third-party IdP's JWT client was a direct dependency
of every Hindsight install.
Add `hindsight-extensions/` as the registry for extensions distributed
separately from the server. Its README is the contract: slots and how config
env vars map onto them, how to write an extension, the package layout and
naming (`hindsight-extensions/<name>/` -> `hindsight-ext-<name>` ->
`hindsight_ext_<name>`), and Docker packaging.
Move `SupabaseTenantExtension` there as the first entry, published as
`hindsight-ext-supabase-tenant`. All 54 of its tests move with it, plus two new
ones asserting the documented `hindsight_ext_supabase_tenant:...` env value
actually resolves through `load_extension`.
Two decisions worth their comments:
- The extension does NOT declare `hindsight-api-slim` as a runtime dependency.
The server is the host process that imports it, not something it installs;
declaring it would let `pip install` of an extension silently move the server
version underneath a running deployment. It is a dev extra, resolved from the
local checkout via `tool.uv.sources` (dev-only metadata, verified absent from
the built wheel).
- The Docker example installs with `uv pip install --python
/app/api/.venv/bin/python`, matching docker-compose/custom-models: the image's
venv was created by `uv sync` and ships no `pip`, so a bare `pip install`
lands in user site-packages and is invisible to the server.
Core changes:
- `extensions/__init__.py` and `builtin/__init__.py` export interfaces only.
Nothing needed the concrete re-exports — the loader imports by path — and
dropping them is what lets an extension have optional dependencies at all.
- `builtin/supabase_tenant.py` stays for one minor release as a module whose
`__getattr__` raises the migration instructions. `load_extension` wraps a
missing *attribute*, not a failed import, so the ImportError propagates with
its message intact instead of surfacing as "class not found".
- Drop the direct `PyJWT[crypto]` dependency: no core module imports `jwt` any
more. Note this does not shrink the install — `mcp` pulls pyjwt transitively
and `cryptography` is already pinned directly — so the win here is ownership
and import graph, not bytes.
Locks are not checked in for extensions: `tool.uv.sources` pins the whole
api-slim tree, so every core dependency bump would leave them stale. CI runs
`uv sync --extra dev` and retriggers on `core` changes, since these tests run
against the server's interfaces.
Docs point at the registry rather than restating it, and the Deploying section's
Docker recipe was replaced — it named an image (`vectorize/hindsight-api`) and a
PYTHONPATH volume-mount pattern that no longer exist.
Also includes two one-line generated-file syncs in skills/hindsight-docs
(quickstart, installation) that were already stale on main; regenerating the
docs skill picks them up.
* refactor(extensions): ship extensions by image, drop the compat shim
Follow-up on review. Three changes to how an extension is distributed:
- Delete `builtin/supabase_tenant.py`. An install pinned to the old path now
fails at startup with ModuleNotFoundError rather than a guided message. The
docs carry the migration instead.
- Extensions are not published to PyPI. There is no wheel, no version and no
release step: the unit of distribution is an image built on top of Hindsight
that installs the extension's dependencies and copies the package onto
PYTHONPATH. That drops the whole "declare hindsight-api-slim only as a dev
extra" problem — nothing resolves dependencies against a running server any
more.
- The pyproject is now test-harness only (`package = false`, no build backend,
no distribution metadata), and says so in a comment so nobody re-adds
packaging to it.
Docs say 0.9.3, not 0.10.
Since the Dockerfile is now the distribution mechanism rather than an example,
CI builds it — its final `import` step is the only thing proving the extension
is reachable from the interpreter the server actually runs. It builds against
`:latest-slim` via a HINDSIGHT_IMAGE build arg to keep the pull cheap.
Verified against the real image, not just locally:
docker build -f hindsight-extensions/supabase-tenant/Dockerfile \
--build-arg HINDSIGHT_IMAGE=ghcr.io/vectorize-io/hindsight:latest-slim ...
-> load_extension('TENANT', TenantExtension) inside the container returns
SupabaseTenantExtension with its config resolved from the env vars.
Worth noting from that build: `uv pip install 'PyJWT[crypto]' httpx` reports
"Checked 2 packages" — both are already in the base image transitively. The
line stays because the extension should pin what it imports rather than rely on
the server's transitive tree, but it costs nothing today.
56 extension tests pass; the 3 remaining core tests (which assert no
implementation is re-exported and no core module imports jwt) pass.
* fix(tests): import ApiKeyTenantExtension from its module, not the package
Dropping the concrete re-exports from `hindsight_api.extensions` broke
`tests/test_extensions.py`, which imported `ApiKeyTenantExtension` from the
package inside a multi-line parenthesised import. A collection ImportError
fails the whole shard, which is why all three test-api shards and all six LLM
acceptance jobs went red at once on the previous push.
I'd checked for this with a single-line grep, which cannot see a name inside a
parenthesised import list. Re-checked with an AST scan over every package in
the repo (this was the only occurrence) and by collecting the full suite:
7780 tests collect clean.
|
||
|
|
0478c09c41 |
perf(retain): optimize embedding_to_pgvector via zero-copy orjson (#3815)
Retain and import paths convert float embeddings to pgvector vector literals
for asyncpg binding (insert_facts_batch, compute_semantic_links_within_batch,
update_memory_unit_embedding).
The baseline implementation used a Python generator:
"[" + ",".join(repr(float(value)) for value in embedding) + "]"
For 500 facts (768,000 floats at 1536d), this allocated 768,000 PyFloat objects
and 768,000 PyUnicode strings, taking ~220 ms CPU time and ~15 MB heap memory.
The optimized implementation leverages np.frombuffer on PackedEmbedding
(array('f')) for zero-copy buffer views, and orjson.OPT_SERIALIZE_NUMPY to
format floats directly into the output byte buffer using Rust Ryu SIMD:
* Promotes numpy to explicit direct dependency across hindsight-api and dev;
* Formats shortest float32 representation (byte-identical Postgres storage);
* Isolates _repr_literal fallback helper for non-finite and non-float inputs;
* Unifies _dumps_or_repr_fallback with single payload parameter and no option branching;
* Streamlines embedding_to_pgvector into a concise polymorphic dispatcher;
* Seamlessly supports array('f'), list[float], tuple, ndarray, and str.
Measured on Apple Silicon via vector-serialization-bench (best of 5 repeats):
workload baseline prod speedup peak alloc
single_bge_384 (1x 384d) 0.136 ms 0.040 ms 3.4x 36K -> 13K
single_openai_1536 (1x 1536d) 0.489 ms 0.085 ms 5.8x 144K -> 50K
batch_20_gemini_768 (20x 768d) 4.580 ms 0.514 ms 8.9x 356K -> 185K
batch_200_openai_1536 (200x 1536d) 92.64 ms 9.45 ms 9.8x 6.1M -> 3.4M
batch_500_large_doc (500x 1536d) 221.78 ms 23.84 ms 9.3x 15.0M -> 8.4M
batch_200_raw_list (200x 1536d) 87.70 ms 11.48 ms 7.6x 6.1M -> 6.0M
Throughput increased from 3.3 Mfloat/s to 32.5 Mfloat/s (~9.8x speedup on
typical retain batches), with ~44% peak memory reduction on 500-fact batches.
Includes unit tests in test_packed_embeddings.py covering bit-identical float32
roundtrips, custom non-serializable objects, non-f array fallthrough, tuples,
ndarrays, and non-finites.
|
||
|
|
9fcb7ca7ac |
perf(tokenizer): replace tiktoken with quicktok and default to o200k_base (#3788)
Token counting is on the hot path of both retain and recall. Recall counts once
per candidate fact, per candidate chunk, per source fact and per reranker
document; retain counts whole documents. All of it went through
`len(encoding.encode(text))` — which builds a full Python list of ids only to
take its length.
Measured on this repo's own text with the microbenchmark added here, against
tiktoken 0.12.0 on a 14-core M-series, both on o200k_base:
workload tiktoken quicktok speedup peak alloc
200 ranked facts 4.39 ms 0.75 ms 5.8x 3 KiB -> 1 KiB
500 source facts 6.92 ms 1.12 ms 6.2x 2 KiB -> 1 KiB
50 candidate chunks 12.02 ms 1.57 ms 7.7x 21 KiB -> 1 KiB
100 reranker documents 10.84 ms 1.55 ms 7.0x 12 KiB -> 1 KiB
one 77k-token document 36.08 ms 3.00 ms 12.0x 2.8 MB -> 1 KiB
Summed across the four counting stages one recall runs: 34.2 ms -> 5.0 ms.
Three things make this worth a dependency change rather than a micro-opt:
* `count()` returns an int without materialising the ids, so counting a large
document allocates nothing. tiktoken has no count-only API — `encode_to_numpy`
reaches the same 1 KiB but none of the speed, and is measured here too.
* ids are byte-identical to tiktoken's; the benchmark asserts that on adversarial
inputs before it times anything.
* the vocabularies ship inside the wheel, so nothing is downloaded at runtime.
That removes the tiktoken pre-download from the Docker build (both stages) and
from scripts/dev/setup.sh — air-gapped deployments no longer need it baked in.
The dependency risk is maintenance, not correctness, so it is contained:
engine/token_encoding.py is the only module that imports quicktok, every call
site routes through get_token_encoding() / count_tokens(), and its one
dependency (numpy) was already in the tree. Replacing it means rewriting that
file and nothing else. This also removes the last direct tokenizer import that
had escaped the seam (`__import__("tiktoken")` in reflect/prompts.py).
Removes #3756's workaround. count_tokens_windowed existed to bound the memory of
counting a large retain body, encoding a megabyte at a time and accepting an
approximate answer because a fixed character cut can split a token. count()
allocates nothing at any size AND is exact, so the windowing, its helpers and its
six call sites are gone — those callers now get an exact count. The test file
keeps the property that made #3756 worth fixing (allocation does not track the
input), now asserted against count_tokens itself.
Default encoding moves to o200k_base, selectable with
HINDSIGHT_API_TOKENIZER_ENCODING (server-level: budgets are only comparable
between banks if they are all counted the same way). o200k_base is what current
OpenAI models tokenize with. On English and code it counts within a fraction of
a percent of cl100k_base, but on non-Latin scripts it is far closer to what a
model actually charges — a mixed-script line with emoji is 19 tokens under
cl100k_base and 13 under o200k_base. Since these counts back budgets that stand
in for a context window, the closer vocabulary is the more honest one. Set
cl100k_base to reproduce the previous counts exactly.
Call sites that only need a number now call count(); the ones that need ids
(query truncation, chunk truncation, reranker truncation, prompt fitting) still
encode, but only after a count shows the text does not fit. The chunk-budget loop
also stopped encoding each oversized chunk twice.
|
||
|
|
ebad478240 |
Release v0.9.2
- Update version to 0.9.2 in all components - Regenerate OpenAPI spec and client SDKs - Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed - Python client: hindsight-clients/python - TypeScript client: hindsight-clients/typescript - hindsight-all npm wrapper: hindsight-all-npm - Rust CLI: hindsight-cli - Control Plane: hindsight-control-plane - Helm chart - Sync documentation to version-0.9 |
||
|
|
e65973b1d9 |
fix(retain): bound retain's memory by a budget instead of by the document (#3756) (#3763)
* fix(retain): bound retain's memory by a budget instead of by the document (#3756) Retaining one large document held state proportional to the document rather than to a working set. The reported peak was blamed on embeddings held as list[float]; measured, that was not where it went. Two whole-document operations dominated, both running before a single fact or embedding existed. Peak Python bytes allocated, 45 MB body (tracemalloc, order-independent): before after sizing count_tokens 384.7 MB 9.6 MB (windowed; flat at any size) chunking split_text 200.8 MB 0.0 MB (streamed; flat at any size) The facts themselves were never the problem: the streaming pipeline already bounds them to retain_chunk_batch_size chunks, ~1700 facts, ~21 MB. Four changes, and a bound that is now explicit: 1. count_tokens_windowed() sizes a body a megabyte at a time instead of building one boxed int per token. Every caller compares against a batch budget or logs the number, so the one-token-per-window boundary error (45 tokens in 11.6M) is unobservable. 2. iter_chunks() streams chunks instead of materialising them, including a lazy re-implementation of the RecursiveCharacterTextSplitter configuration retain used. langchain leaves the runtime dependencies with it, kept only as the reference the differential test diffs against. Boundaries are content hashes for delta retain and chunk_ids by index, so test_chunking_streams.py pins the output against langchain directly rather than trusting the rewrite. 3. An oversized item's body is Memory Defense screened AND content-hashed once, not once per slice. That hash was the last piece of work scaling with (sub-batches x document size): ~0.9s repeated ~1,200 times for a 45 MB body, about 18 minutes spent re-deriving one value. 4. ProcessedFact.embedding is array("f") rather than list[float] - 1,616 bytes against 12,344 for 384 dims. float32 is what pgvector stores, so the rounding just happens one step earlier and the stored bytes match. RetainMemoryBudget then turns the pipeline's bound from a count of chunks into a ceiling in bytes (HINDSIGHT_API_RETAIN_MEMORY_BUDGET_MB, default 128). A count is only a memory bound if chunks cost a predictable amount, and they do not - a chunk carries however many facts the extractor found in it. The producer reserves a chunk's estimated cost before queueing and the consumer releases it once written, so over budget extraction waits for the write path instead of growing. What still costs a copy of the document is the document: the submitted string and the sub-batch slices cut from it (45.7 MB for a 45 MB body). Nothing in the front half is superlinear any more, and nothing but those two scales at all. Speed is unchanged or better - sizing 2.42s -> 2.46s, chunking 0.29s -> 0.16s on a 45 MB body. Measured by hindsight-dev/benchmarks/perf/retain_memory.py, which reports tracemalloc rather than RSS: RSS cannot attribute an allocation to the code that made it (arenas are mapped on first touch and reused silently), and reading it that way is what made this issue's original diagnosis wrong twice. * test(retain): pin the new chunker against the one it replaced, over a corpus (#3756) The streaming chunker was diffed against langchain only for the plain-text splitter, on hand-written strings. That leaves the paths retain actually takes on real input — JSON conversations, JSONL logs, the structured-limit branch — covered by nothing but the assumption that they were untouched. This runs the whole pre-#3756 `chunk_text`, copied verbatim and still calling langchain, against the live one over 21 document shapes x 7 chunk sizes, the same again across three structured-chunk-size settings, and 200 seeded random documents. It also asserts `iter_chunks` and `chunk_text` agree everywhere and that every emitted chunk re-chunks to itself (#2301's invariant). Why the copy is verbatim and must stay that way: the moment the reference shares a code path with the live implementation the comparison proves nothing. `test_the_legacy_reference_really_is_the_old_implementation` pins the reference to langchain's own output so that cannot happen quietly. Verified non-vacuous by mutation: shifting the packer's budget by one character fails 41 of the 126 tests. (An exactly-budget piece re-packs to itself whichever branch it takes, so `<` vs `<=` is an equivalent mutation rather than an uncaught one — noted in the corpus helper so nobody re-derives it.) * fix(retain): four regressions CI caught in the memory-budget work (#3756) 1. estimate_chunk_bytes() called len() on a null context and failed the retain it was sizing. ProcessedFact.context is annotated str, but a converted file upload retains without one and puts None there — every file-retain test returned HTTP 500. A budget heuristic must never be able to break the operation, so every string now goes through a None-tolerant helper. 2. RetainMemoryBudget accepted a non-int limit and hung. A MagicMock config makes every comparison inside it truthy, so the producer waited for room that could never be reported — test_consumer_failure_cancels_in_flight_ extractions timed out at 300s instead of failing. It now rejects a limit that is not an int, so a mis-mocked config is a clear TypeError rather than a wedged retain, and the test models the field it needs. 3. Two async-batch tests stubbed chunk_text, which retain no longer calls; they patch iter_chunks now, which covers both forms. 4. One mapping test compared ProcessedFact.embedding against a float list. It is packed since this branch — compare the unpacked values, since what the assertion is about is which facts survived, not the container. |
||
|
|
7c56464365 |
engine: serve the addressed document/chunk/entity reads from the store too (#3560)
* perf(retain): store-owned backend writes facts once with entities inline
A memories store that owns its rows (external backend) retains a document in a
connection-free store phase. That phase wrote each memory TWICE: insert_facts
staged it without entities, then record_unit_entities read the just-written
records back (full vectors) and re-upserted them with entity ids attached —
because entity ids can only be resolved onto real unit ids after they exist.
On an object-store-backed engine that reattach is a second full write per memory
plus a read-back, doubling the round-trips on the slow path (the dominant cost of
retain). It's avoidable: mint the ids without writing (insert_facts_batch with
defer_index), remap the entities, then write once via index_facts with the entity
ids already inline — tagged with the write-group txn so it commits atomically with
the group (and, as a bonus, the postings are now witness-covered instead of riding
an uncovered seam). Co-occurrence accumulation still runs (record_unit_entity_postings
gains store_write=False: co-occurrence only, since the row is already correct).
Streaming ext path only; the delta path is unchanged. Tests updated: the single
write via index_facts is asserted connection-free and txn-tagged, and the posting
runs store_write=False. No behavior change for the Postgres store (a no-op there).
* retain: PG-free path for a store that owns entity resolution + atomicity
A store advertising `store_owned_retain` now commits the entire retain in ONE
server-side call: it resolves each fact's raw entity names against its own
registry, mints ids for new names, writes the memories with ids attached, and
replaces the document's prior version atomically. The orchestrator therefore:
- skips Phase-1 entity resolution (no Postgres trigram scan / entity INSERTs);
- routes the streaming write through a new `_streaming_store_owned_retain` that
reconstructs entity names from the facts and issues the single retain — no
connection phase, no `documents`/`chunks`/`entities` rows, no commit witness,
no `decide_txn`. The store's single write is already atomic;
- marks the document tracked so the post-loop finalizer (which would write a
Postgres documents row and run handle_document_tracking, whose delete-by-
document_id lands at a later seq and would delete the just-written memories)
does not fire;
- falls delta retain back to full retain for such a store (delta's chunk-diff
still rides a Postgres connection phase) — a follow-up.
Non-store-owned (Postgres) backends are unchanged: the capability gate defaults
off, so the two-phase path runs exactly as before.
Verified end-to-end (store-owned backend, LLM-free chunks): retain 3 docs ->
recall 3/3 ranked; zero Postgres rows for the bank; an entity shared by two
documents resolves to ONE id; two writes per document, no clobbering tombstones.
* retain: bank-scoped store_owned_retain_for so a router can activate it per-org
The store-owned retain path was gated on a plain `store_owned_retain` attribute,
which a routing memories extension (per-org: some banks on a store, some on
Postgres) does not expose — so a routed store-owned bank silently fell back to
the Postgres path. Add `store_owned_retain_for(bank_id)` on the base extension
(defaults to the class attribute, mirroring owns_document_store_for) and consult
it from the three retain gates instead of the attribute. A single-store extension
needs no change; a router overrides the _for method to answer per bank.
* memories: declare retain() on the base interface so routers auto-delegate it
RoutingMemories generates a delegating wrapper for every async method on the
MemoriesExtension interface. The store-owned retain() lived only on the concrete
store, so a routed org hit 'RoutingMemories has no attribute retain'. Declare it
on the base (default raises NotImplementedError; store-owned stores override, and
the orchestrator only calls it when store_owned_retain_for is true) so the router
covers it automatically.
* store-owned: drop Protocol-B txns from consolidation + unit-delete; fix include_chunks
Two write paths still opened a cross-store write-group for a store-owned org even
though every memory write in them lands ONLY in the store:
- Consolidation (consolidator.py): minted a write-group per LLM batch and left it
pending if the batch crashed/cancelled between mint and decide — the exact source
of the undecided txn+upsert/txn+patch ops that stall the store's indexer and
starve every namespace's fold. For a store-owned org the observations, deletes and
mark_consolidated stamps are store-only, so _batch_txn=None: plain, immediately-
durable, idempotent-on-retry writes, no witness, nothing left undecided. The
refresh-tag bookkeeping becomes a best-effort plain write.
- delete_memory_unit (memory_engine.py): the write-group wrapped only the store
tombstone (the relink/prune enqueues join memory_units/unit_entities, which a
store-owned org keeps no rows in — no-ops); a plain delete needs no group.
Also fix include_chunks for store-owned recall: it seeded chunk metadata from the
Postgres chunks table, which PG-free retain no longer writes, so no chunks came
back. Derive the metadata from the hits (chunk_id + document_id) and overlay the
text from the store — the memlake path that was already half-wired.
Curation, delete-document and 0-fact retain tracking still use a write-group: they
genuinely write the Postgres entities registry / documents row alongside the store,
so they stay until those move off Postgres.
* recall include_chunks: synthesize chunk metadata from hits when SQL chunks is empty
PG-free retain writes no SQL chunks row, so include_chunks came back empty for a
store-owned org. Query the chunks table as before; if the store owns its document
store AND the query is empty, synthesize each row from the chunk_id itself
(chunk_id = {bank}_{document}_{index}; bank_id known, index is the last segment),
then overlay the text from the store. Chunk metadata now returns; the text overlay
(list_chunk_texts) is exercised the same way as the legacy owns-docs path.
* recall: remove stray duplicate 'if include_chunks' line (import fix)
* store-owned: PG-free delete-document, curation, 0-fact tracking (finish Protocol-B removal)
The last three write-group txn users for a store-owned org, all of which depended
on Postgres rows that PG-free retain no longer writes:
- delete_document: gated the store tombstone on the Postgres documents DELETE
RETURNING id, which is always None under PG-free retain (no row) — so a
store-owned document could NEVER be deleted (memories + doc record left behind).
Now drives the deletion off the store: plain delete-by-document + doc-record
delete, no write-group (memlake-only).
- curation (edit/invalidate/revert): _curation_txn=None for store-owned — the
edited memory write is atomic on its own; the Postgres entity/posting writes are
unused by store-owned reads. No witness left undecided.
- 0-fact retain tracking: a store-owned 0-fact (re-)ingest now does one plain
delete-by-document instead of a Postgres documents row + write-group.
With retain, delta, consolidation, unit-delete, delete-document, curation and
0-fact tracking all txn-free, a store-owned org opens NO Protocol-B write-group at
all — nothing can be left undecided to stall the indexer.
* engine: list documents from the store when it owns document metadata
A store that owns its document metadata keeps no rows in the SQL documents table,
so list_documents returned an empty page for it. Add a store-owned branch that
delegates to the store's own list_documents (returning the same {items,total,
limit,offset} shape), and declare list_documents on the MemoriesExtension base so
routing delegates it. Store-generic.
* engine: count documents from the store when it owns document metadata
Bank stats read total_documents from the SQL documents table unconditionally, so a
store that owns its documents (empty SQL table) reported 0 — the one count not
asked of the store, unlike links/nodes. Add a store-owned branch calling the
store's count_documents, and declare count_documents on the base. Store-generic.
* engine: read the entity graph from the store when it owns entities
get_entity_graph read the SQL entity_cooccurrences/entities tables, empty for a
store that owns its entities. Add a store-owned branch delegating to the store's
get_entity_graph, and declare it on the base. Store-generic.
* curation: a store that owns entities resolves+mints edit names itself (#3557)
When a store owns the whole retain (its own entity registry, atomic writes), a
curation edit that changes entities was still minting the new entities into the
host SQL 'entities' table (resolve_entities_only autocommit) and writing the
unit_entities postings (reassert_entities_batch / link_units_to_entities_batch) —
rows that store's own reads never consult. So a brand-new entity created by an
edit landed only in SQL and was invisible to the store's entity reads.
Route it the same way retain already does: hand the store the raw entity NAMES
and let its apply_edit resolve + mint them against its OWN registry, rewriting the
memory's entity ids from the result. apply_edit gains an 'entity_names' argument
(authoritative when set; a SQL-registry store ignores it and uses the pre-resolved
entity_ids). For such a store the edit path now skips the SQL entity mint, the
canonical-name read-back, and the clear/reassert/link/prune postings entirely.
No behavior change for a SQL store (entity_names stays None; the resolve+relink
path is untouched). Companion: a store provider's apply_edit must resolve+mint
entity_names. (get_entity_graph, the other store-owned entity residue, already
reads from the store.)
* engine: serve the ADDRESSED document/chunk/entity reads from the store too
The list routes already ask `owns_document_store_for` / read the store; the addressed
routes did not, so a store that owns its document metadata and entity registry handed out
ids from a list route that its own detail routes then 404'd:
GET /banks/{b}/documents -> 200, 50 items
GET /banks/{b}/documents/{that id} -> 404 "Document not found"
GET /banks/{b}/entities -> 200, 31 items
GET /banks/{b}/entities/{that id} -> 404
GET /banks/{b}/documents/{id}/chunks-> 404, so no chunk id is discoverable at all
Four reads, each of which could only ever miss for such a store:
* `get_document` — both branches read the row `FROM documents`; the else branch's comment
even asserted "the documents row is still SQL", which stopped being true. Now reads the
record from the store when it owns the document store. `retain_params` is not carried in
the store's document record, so params/metadata/observation_scopes come back null for
such a bank — a gap in what the write path persists, and null beats 404-ing the document.
* `list_document_chunks` — verified the document against SQL and paged SQL `chunks`,
overlaying only the TEXT from the store. Both are empty, so it 404'd. Now served from
the store, rebuilding chunk ids in the same `{bank_id}_{document_id}_{index}` shape
retain writes, so an id from this route is accepted by the addressed route.
* `get_chunk` — looked the id up in SQL `chunks`. The id is self-describing, so it is now
parsed and served from the store. Both splits are from the right: the index is the last
segment and the document id before it is a UUID, so a bank id containing underscores
still parses.
* `get_entity` — read only SQL `entities`. Now resolves the single id against the store's
registry; an addressed lookup rather than paging, so it stays O(1) in registry size.
Two small module helpers rather than inline: `_parse_chunk_id` and `_epoch_ms_to_datetime`
(a store returns epoch millis where SQL returns datetimes, and the response builders call
.isoformat()).
* engine: hydrate recall chunk text concurrently, and fetch only the chunks asked for
Measured end to end: a 30-hit recall with `include.chunks` took **20.6s p50** against
**735ms** for the same recall without it — 28x, for the same 30 results. Entity hydration
on the same corpus is free by comparison, so the cost was specific to chunks.
Two causes, and counting round-trips alone hides both:
* the per-document fetches were `await`ed in a loop, serialising one store round-trip per
document (~690ms each across ~30 documents — that is essentially the whole 20.6s);
* `list_chunk_texts` downloads a document's WHOLE packed chunk blob. Recall hits are
spread across documents, so the usual case is one wanted chunk per document and the
blob is mostly waste.
So: fan the documents out concurrently (bounded, so a wide recall cannot open an unbounded
fan-out at the store), and pick the call shape per document — `get_chunk_text` when only
one chunk of that document was hit, `list_chunk_texts` when several were, where the blob
amortises.
A document whose fetch fails now leaves its rows with the empty text they already had and
logs, rather than failing the whole recall over one chunk body.
The previous comment justified the loop as "O(documents) round-trips, not O(chunks)". That
was true and still too slow: the round-trip COUNT was never the problem, the serialisation
and the per-call payload were.
* retain: carry retain_params into the store's document record
Closes the gap this PR previously only surfaced: for a store that owns the whole retain,
`get_document` returned null `retain_params`, `document_metadata` and
`observation_scopes`, because the write path passed `metadata={}` and there is no SQL
`documents` row to fall back on.
The store's document metadata is `string -> string`, so the params ride as one JSON value
rather than being flattened, and the read parses them with the same `parse_json` the SQL
column goes through — so both paths produce identical output for identical input.
Also corrects `_store_document_bodies`'s docstring, which asserted that "the SQL
documents/chunks rows still carry the small metadata (id, content_hash, chunk_index,
tags)". That was true when only the bulky text columns moved to the store. It is not true
for a store-owned retain, where there is no SQL row at all — anything not carried into the
store record is simply lost, which is precisely how these three fields went missing.
Documents written before this still read back with null params; that is a data gap, not a
code path, and null remains better than 404-ing the document.
* test: pin the retain_params carry-through into the store record
Two unit tests against the in-memory store, no DB needed: the params reach the record as
one JSON value, and an absent params dict writes no key rather than a "null" string the
read would hand back literally.
Verified the first fails without the fix (metadata={} drops them) and passes with it.
* engine: check observation-history existence against the store, not SQL
`get_observation_history` probed `memory_units` to decide whether the memory exists and
whether it is an observation. For a store that keeps memories outside SQL that table is
empty, so the probe could only ever miss and the caller 404'd a memory that
`memories/list` had just returned — the same list-says-yes/detail-says-no shape as the
document, chunk and entity reads in this PR.
Only the existence + fact_type probe moves. The history rows themselves stay in SQL
(`observation_history`), which is correct: they are curation metadata, not memory content.
Worth being explicit, because I got this wrong first time round: this is NOT "expected
because the bench bank runs with observations disabled". With observations ON it would
still 404, because the failure is in the existence check, before history is consulted at
all.
* engine: hydrate recall chunks in one store call when the store can
Making hydration concurrent took recall-with-chunks from ~20.6s to ~3-5s, but it stayed several
times the cost of the same recall without chunks. Concurrency hides round-trips, it does not remove
them: a recall's hits are spread thin across documents — 88 chunks over 76 documents, measured — so
per-document fetching is ~76 round-trips, which at a bound of 16 is still five waves.
A store that can fetch many chunks at once turns that into one call. Probed with getattr rather
than a capability flag, because the per-document path has to stay for stores that cannot, and the
fallback is the existing code unchanged.
The batched path fails the same way the per-document one does — hits come back without chunk text
and a warning is logged, rather than a whole recall failing over chunk bodies.
* engine: put the batched store calls on the interface, where a router can reach them
Both batched calls were added to the concrete store only, and a routing store generates its
delegators from the methods MemoriesExtension declares — so neither had a delegator and neither was
reachable in a cloud deployment. get_chunk_texts was deployed and measured: recall-with-chunks did
not improve at all, because the engine's capability probe returned None on every call and it
silently used the per-document path. Verified on the running pod: the provider had the method, the
router did not.
That is the second time this exact shape has cost an optimisation — store_owned_retain_for was the
first, and it was equally silent, because the fallback is correct and merely slower.
So both are declared here with default implementations that loop the per-item call. Correct for
every store, saving nothing for one that cannot batch, and reachable through a router for one that
can. The engine now calls get_chunk_texts unconditionally instead of probing with getattr, and the
per-document hydration path and its concurrency knob are deleted rather than left as an unreachable
branch.
* engine: hydrate the surviving candidates, so ranking need not move payloads
Fusion orders candidates by id and arm score and never reads a payload, so a store can return
scores for the wide arms and materialize only what survives. The engine now asks it to, after the
reranker's candidate trim and before anything reads the payload — the reranker scores text, the
boosts read timestamps, the response returns both, and nothing between retrieval and that point
touches it except the tracer's entry-point log.
Declared on MemoriesExtension with a default that returns immediately, so a store returning full
results (Postgres) is unaffected, and so a routing store generates a delegator for it — a method
that exists only on a concrete store is unreachable in a cloud deployment, which has already cost
three optimisations here.
Timed as its own phase, because the point of the change is where the time goes and a phase that
cannot be seen is a phase nobody will notice regressing.
* retain: read the append base, and the sub-batch chunk prefix, from the store that holds them
Two failures with one shape: a store that owns the document store keeps the body
and the chunk texts in its own store and leaves the SQL columns empty, and both
of these read the SQL.
append: the base text came from `documents.original_text`, which such a store
writes as NULL by construction, so there was nothing to prepend and every append
silently became a replace — each earlier turn dropped. Falls back to the store's
own record, keeping SQL's content_hash authoritative for the race gate.
sub-batches: `put_document` REPLACES a document's chunk list, and a sub-batched
retain called it once per sub-batch with only that slice, so a large document
kept roughly the first sub-batch's chunks — 8-46% of the body, silently. The SQL
chunk rows were already offset by `chunk_index_offset`; the store's packed object
was not. Restore the prefix the earlier sub-batches stored so the whole document
goes in.
StoredMemory gains `updated_at` — write time, distinct from `created_at`, which
the curation read model reports and a store carries on every memory.
test: assert the append's metadata through the engine's read API rather than a
SELECT against `memory_units`, which a store owning its memories leaves empty —
the test failed with `assert []` on a backend whose behaviour was correct.
* retain: an append replaces the document, and its write is conditional on the base it read
Two halves of `update_mode="append"` against a store that owns the document store.
**Replace.** The store-owned retain skipped the replace-tombstone for an append. That
was load-bearing only while the prepend was broken: with the base text read from the
store that holds it, the append's first batch carries the WHOLE document again, so not
replacing left a second copy of every earlier fact — carrying the metadata of the retain
that created it, where Postgres reprocesses and ends with one set.
**Conditional.** The base a concurrent append reads can move before it writes. Postgres
serializes on the document row; a store-owned bank has no such row, so the write was
last-writer-wins and turns were silently dropped. The base read now also captures the
store's watermark, the body write carries it as a precondition, and a lost race comes
back as `StoreWriteConflict` -> `ConcurrentAppendConflict`, which the existing retry loop
redoes on the newer base. Sequential appends are unaffected: no contention, no retry.
`StoreWriteConflict` joins `StoreWriteUnavailable` on the interface. They are different
answers: unavailable means "not now, the same write will do"; conflict means the write is
STALE and re-applying it would re-decide on an old base.
tests: two assertions read `memory_units` with raw SQL to check that units survived. A
store that owns its memories keeps no rows there, so both reported an empty result on a
backend that had kept every one. They ask the engine now — same property, one level up,
where it is actually the interface.
* suite: carry the document tag filter to the store, and mark the tests that assert Postgres internals
`list_documents` passed `tags`/`tags_match` to the SQL branch and dropped them on the
way to a store that owns its document metadata — the filter silently did nothing there.
They go with the call now, and the interface says `total` must count what matches.
`UserEntities`, not a bare list, in the store-owned retain's entity merge: the merge
reads `.entities` and `.resolve` off it, and `resolve` is what distinguishes a caller
correcting a name from the extractor guessing one. Every store-owned retain carrying
user entities failed on it.
Registers `memory_backend_incompatible`, the marker `run-hindsight-suite.sh` already
deselects but nothing declared, and applies it to thirteen modules that SEED
`memory_units` / `memory_links` / `unit_entities` with raw INSERTs — each through a
shared `_insert_*` helper, so it is the module's whole method, not a case or two. A
MEMORIES extension owns those rows and leaves the tables empty, so the seed lands
nowhere the code under test can see it. Worth being precise about why this is not
sweeping failures under a rug: in those modules the tests that still PASSED were
passing vacuously — an assertion that a result set is empty holds trivially when
nothing was ever seeded — so the marker removes false green as well as red.
Unchanged, and still required, on Postgres.
* suite: fold the incompatibility marker into modules that already had a pytestmark
Three of the marked modules set `pytestmark` again further down (an xdist_group), which
overwrote the marker rather than adding to it — so they kept running, and kept failing,
against a store that owns their rows. Combined into a list.
* cli: do not build the application in the parent when uvicorn will fork workers
`--workers N` (and `--reload`) serve an import string, so every worker imports
`hindsight_api.server:app` for itself. The parent built a MemoryEngine and the whole
FastAPI app anyway and then threw the object away — and that was not merely wasteful.
uvicorn's multiprocess supervisor FORKS. Each child therefore inherited a parent that had
already constructed connection pools, HTTP clients and background threads. Threads do not
survive fork; the locks they held do. The children hung before they could log a line, the
supervisor's healthcheck SIGKILLed them at 5 s, and it respawned them forever — roughly one
death every 5.6 s, each re-paying a 10 s init. Half the fleet served nothing.
It failed invisibly, which is the part worth fixing loudly: no traceback (SIGKILL leaves
none), and the pod reported `Ready=true` with `restartCount=0` throughout, because the
supervisor holds the port while the workers behind it die. Anyone raising this knob for
capacity got less capacity and no signal at all.
Measured in a live pod, same image, same uvicorn version, same uvicorn options both ways:
parent pre-builds the app (before this) -> 16 child deaths / 90 s, 1 startup
clean parent (`python -m uvicorn`, equivalent) -> 0 child deaths / 80 s, 2 startups
Two earlier theories are wrong and worth recording so nobody re-runs them: it is NOT OOM
(`memory.events` oom_kill stayed 0, peak 1.7 GiB against a 4 GiB limit), and it is NOT the
healthcheck timeout being shorter than the 10 s import — raising it to 60 s changed nothing,
because a hung child never answers however long you wait.
`--daemon` with `--workers > 1` now says that its idle timeout is not applied. That was
already true, and already silent.
* cli: correct the claim on the previous commit — that change is a cleanup, not the fix
The previous commit described skipping the parent's app construction as the fix for
the worker respawn loop, on the theory that children inherited the parent's pools and
locks across fork. uvicorn's multiprocess uses SPAWN, not fork, so nothing is
inherited; the change was built, deployed to dev, and the loop was unchanged.
Keeping the change — the parent really was doing ten seconds of work to build an
object it discards — but describing it accurately.
The real cause, isolated in a pod by moving a single import: a spawn child rebuilds
`__main__` by re-running `sys.argv[0]`, pip's console-script wrapper, whose
`from hindsight_api.main import main` pulls `hindsight_api/__init__.py` and the whole
engine before uvicorn's child bootstrap starts. It then misses the supervisor's 5 s
healthcheck and is SIGKILLed, silently, forever. Same file with that import inside
the `__main__` guard: 12-16 deaths become 0.
* cli: make importing hindsight_api.main cheap, so spawned workers survive their bootstrap
THE fix for the worker respawn loop, and this time isolated by experiment rather than
reasoning: in a dev pod, `hindsight-api --workers 2` went from 12-16 child deaths per 90 s
with one worker ever reaching startup, to 0 deaths and 2 startups.
uvicorn's multiprocess supervisor uses spawn, so every worker rebuilds `__main__` by
re-running `sys.argv[0]` — pip's console-script wrapper — whose top line is
`from hindsight_api.main import main`. That pulled this package's `__init__`, and through it
the engine, both model wrappers and the whole search stack, and then `main` itself pulled
`.api` (~6.2 s to import) and `.extensions` (~2.6 s). Every child therefore re-imported the
entire application BEFORE uvicorn's child bootstrap began, missed the supervisor's 5 s
healthcheck, and was SIGKILLed and respawned, forever, with no traceback.
`from hindsight_api.main import main`: **6578 ms -> 312 ms**.
Two things this had to preserve, and the shape follows from them:
* `hindsight_api.__init__` resolves its exports through a PEP 562 `__getattr__`, so
`from hindsight_api import MemoryEngine` still works and costs what it always did.
`apply_default_thread_limits()` stays eager — it sets env vars OpenBLAS/OpenMP/MKL read
only at load time, so it must precede numpy/torch. Laziness makes that stronger.
* `main.py` keeps its heavy names as MODULE attributes, resolved on first use, because
`patch("hindsight_api.main.MemoryEngine")` is how eight tests drive this function. Importing
them inside `main()` broke exactly those tests: invisible to `patch`, and a local import
shadows any patch that does land. `main()` reads them off `sys.modules[__name__]`, since a
module `__getattr__` serves `module.X` but NOT a bare global inside the module's own
functions — that raises NameError, which is how the first attempt failed.
Suite: 205 failures against a 218 pre-change baseline, and 2 not in it. Two runs of the
IDENTICAL code differ by 27, so that is inside the noise; the affected files all pass in
isolation. Measuring that floor is the only reason the number means anything.
* docs: drive document re-tagging off the store, and add set_document_tags
`update_document` gated on `UPDATE documents ... RETURNING id`, which matches nothing for a
store that owns its document metadata — so it returned False for a document that exists, and
the store branch below it (retag the memories, cascade the observations) was unreachable for
the only store it was written for.
`set_document_tags` is new on the interface because a document's own tags live on the store's
record while its memories are retagged separately; moving one without the other leaves the
browser showing a stale half. It is a record-only write by design — the record already carries
every body's content hash, so a store can honour it without moving any bytes.
* export: refuse a bank whose memories are not in SQL, instead of archiving nothing
The export loaders read memory_units, unit_entities, memory_links, documents and
chunks directly. For a bank whose memories live outside SQL those tables are
empty, so the export walked them, found nothing, and returned a well-formed
archive containing no memories — with a 200. An operator taking a backup or
migrating a bank finds out at restore time, which is the worst possible moment.
Refusing is not the eventual fix, but an empty archive that reports success is
worse than an error that names the problem while it is still actionable.
Implementing the export properly needs more than pointing these queries at the
store: the read model carries neither a memory's causal edges nor a chunk
listing, so the memories interface has to grow before this can be lifted, and
both stores then implement it. That is a scoped piece of work rather than
something to half-build behind a flag.
The admin CLI refuses too, on a different signal. It talks to Postgres on a raw
connection and has no store to ask, so it cannot tell whether a given bank is
store-owned; the presence of a configured memories extension is what it can see,
and the API export is the path that knows.
Tests assert the refusal rather than the archive's contents, because an empty
archive is exactly what a passing-but-wrong implementation produces. A SQL-backed
bank and the no-store case both verify the guard does NOT fire.
* export: assemble a store-owned bank's archive through the memories interface
The document export now works for a bank whose memories are not in SQL, instead
of refusing. Same archive, sourced differently: list_documents +
get_document_record for the document and its text, list_chunk_texts for chunks,
scan_memories for facts and observations, and each memory's own entity_ids and
causal_edges for what SQL reads out of unit_entities and memory_links.
StoredMemory gains causal_edges. It already carried semantic_edges, but those are
derived and rebuildable while causal edges are intrinsic — written with the
memory, and with no memory_links table they are the only copy. Without them on
the read model an export of such a bank drops every causal relation silently,
which is the same class of defect as the empty archive this replaces.
Fact order is re-established rather than inherited. The SQL query orders by
(document_id, created_at, id) and causal target_fact_index is an ordinal into
that order, so a store walking in a different order would silently repoint every
edge. The test's fake returns its facts reversed for exactly this reason, and
fails if the sort is removed.
One field cannot be carried: consolidation_failed_at has no interface field — it
is written into the store's metadata bag and nothing reads it back — so it
exports unset. That loses the record of a consolidation that gave up, not any
memory, and is stated in the loader rather than left to be discovered.
export_bank still refuses: it also dumps bank config, mental models, directives
and webhooks straight from SQL, so routing it is a separate change.
The test asserts archive CONTENTS — facts in order, the entity name resolved
through the registry, the causal edge as an ordinal — because an empty archive is
precisely what the broken version returned successfully.
* export: route the whole-bank export through the store too, and drop the refusal
export_bank now takes the same path export_documents does: memories come from
the store when the bank keeps them outside SQL, while bank config, mental models,
directives, webhooks, knowledge pages and the history tails stay on the
connection, because those live in Postgres for every deployment. Routing all of
it to the store would lose the config; routing none of it loses the memories.
With both entry points working, the 501 refusal is gone.
The admin CLI resolves the configured store rather than refusing. It used to bail
whenever an extension was configured, since it holds a raw connection and could
not tell whether a given bank was store-owned — but it can simply ask for the
store, and a Postgres deployment resolves to the SQL one whose capability probe
sends the export down exactly the path it always took. Resolving a store inside a
short-lived CLI process is safe because it connects lazily on first use rather
than in initialize().
The CLI contract test pins the exact kwargs, so it now pins the store too.
* retain: run the delta path for a store-owned bank, instead of full-replacing
Delta was skipped entirely when the store owns the rows, on the stated grounds
that "full retain of an unchanged doc is still cheap". That is true of cost and
false of behaviour. The full-replace path deletes the document's facts and
rebuilds them, so re-submitting a document that changed NOTHING orphaned or
destroyed every observation standing on those facts, and requeued the facts for
consolidation. Delta exists as much to leave unchanged things alone as to save
work.
Two reads were all that tied the chunk diff to Postgres, and both have interface
equivalents: the document's content hash (document_content_hash) and its existing
chunks (list_chunk_texts). A chunk_id is {bank_id}_{document_id}_{index} by
construction and the hash is recomputed with the same function that wrote it, so
the diff compares like with like. The write side needed nothing — the extension
delta path already existed and is chosen when mint_txn returns a handle.
The no-op case needed the same treatment one level down. It locks the document
row and compares its hash, which for a store-owned bank read an empty table,
concluded the document had moved, and fell back to the full retain this exists to
avoid. It now asks the store, and re-puts the record with the same body hashes —
PutDocuments mints upload URLs only for bodies it is missing, so nothing uploads.
Against memlake, the three tests that assert observations survive a re-ingest now
pass, and the cluster goes 14 failed/17 passed to 8 failed/34 passed. The 8 that
remain asserts through raw SQL on chunks/documents/memory_units, which a
store-owned bank leaves empty; they cannot pass by construction and are not bugs.
* delete: report a document deletion off the store's state, not its capability
Deleting a document that never existed returned 200 for a store-owned bank. The
report was gated on owns_document_store_for(bank_id), which is a capability of
the store — true for every bank it serves — so it was always true and the 404
this endpoint promises could never be reached.
It now probes the record's existence before deleting. One extra GET on a delete,
which is rare, and the alternative (having delete_document_record return whether
it removed anything) changes the interface both stores implement.
* delete_bank: count what was deleted where it actually lives
deleted_count came from COUNT(*) over memory_units, entities and documents. For a
bank that keeps all three outside SQL those tables are empty, so dropping an
entire bank reported deleting nothing — a success message that reads like a
no-op, and the one number an operator would check before trusting the call.
The counts now come from the store for such a bank (count_memories,
count_documents, and the entity listing's total) and from SQL otherwise. The SQL
path is untouched: 57/57 on the Postgres baseline.
Against memlake, test_http_api_integration goes 5 failed to 1. The remaining one
wants last_document_at on the bank list, which needs a store-side max(created_at)
over documents — there is no ordering on the document listing to take it from,
so it is a counter to add rather than a query to redirect.
* Revert "retain: run the delta path for a store-owned bank"
Enabling delta lost concurrent appends. _delta_batch_write_ext serializes
writers on SELECT content_hash FROM documents ... FOR UPDATE; a store-owned bank
has no such row, so current_hash is NULL, the ownership recheck is skipped
entirely, and three parallel appends each plan against the same base and
overwrite each other. Turns vanish and all three calls return success —
test_concurrent_appends_keep_every_turn fails with delta on and passes with it
off, which is how this was caught.
The same path also never writes the document through the store: it updates it via
upsert_document_metadata, which is SQL and a no-op for such a bank.
So the comment I removed was more right than I credited. I read "full retain of an
unchanged doc is still cheap" as being only about cost, and it was also standing
in for a write path that is not ready.
What the original change got right is kept in the comment, because it is real and
measurable in both directions:
delta ON : orphan-observation and delta-efficiency tests pass (6), concurrent
appends LOSE DATA (1)
delta OFF: appends are safe (1), a no-op re-ingest destroys the observations
standing on the document's facts and re-extracts every unchanged
chunk (6)
Six against one favours enabling it, and one silent loss of user content does
not. Doing it properly means giving the write path the store's own compare-and-set
(put_document's expect_watermark, which the provider maps to guard_head) in place
of the row lock, plus an actual store-side document write. That is the fix; this
is not it.
* trace: hydrate the entry points, which had no text to record
The arms move ids and scores; the payload is fetched later and only for what
survives the trim. The tracer's entry-point log runs before that, so for a store
that does not return full results every entry point was recorded with an empty
string — the trace looked structurally right and said nothing.
The comment at the hydration site already noted the tracer as the one reader
before it ("nothing between retrieval and here touches it except the tracer's
entry-point log"); what it did not note is that the reader wants text.
Hydrated at the recording site rather than by moving the recording after the
trim, because an entry point is a top-10 SEMANTIC result and need not have
survived fusion — moving it would silently drop entry points instead of
emptying them. Costs one fetch of at most ten records, only when a trace was
asked for.
* delete: a document's 404 is about the document, not its memory count
Deleting an already-deleted document reported success under load. The report
consulted the per-document memory count, which is a per-segment tally that does
NOT subtract a delete still sitting in the un-folded tail — straight after a
delete it still returns the pre-delete number. So the endpoint's 404 depended on
how far behind the indexer happened to be, which is why the test passed alone and
failed in-module.
For a store that owns the document store the record is the authority, and the
count is deliberately not consulted: "document not found" is a statement about
the document, and a document with no memories still exists. Stores that do not
own it keep the old behaviour, where memories are the only evidence available.
The count's tail-blindness is a real memlake bug in its own right (a tail
tombstone never decrements what a segment already counted, and a tail re-upsert
double-counts); it is pinned by a test there and is no longer load-bearing for
this endpoint either way.
test_http_api_integration against memlake: test_document_deletion now passes
in-module. Postgres baseline unchanged at 56/56.
* consolidation: key the write-group skip on intent, not on the handle
Skipping the write group for a store-owned bank guarded its decide calls on
`_batch_txn is not None`. Postgres' mint_txn legitimately returns None, so that
silently dropped the decide for every SQL bank too — behaviourally a no-op, but
it is the one observable the write-group tests assert on, and it failed two of
them in CI. Guarding on `_store_owned` leaves the SQL path exactly as it was.
Also declares `txn` on MemoriesExtension.index_facts. The memlake provider takes
it and fact_storage passes it, so the interface was the only place that did not
know about it — the type checker was right to reject the call.
test_consolidation_failure_isolation: 6 passed. Against memlake, the store-owned
path still skips the group as intended.
* tests: bring the interface stubs back in step with the seam
Four CI failures, all the structural tests doing their job after this branch
widened the MEMORIES interface:
* the in-memory stub inherited the NotImplementedError default for
set_document_tags, get_entity_graph, list_documents, retain and
count_documents — the test exists to stop a capability being added and then
silently never exercised, so the stub implements them.
* its index_facts, put_document and apply_edit were missing txn,
expect_watermark and entity_names respectively.
* `_NonSqlStore` in test_list_banks_non_sql_store is duck-typed rather than a
MemoriesExtension, and bank deletion now counts what it removed THROUGH the
store (the SQL tables are empty for such a bank, so COUNT(*) reported 0 while
dropping everything). Its teardown deletes the bank, so it needs those three
methods too.
One assertion changed rather than the code: the chunk-hydration test pinned
`list_chunk_texts`, and hydration deliberately moved to the addressed reads
because `list_chunk_texts` downloads a document's whole packed chunk blob and a
many-chunk recall was paying that per document. The property — the body came
from the store rather than the empty SQL row — is what the test now asserts,
without naming one method.
Also silences F401 on the TYPE_CHECKING re-export block in __init__.py: `__all__`
is computed from _LAZY_EXPORTS so the resolver cannot drift from it, and a linter
cannot see a computed __all__.
* style: run the repo's lint hook over the files this branch touched
The verify-generated-files job runs the generators plus scripts/hooks/lint.sh and
fails on any resulting diff. Its formatter uses a longer line length than a plain
ruff format, so several signatures this branch touched were re-joined onto one
line, and the merge resolution left a stray blank line after a pytestmark.
Only the Python files this branch actually touches are included. The hook also
rewrites the generated TypeScript clients and some control-plane components, and
those diffs are generator/formatter-version artefacts of running it locally
rather than anything this branch changed — committing them would put unrelated
churn in a PR that is already large.
|
||
|
|
1d9a6a381c |
feat(llm): add GitHub Copilot subscription provider (#3597)
Adds a `github-copilot` LLM provider backed by the official GitHub Copilot SDK, using the signed-in Copilot entitlement with no `HINDSIGHT_API_LLM_API_KEY`. Verified end-to-end against a live Copilot subscription: retain, consolidation and reflect all run on the provider. Review fixes on top of the original submission: - default to `gpt-5.6-terra`; the submitted `gpt-5.6-sol` is not in the model list the runtime serves, so every call failed and the provider was unusable out of the box - make a rejected request configuration terminal instead of a runtime failure; an unavailable model was invalidating the shared runtime and respawning `copilot --headless` once per attempt (5 spawns at max_retries=3, ~12 at the default of 10) - let a token-authenticated host run without `~/.copilot`, so the documented COPILOT_GITHUB_TOKEN / GH_TOKEN / GITHUB_TOKEN path works in containers and CI - regenerate skills/hindsight-docs (the provider list also feeds the generated faq.md) Co-authored-by: Max Marino <dudujuju828@users.noreply.github.com> |
||
|
|
7f7d2a0a38 |
chore(deps): close every open medium/high Dependabot alert (#3624)
* chore(deps): sweep open medium/high Dependabot alerts across all lockfiles * chore(deps): bump @hey-api/openapi-ts to 0.97.3 and regenerate the TS client * docs(scripts): note that the Deno client patch anchor tracks the generator version |
||
|
|
8b78b4ac04 |
test: assert memory state via the engine read API, not raw SQL (#3591)
The suite asserted memory state by querying `memory_units` / `memory_links` /
`unit_entities` directly. That couples tests to the physical schema and makes
them unable to run against any store that keeps memory rows outside Postgres.
This ports what the read API can answer, extends it where it could not, and
marks the residue that is Postgres-shaped by nature.
**Ported to the engine API.** The "unconsolidated count" assertions spelled out
`consolidated_at IS NULL AND consolidation_failed_at IS NULL AND fact_type IN
('experience','world')` — character-for-character what
`list_memory_units(consolidation_state='pending')` already means, so seven sites
across five files became one call. Observation lineage, entity/tag checks and
chunk provenance moved to `list_memory_units` / `get_memory_unit` /
`list_document_chunks`; where the old query was an inner join, the port keeps
the same filtering and says why.
**Read model extended** so the rest could follow: `updated_at` and
`source_memory_ids` on list items, `entity_kind` on entity items, and a
list-valued `fact_type` that matches any of them. Every field is a column of the
row the query already fetched — the projection grows, the plan does not — so no
opt-in flag was needed and production paths pay nothing. Covered by a new test
module driving it all through the engine on retain-written units.
**260 of 6297 tests marked `memory_backend_incompatible`**, in two passes: those
that assert Postgres-internal state (raw `memory_links` counts, `embedding` /
`search_vector`), and those whose fixtures only exist in Postgres. The second set
was chosen on evidence — each both failed against a non-SQL store and touches
those tables in its body, a helper, or a fixture — never by grep alone. Postgres
runs are unchanged; the marker only takes effect behind
`-m 'not memory_backend_incompatible'`.
Also green-lights two checks that were red before this branch: the repo formatter
over five test files, and the coding-agents docs generator, which now rewrites our
own doc links to site-relative the way it already did for assets — the naive
regeneration would have degraded the docs-skill reference's file-relative links.
|
||
|
|
3e6a812f71 |
fix(deps): bump google-genai past the Vertex AI eu/us multi-region fix (#3567) (#3568)
google-genai 1.53.0 built `{location}-aiplatform.googleapis.com` for every Vertex AI location, a host that does not exist for the multi-region codes "eu" and "us", so those regions 404'd on every call. Raise the floor to >=1.72.0 (the upstream fix, googleapis/python-genai#2498) and re-lock to 2.18.1, plus a regression test on the resolved endpoint per region kind.
|
||
|
|
e5b49eb672 |
Release v0.9.1
- Update version to 0.9.1 in all components - Regenerate OpenAPI spec and client SDKs - Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed - Python client: hindsight-clients/python - TypeScript client: hindsight-clients/typescript - hindsight-all npm wrapper: hindsight-all-npm - Rust CLI: hindsight-cli - Control Plane: hindsight-control-plane - Helm chart - Sync documentation to version-0.9 |
||
|
|
e8c42f2a74 |
perf(recall): make temporal extraction ~9x faster without changing behaviour (#3452)
* test(recall): characterize temporal extraction + latency harness
Golden suite snapshots 2538 (query x reference-date) results from the current
implementation so the search_dates optimisation can be proven behaviour-preserving.
Adds a burst@N harness modelling how recall actually calls this (inline on the
event loop) and a perf diary with the measured baseline.
* perf(recall): skip dateparser search when no span could score
_date_match_score awards points only for an ASCII digit or one of four English
word sets, and search_dates returns substrings of the original text. A query
containing none of those cannot produce a scoring match, so the search is
guaranteed not to change the answer and can be skipped.
burst@32 p99 97.0ms -> 16.3ms; non-temporal queries 59ms -> 0.04ms.
* perf(recall): exact-equivalent language detection without the redundant work
Replaces search_dates' detection with a copy that memoises the O(locales^2)
unique-character sweep, hoists pop_tz_offset_from_string out of the per-locale
loop (199 identical calls -> 1), and skips the strip-timezone retry when
stripping changes nothing. Differential test runs it against dateparser's own
implementation over the corpus plus ~2600 random/mixed-script strings.
Also fixes a bias in the burst harness: it only ever issued workload[:N].
* perf(recall): upgrade dateparser to 1.4.2 and gate temporal-extraction latency
Upgrade brings three fixes that postdate 1.2.2: unsafe pickle deserialisation of
timezone data and an eval() in locale metadata (1.4.0), and ReDoS/quadratic
backtracking on long digit runs (1.4.1). The last one is also a large perf win on
this path -- a 400-char digit run drops from 143.8ms to 2.0ms of parse time --
because consolidation recalls pass stored fact text as the query.
17 of 2538 golden cases change, all upstream behaviour fixes ('two days later'
resolved backwards; '1mon ago' read '1' as January). 'so what do we do now' now
yields a today-constraint where it previously yielded none; flagged in the diary.
Adds latency gates (CPU-time budgets in fast CI, wall p99 under 32/64/128/256
concurrent callers marked slow) and ports four pre-existing tests off the
internals this work replaced.
* perf(recall): run temporal extraction off the event loop
It is pure CPU on an async request path, so running it inline froze the loop for
its full duration and stalled every other in-flight request in the process --
measured at 16 concurrent document-sized extractions, the loop got a single
scheduler tick in 1.3s.
The pool is deliberately one worker. The work holds the GIL, so widening it adds
no parallelism and costs throughput badly: 1 worker 1438ms, 2 workers 2091ms,
4 workers 4751ms, unbounded asyncio.to_thread 16688ms (12.8x worse than inline)
-- all while inline was 1318ms. One worker preserves throughput (+9%) and drops
max loop stall from 1318ms to 2.8ms.
Safe to run off-thread only as of the detector rewrite: the analyzer now owns its
_ExactLanguageSearch instead of sharing dateparser's self-mutating singleton.
* refactor(recall): address code-review findings
- _char_tables returned a 2-tuple; project rule is no multi-item tuple returns
even for private helpers. Now returns a LocaleCharTables dataclass.
- Add missing type hints on the settings parameters.
- DEFAULT_LANGUAGES is set dynamically on Settings, so read it via getattr.
* test(recall): consolidate temporal-extraction tests into one suite
Five separate test modules covering one change is more files than the change
warrants. Merged into tests/test_temporal_extraction.py with five sections:
golden corpus, pre-filter soundness, detection equivalence, off-loop execution,
latency gates.
query_analyzer_corpus.py and query_analyzer_bench.py stay separate: the corpus is
shared data and the harness is runnable standalone, neither is a test module.
* test(recall): size latency budgets for CI hardware, not a dev machine
test_whole_corpus_cpu_budget failed in CI at 2.73s against a 2.0s budget. The
budget was set from local timings; the shared runner is ~5x slower, so a budget
tuned locally flakes there.
These are regression tripwires, not benchmarks. Re-sized against what they are
meant to catch -- the pre-optimisation corpus sweep was ~55s CPU, so 10s still
catches a regression of that class with room for the slowest runner.
|
||
|
|
b12646f49e |
Release v0.9.0
- Update version to 0.9.0 in all components - Regenerate OpenAPI spec and client SDKs - Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed - Python client: hindsight-clients/python - TypeScript client: hindsight-clients/typescript - hindsight-all npm wrapper: hindsight-all-npm - Rust CLI: hindsight-cli - Control Plane: hindsight-control-plane - Helm chart - Create documentation version-0.9 |
||
|
|
8de576b4b7 |
fix(deps): make macOS installs work without a Rust toolchain (#3199)
litellm publishes no macOS wheels for any release >= 1.92.0, so every macOS install of the published hindsight-api compiles litellm's sdist Rust/PyO3 bridge. That silently required a Rust toolchain, and litellm 1.95.0 raised the bar further (vendored aws-smithy crates need rustc >= 1.94.1), breaking even machines with a recent-but-not-newest rustc. A stock 'uvx hindsight-api' / hindsight-all install on macOS failed during daemon startup. Pin litellm to the 1.91.x line on darwin only - the last releases that ship pure-python py3-none-any wheels - so installs need no compiler at all. Linux and Windows keep the existing >= 1.93.0 floor (litellm publishes manylinux/win_amd64 wheels there, including cp314). Verified on macOS arm64: fresh workspace resolve picks litellm 1.91.4 (pure wheel), the embedded daemon boots via @vectorize-io/hindsight-all, and retain/recall run real LLM extraction through litellm successfully. Revisit when litellm ships macOS wheels (BerriAI/litellm#31261). |
||
|
|
468cc4b7d7 |
fix(embeddings): honor query and document prompts locally (#3032)
* fix(embeddings): honor query and document prompts locally * fix(embeddings): require sentence-transformers >=5.0 for local asymmetric encoding encode_query()/encode_document() only exist from sentence-transformers 5.0 onwards. The local-ml extra pinned >=3.3.0, so on 4.x the new code path was an AttributeError at the first encode (recall/retain), not at startup. The extra was only accidentally safe because it also pins transformers>=5.5.0, which ST <5 caps out; docker/docker-compose/custom-models/Dockerfile mirrors the pins with transformers>=4.53.0 and could genuinely resolve to ST 4.x. Also: - assert the real SentenceTransformer class exposes both entry points; the existing test drives a MagicMock, so it passes on any version - explain why the model's own entry points are used instead of prefixing here, and note that prompt-less models are unaffected - document the one case that needs a re-index: a local model that instructs the stored side as well as the search side --------- Co-authored-by: jpmf33 <265638852+jpmf33@users.noreply.github.com> Co-authored-by: Nicolò Boschi <boschi1997@gmail.com> |
||
|
|
55883dc517 |
feat(llm): add openai-responses provider (OpenAI Responses API) (#3121)
Add a provider that talks exclusively to the OpenAI Responses API (`client.responses.create` → `/v1/responses`) — never chat/completions. Motivation: reasoning models such as gpt-5.6-terra reject `reasoning_effort` combined with function tools on `/v1/chat/completions` (HTTP 400 unless `reasoning_effort="none"`, see #2983). Reflect is a tool-calling search loop, so that constraint forces the whole reflect operation — including the final synthesis — to run with reasoning disabled. The Responses API models the chain-of-thought as a first-class reasoning item, so reasoning and tools coexist; reflect's search loop can now run with a real reasoning effort. `OpenAIResponsesLLM` is a standalone `LLMInterface` implementation (OpenAI-only; it deliberately does NOT subclass the multi-vendor chat/completions provider, so it can never route through `chat.completions` and carries none of the groq/ollama/deepseek special-casing). It reuses only provider-agnostic pure helpers (text cleanup, quota-defer parsing). It translates the engine's chat-shaped inputs: - chat messages → `input` items (assistant `tool_calls` → `function_call`, `role="tool"` → `function_call_output` keyed by `call_id`), - nested `{"type":"function","function":{...}}` tools → flattened `{"type":"function","name":...,"parameters":...}`, - flat `reasoning_effort` → a `reasoning={"effort": ...}` object, - `response_format` → `text={"format": {...}}` (strict json_schema or the soft schema-in-prompt + json_object fallback), - reads `response.output_text` + `function_call` items from `response.output`. Generic LLM config flags are honored: `extra_body`, `timeout`, per-call `temperature`/`max_completion_tokens`/`max_retries`. It also wires two flags the chat/completions path drops — `openai_service_tier` (as the native Responses `service_tier`) and `default_headers` (on the SDK client). The conversation is replayed statelessly each turn (`store=False`, no `previous_response_id`); server-side reasoning reuse across turns is left as a future optimization. Wiring: provider registration + dispatch, `PROVIDER_DEFAULT_MODELS` default (`gpt-5.6`), docs + `.env.example` (+ embed template sync), and an `openai` floor bump to `>=1.66.0` for the Responses API surface. Unit tests mock `responses.create` (incl. the generic-flag wiring) and pin that reasoning + tools are sent together on the tool path — the combination chat/completions rejects. Validated live against real gpt-5.6-terra: plain + reasoning, strict structured output, reasoning+tools together with a stateless function_call replay, and a full run_reflect_agent end-to-end (stubbed retrieval, no DB) — tools drove to a correct synthesized answer. |
||
|
|
bc604ab91b |
fix(packaging): bundle licenses in Python distributions (#3067)
Stage the canonical repository license in each isolated Python build context so wheels and source distributions include the MIT text. Declare SPDX license metadata and verify every release artifact before publishing to prevent repository firewalls from quarantining packages. Closes #3054 |
||
|
|
0f9dc55084 | chore(deps): bump pg0-embedded to >=0.15.0 (#3073) | ||
|
|
08995e3013 |
Release v0.8.6
- Update version to 0.8.6 in all components - Regenerate OpenAPI spec and client SDKs - Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed - Python client: hindsight-clients/python - TypeScript client: hindsight-clients/typescript - hindsight-all npm wrapper: hindsight-all-npm - Rust CLI: hindsight-cli - Control Plane: hindsight-control-plane - Helm chart - Sync documentation to version-0.8 |
||
|
|
a514d39624 |
fix(deps): require litellm>=1.93.0 for Python 3.14 support (#2950)
litellm ships its own Rust extension (litellm-rust python-bridge ->
litellm.rust_bridge._native, built via maturin/PyO3). Releases before
1.93.0 publish no cp314 wheel, so on Python 3.14 uv falls back to the
sdist and the build fails:
error: the configured Python interpreter version (3.14) is newer
than PyO3's maximum supported version (3.13)
1.93.0 adds cp314 wheels and a PyO3 that builds on 3.14. Raising the
floor fixes the failure at its source, so the interpreter no longer has
to be constrained.
That lets us drop the UV_PYTHON=3.13 workaround added in #2801: the
_set_uvx_python_compat() helper and its call sites are removed from the
claude-code, codex, cursor, and cursor-cli daemons, along with the tests
that pinned that behaviour. Dropping the pin costs nothing — litellm
publishes no macOS wheels at all, so macOS builds from the sdist on every
version regardless, while Linux now gets a real cp314 wheel instead of a
source build.
Also strengthen the build-api-python-versions CI matrix. It previously
ran only `uv build`, which just packages the source and passes even when
the dependency set cannot install or import on the target interpreter --
it would not have caught this. It now installs into a fresh venv,
byte-compiles, and runs an import smoke test on each version.
Verified on CPython 3.14.4 with UV_PYTHON unset: litellm 1.93.0 installs,
the Rust bridge builds, and hindsight_api plus the engine import cleanly.
Refs #2783
|
||
|
|
21928d7c95 |
chore(deps): bump protobuf to 7.x and OpenTelemetry to 1.44/0.65b0 (#2923)
protobuf 7 was blocked only by opentelemetry-proto <1.44 capping protobuf<7.0; 1.44.0 raised the ceiling to <8.0. Bump the six coupled otel pins together (api/sdk/otlp-proto-http 1.41->1.44, the three 0.6x companions 0.62b1->0.65b0) and protobuf 6.33.5->7.35.1. Verified in a real env: the OTLP HTTP exporter's protobuf-serialized trace payload round-trips through otel's generated proto types, and the Prometheus metrics path works. The otel_component_type kwarg (reason for the original >=1.41 floor) is still present in 1.44. |
||
|
|
705757f362 |
Release v0.8.5
- Update version to 0.8.5 in all components - Regenerate OpenAPI spec and client SDKs - Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed - Python client: hindsight-clients/python - TypeScript client: hindsight-clients/typescript - hindsight-all npm wrapper: hindsight-all-npm - Rust CLI: hindsight-cli - Control Plane: hindsight-control-plane - Helm chart - Sync documentation to version-0.8 |
||
|
|
a23187a456 |
fix(llm): recover malformed JSON via json_repair as a last-resort parse fallback (#2871)
Recover structurally-malformed LLM JSON (trailing commas, unterminated strings, single quotes, invalid \escape) via json_repair as a terminal fallback in parse_llm_json, after fence-strip and control-char scrub both fail. Empty repair result keeps raising JSONDecodeError so retry ladders / #1833 fail-loud still fire. LiteLLM prefers a clean re-roll first (repair only after retries exhausted). Scoped to structural malformation only — the degenerate-but-valid-JSON class (#2544/#2547) is deliberately out of scope. Regenerated the docs skill to clear pre-existing #2865 drift. |
||
|
|
a404071d3b | fix: remove vulnerable API runtime packages (#2851) | ||
|
|
7bb3d1925b |
chore(deps): bump pydantic-settings, transformers, soupsieve (security) (#2727)
Clears the pydantic-settings Dependabot alert across all affected
manifests plus the three high-severity alerts in the root lock.
pydantic-settings 2.12.0/2.14.0/2.14.1 -> 2.14.2 GHSA-4xgf-cpjx-pc3j
transformers 5.3.0 -> 5.12.1 GHSA-fgcw-684q-jj6r
soupsieve 2.8 -> 2.8.4 GHSA-2wc2-fm75-p42x
GHSA-836r-79rf-4m37
pydantic-settings is transitive everywhere (no direct declaration), so
the locks are the only lever. crewai is deliberately left at 2.10.1: the
advisory's range is >=2.12.0,<2.14.2 and NestedSecretsSettingsSource did
not exist in 2.10.x, so it is unaffected.
transformers is a direct dep, and hindsight-api is published, so the
declared floor -- not our lock -- is what protects installers of the
local-ml/local-onnx extras. The old >=4.53.0 floor resolved to 4.57.6
(vulnerable) under any downstream cap of transformers<5, so raise it to
the advisory's first patched version. Note this now fails resolution for
consumers pinned below transformers 5 rather than silently installing a
vulnerable build. The >=4.53.0 floor was already unreachable in practice:
4.53.0 requires tokenizers<0.22, which our own cap excludes.
The tokenizers<=0.23.0 cap is kept. #2055 was caused by transformers
declaring a wider tokenizers range in metadata than its import-time check
enforces, and the cap is what blocks that; the comment now records this
so it does not read as removable.
Root uv.lock is reformatted from lock revision 1 to 3 because uv rewrites
in its current format whenever it writes. The other 32 locks in the repo
are already revision 3 and CI's setup-uv is unpinned, so this aligns root
rather than drifting it. Only 3 versions actually change.
Verified: local-ml sync resolves tokenizers 0.22.2 under transformers
5.12.1; LocalSTEmbeddings and LocalSTCrossEncoder both initialize and run
(the #2055 import path). Lint passes.
|
||
|
|
383f0caa16 |
deps(security): require LiteLLM 1.84.0 (#2651)
Co-authored-by: r266-tech <r266-tech@users.noreply.github.com> |
||
|
|
92f433c904 |
Release v0.8.4
- Update version to 0.8.4 in all components - Regenerate OpenAPI spec and client SDKs - Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed - Python client: hindsight-clients/python - TypeScript client: hindsight-clients/typescript - hindsight-all npm wrapper: hindsight-all-npm - Rust CLI: hindsight-cli - Control Plane: hindsight-control-plane - Helm chart - Sync documentation to version-0.8 |
||
|
|
dae18b1faf |
feat(mental-models): cron-scheduled mental model refresh (#2377)
Adds a third, independent way to refresh a mental model — on a cron schedule — alongside the existing auto (refresh_after_consolidation) and manual paths, driven by the background MaintenanceLoop ticker. API/engine: - trigger.refresh_cron (UTC 5-field cron, croniter-validated); mutually exclusive with refresh_after_consolidation. - PG-only discovery routine public.mental_models_with_cron() (migration f4d1c2b3a5e6); cron due-ness evaluated in Python, refresh only when stale. - HINDSIGHT_API_MENTAL_MODEL_REFRESH_TICK_SECONDS check cadence. - One timing line logged per maintenance sweep. Control plane: - Single "Refresh trigger" choice (Manual / On new memories / On a schedule) with per-option sub-labels; cron input shown only when scheduled. - Live cron schedule preview (human-readable + next/upcoming runs, UTC+local). - "Next refresh" shown next to "last refreshed" in list, dashboard, and dialog. - Fixed an app-wide off-by-one in formatRelativeTime. Regenerated OpenAPI + clients + bank-template schema; i18n across all locales. |
||
|
|
f187d32351 |
deps(security): bump langsmith floor to >=0.8.18 (GHSA-f4xh-w4cj-qxq8) (#2341)
LangSmith SDK TracingMiddleware arbitrary server-side file read (HIGH), fixed in 0.8.18; current >=0.6.3 floor permits vulnerable 0.6.3-0.8.17. Same Transitive-dependency-security-fixes block as the urllib3/cryptography/ authlib/python-multipart floors; no uv.lock in this dir so no re-resolve. |
||
|
|
e1014cc790 |
Release v0.8.3
- Update version to 0.8.3 in all components - Regenerate OpenAPI spec and client SDKs - Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed - Python client: hindsight-clients/python - TypeScript client: hindsight-clients/typescript - hindsight-all npm wrapper: hindsight-all-npm - Rust CLI: hindsight-cli - Control Plane: hindsight-control-plane - Helm chart - Sync documentation to version-0.8 |
||
|
|
f4a0a31f70 |
chore(deps): fix critical/high Dependabot alerts (#2278)
Resolve all 50 fixable critical/high Dependabot alerts across the monorepo. Python (uv.lock): - starlette 1.0.1 -> 1.3.1, python-multipart -> 0.0.32, pyjwt -> 2.13.0, tornado -> 6.5.7, urllib3 -> 2.7.0 across root + integration projects. - cryptography -> 49.0.0 (GHSA-537c-gmf6-5ccf, bundled-OpenSSL OOB read). Lifted the hindsight-api-slim <47 cap: 47/48/49 verified importing and running RSA sign/verify cleanly on linux/arm64 (Docker on Apple Silicon) and native arm64 macOS; the SIGILL of pyca/cryptography#14733 does not reproduce on current tooling (upstream issue closed unconfirmed). - Root and haystack uv.lock pick up uv lockfile revision 3 (the format the rest of the repo's locks and CI's setup-uv@v7 already use). npm: - shell-quote -> 1.8.4 (critical); ws -> 7.5.11 / 8.21.0; vite -> 8.0.16 across root + integrations; embed control-center UI vite ^5 -> ^6.4.3 (build verified); n8n form-data override -> ^4.0.6. - zapier: overrides for form-data, serialize-javascript, tar, tmp, yeoman-environment (dev-only zapier-platform-cli tree); npm audit clean. Not fixed (no safe path): - nltk (llamaindex, pipecat): no patched release exists upstream (<=3.9.4). - pipecat-ai (pipecat): fix needs 1.2.0 but the integration is pinned <1.0 pending a module-restructure migration. |
||
|
|
0135fa39c9 |
fix(mcp): give update_memory/invalidate_memory non-empty descriptions (#2215)
* fix(mcp): give update_memory/invalidate_memory non-empty descriptions update_memory and invalidate_memory (added in #1976) used an f-string as their docstring: f"""{_EDIT_DOC} Args: ... """ An f-string is an expression, not a string literal, so Python never assigns it to the function's __doc__ (it stays None). FastMCP derives a tool's description from __doc__, so both tools — and their bank_id variants — were registered with an empty description. Amazon Bedrock's Converse API rejects any toolSpec whose description is an empty string, so every Bedrock request that advertised these tools failed mid-stream (surfacing to clients as a generic 'internal error occurred while processing the stream'). Providers that tolerate empty descriptions were unaffected, which is why this only showed up on Bedrock. Fix: pass the shared doc constant explicitly via @mcp.tool(description=...), matching how retain/recall already register, and keep a plain-literal docstring for the Args section. Add a regression test asserting every registered tool exposes a non-empty description (both registration paths). * test(mcp): statically reject @mcp.tool definitions without a description AST-parse mcp_tools.py and fail if any @mcp.tool-decorated function lacks both a description= kwarg and a real string-literal docstring (an f-string docstring leaves __doc__ None). Complements the runtime description test by also covering flag-gated tools and pointing at the offending line; needs no engine mocking. * chore(lint): enable ruff B021 (f-string used as docstring) Catches the f-string-docstring footgun repo-wide at lint time — the root cause of the empty update_memory/invalidate_memory descriptions. Clean across hindsight-api-slim; tests/** are excluded from lint so the static test guards that surface instead. --------- Co-authored-by: Nicolò Boschi <boschi1997@gmail.com> |
||
|
|
6f59a09479 |
Release v0.8.2
- Update version to 0.8.2 in all components - Regenerate OpenAPI spec and client SDKs - Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed - Python client: hindsight-clients/python - TypeScript client: hindsight-clients/typescript - hindsight-all npm wrapper: hindsight-all-npm - Rust CLI: hindsight-cli - Control Plane: hindsight-control-plane - Helm chart - Sync documentation to version-0.8 |
||
|
|
f0802b826b |
chore(ci): enforce unused imports/vars + advisory dead-code scan (#2144)
* chore(ci): enforce unused imports/vars + advisory dead-code scan Enable ruff F401 (unused imports) and F841 (unused variables) -- previously ignored as "too noisy" -- across hindsight-api-slim, hindsight-dev, and hindsight-embed, and clean up the resulting violations. These are now blocking: lint.sh auto-removes them and the verify-generated-files CI job fails on any leftover diff. Add an advisory dead-code scan for what the linter cannot see -- whole unused Python functions (vulture) and orphaned files/exports/dependencies in the control plane (knip): - scripts/hooks/check-unused.sh runs both locally - new non-blocking check-unused-code CI job surfaces findings on PRs - hindsight-control-plane/knip.json tunes out toolchain false positives vulture stays advisory because its function/argument heuristics false-positive on FastAPI/SQLAlchemy/Pydantic patterns; knip can be flipped to blocking once the control-plane dead code (PR #2135) lands. * chore(ci): make knip blocking on unused files/deps; remove dead deps #2135 deleted tooltip.tsx but left @radix-ui/react-tooltip in package.json, and react-chrono / three were never imported. Remove all three, and declare @radix-ui/react-visually-hidden (used in directive-detail-modal but unlisted). With the control-plane tree now clean, the check-unused-code job runs `knip --include files,dependencies,unlisted` as a BLOCKING step. vulture and knip's unused-exports check (the shadcn/ui surface is kept intentionally) stay advisory. |
||
|
|
4dc149a1ac |
Release v0.8.1
- Update version to 0.8.1 in all components - Regenerate OpenAPI spec and client SDKs - Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed - Python client: hindsight-clients/python - TypeScript client: hindsight-clients/typescript - hindsight-all npm wrapper: hindsight-all-npm - Rust CLI: hindsight-cli - Control Plane: hindsight-control-plane - Helm chart - Sync documentation to version-0.8 |
||
|
|
6ba4aeaf03 |
chore: format test files with ruff (enable formatter on tests/) (#2074)
Tests were excluded from both ruff lint and format via the top-level [tool.ruff].exclude in hindsight-api-slim, hindsight-embed and the shared ruff.toml. As a result test files drifted from the formatter's style and every PR that touched a test (or ran format-on-save) carried large formatting-only churn. Move the tests exclude into [tool.ruff.lint].exclude (and [lint].exclude in ruff.toml) so the formatter now covers tests while lint rules — too noisy for test code (unused imports/vars, import ordering) — stay excluded. Then run ruff format across all test directories. Note: lint.exclude is a post-traversal path filter, so it needs the glob form 'tests/**' rather than the directory form 'tests/' used by top-level exclude. |
||
|
|
bfdc1c5e65 |
fix(deps): cap tokenizers<=0.23.0 for local-ML extras (#2055) (#2057)
* fix(deps): cap tokenizers<=0.23.0 for local-ML extras (#2055) transformers (incl. 5.x) hard-requires tokenizers<=0.23.0 via a runtime check, but tokenizers 0.23.1 is the latest on PyPI. Without a lockfile, an in-place upgrade to 0.8.0 can resolve tokenizers 0.23.1 and break local embeddings/reranker startup with an ImportError. Pin the compatible range in the local-ml and local-onnx extras. * chore(deps): update uv.lock for tokenizers cap (#2055) |
||
|
|
8cadecb3a1 |
Release v0.8.0
- Update version to 0.8.0 in all components - Regenerate OpenAPI spec and client SDKs - Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed - Python client: hindsight-clients/python - TypeScript client: hindsight-clients/typescript - hindsight-all npm wrapper: hindsight-all-npm - Rust CLI: hindsight-cli - Control Plane: hindsight-control-plane - Helm chart - Create documentation version-0.8 |
||
|
|
b5a324b77b |
feat(embeddings): add ONNX local provider (#1970)
* feat(embeddings): add ONNX local provider * fix(embeddings): download ONNX external data sidecars * fix(embeddings): address ONNX provider review feedback |