Commit Graph

227 Commits

Author SHA1 Message Date
Chris Bartholomew 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
2026-09-07 10:20:57 +02:00
Nicolò Boschi 280f098202 feat(retain): inline images and files as first-class content (#4077)
Makes images and files first-class raw content in `retain`. `content` accepts an
ordered list of text/image/file blocks, the extractor reads each attachment in
the position it occupies, and every read surface hands back the attachments
behind what it returns. A plain string behaves exactly as before — text-only
retain is byte-identical, because everything new sits behind an ATTACHMENTS
block that is empty when a chunk carries none.

Blocks are flattened at the API boundary into one canonical body with atomic
placeholders, so `documents.original_text` stays plain text and content_hash
idempotency, `update_mode=append`, chunk-delta re-extraction and
`reprocess_document` keep working untouched. Bytes live in the existing
FileStorage abstraction, content-addressed by sha256.

Schema (one migration, both dialects): `attachments` for the blob,
`document_attachments` for which documents reference it, and
`memory_units.attachment_ids` for which attachments a *fact* came from — a
column rather than a third table, because those ids behave exactly like `tags`.

Provenance is per fact, not per chunk. Extraction runs one call per chunk, and a
chunk holding a screenshot also holds the prose around it, so a chunk-level edge
cited the diagram as evidence for the paragraph that never mentioned it. The
extractor is asked instead, and a fact stated in the prose carries nothing.

Extraction quality was measured against a real image-QA dataset with a raw-VLM
ceiling arm before merging: transcribing structured attachments rather than
summarizing them, and recording how each value is drawn, took the gap between
"the model can read this off the image" and "memory can answer it" from 31.3% to
10.0% on the same 40 charts. The prose-article benchmark went 75% -> 100% over
the same change, so it is not chart-specific tuning.

Also here:

* A vision slot (`HINDSIGHT_API_VLM_*`) so attachment-bearing chunks alone use a
  vision model and text-only chunks stay on a cheaper retain LLM. A vision call
  deliberately does not fail over to the retain chain's text models — that would
  reintroduce the silent omission the 422 gate exists to prevent.
* The extension retain hook can now see each attachment (media type, size, kind,
  filename) and refusing a retain reclaims its bytes, which previously stayed
  fetchable forever.
* A filename lives on the document edge, not the blob: the same PDF can be
  attached under a different name elsewhere, and content-addressing made the
  first name win for both.

Known limitations, documented rather than hidden: store-owned memory backends
get nothing (that retain path is Postgres-free and pre-dates this work), very
dense pages are sampled rather than exhausted, and the Python client's
ContentBlock is a plain dict where TypeScript gets the real union.

Breaking for Go and Rust callers: `content` is now a union, so a bare string no
longer satisfies it. Go gains a `TextContent()` helper; Rust uses
`Content::Variant0(...)`.
2026-09-04 12:48:08 +02:00
Nicolò Boschi 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.
2026-09-02 17:58:22 +02:00
Nicolò Boschi f747d96c38 feat(recall): fuzzy tag matching on tag_groups leaves (#4026) (#4028)
* feat(recall): fuzzy tag matching on tag_groups leaves (#4026)

Tags increasingly hold user-facing names, and tag filtering is exact array
containment. A caller filtering by what a query mentioned passes `typsecript`,
and the memory tagged `typescript` is dropped before ranking runs — so the
recall returns empty even though ranking would have found it. Better ranking
cannot fix that; the match itself has to tolerate the misspelling.

A `tag_groups` leaf gains one optional field, `resolve`, defaulting to `exact`
(today's behaviour). Set to `fuzzy`, its tags are matched against the bank's
tags by similarity instead of literally. That is the whole API change: no new
config, no new response field, no new TagsMatch values.

Matching is trigram similarity at 0.45 via `entity_resolver._trigram_similarity`,
already verified byte-identical to Postgres `similarity()` (#3107), so Postgres,
Oracle and store-owned backends behave the same. Resolves: typescropt/typescript
0.57, kubernets/kubernetes 0.62, user:alcie/user:alice 0.47. Does not: mango/mongo
0.33, k9s/k8s 0.14.

Known limit, pinned by a test: similarity is length-sensitive. A short tag has
few trigrams and one edit destroys three of them, so kakfa/kafka scores 0.20 and
does not resolve. Fuzzy matching is effective on descriptive tags and close to
inert on very short ones — and that same property is what keeps different short
words apart.

Resolution runs above the SQL layer, rewriting the leaf into ordinary exact
leaves so only those reach the query builders. The ~20 SQL call sites, the
Python mirrors used on the graph path, the GIN(tags) index, the store protocol
and the Oracle dialect are untouched. Per mode, for tokens t1..tn resolving to
E1..En: any/any_strict becomes one leaf over the union; all/all_strict becomes an
AND of one OR-leaf per token, so a memory must carry some spelling of each;
exact becomes an OR over the cross product, one tag per token, bounded at 32
branches and checked before enumeration, with combinations carrying fewer
distinct tags than tokens dropped.

Failing closed: a tag that resolves to nothing stays in the filter as itself,
leaving the leaf unsatisfiable. Returning an empty list would read as "no tag
filtering" in the builders and hand back the whole bank.

The vocabulary comes from the existing `list_tags` store method, so there is no
schema change. A bank holding more than 5000 distinct tags is rejected with a
422 rather than resolved against a truncated vocabulary.

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

* fix(clients): make tag_groups reachable through the wrapper SDKs

`Hindsight.recall(tag_groups=...)` and `.reflect(tag_groups=...)` raised
ModuleNotFoundError for every caller. The wrapper imported
`hindsight_client_api.models.recall_request_tag_groups_inner`, which the
generator does not emit: it produces one union model per tag_groups shape and
names it after the first schema that used it, so the class is
`MentalModelTriggerInputTagGroupsInner`. Nothing caught it because the wrapper's
tests never passed tag_groups and the import sits inside the `if tag_groups is
not None` branch, so it only fires when the feature is used.

Fixed at both call sites, with mirrored regression tests on the Python and
TypeScript wrappers asserting a tag group reaches the request body with the
leaf's `resolve` intact — the pair the review checklist asks for, since a
capability that exists in one wrapper and not the other is invisible to
client-coverage-check (it validates request-body fields, not wrapper surface).

Also thread tag_groups through the control-plane recall and reflect proxy routes
and their client types. Both accepted every other tag filter and silently
dropped this one, so no control-plane caller could use compound tag filtering at
all — fuzzy or exact.

Two follow-ups from reviewing #4026:

- Reject `resolve="fuzzy"` in a mental-model trigger's tag_groups. A trigger's
  scope is read by two paths that resolve differently: the refresh runs through
  reflect, which resolves fuzzy leaves, while the staleness check and the scope
  watermark build SQL straight from the stored groups and do not. A stored fuzzy
  leaf would build content from the resolved tags while never being marked stale
  by them, and would drift as the bank's tag vocabulary changes.

- Promote `entity_resolver._trigram_similarity` to `trigram_similarity`. Two
  subsystems now share it — entity resolution and fuzzy tag matching — so the
  leading underscore misrepresented a real contract between them.

Claude-Session: https://claude.ai/code/session_011KDT484YujNcBxHzbfzNbk
2026-09-02 16:50:53 +02:00
Nicolò Boschi e8518c392f fix(clients): close the bank-config drift between the wrapper SDKs and the server (#4030)
The wrappers (hindsight_client.py / src/index.ts) are hand-written layers over
the generated SDKs, and they build the config PATCH body by enumerating fields.
The endpoint takes a free-form `updates: dict[str, Any]`, so nothing regenerates
that list and nothing checked it. It had drifted three ways:

- 22 of the server's 47 _CONFIGURABLE_FIELDS were reachable from neither
  wrapper, including the whole recall-budget group and memory_defense.
- 8 more were in Python but not TypeScript.
- reflect.apply_all_directives was missing from both.

Both wrappers now cover all 47, plus apply_all_directives.

Two wrong type hints went with it. llm_gemini_safety_settings was declared
dict[str, str] in Python, but the server, the Gemini provider and the control
plane all take a list of {category, threshold} — a caller following the hint
sent a shape the provider rejects.

client-coverage-check existed to catch exactly this and was wired into no
workflow, so it sat red on main with apply_all_directives missing from both
wrappers. It now runs in CI next to check-cli-coverage, and it can see bank
config: the OpenAPI walk it already did reports updateBankConfig as covered by
its single `updates` property, so the check reads _CONFIGURABLE_FIELDS from
config.py directly.

The bank-config check verifies forwarding, not just acceptance. Declaring the
keyword is not enough — a field accepted and never written into the request body
is silently dropped, which is how this class of bug presents: the caller gets a
200 and no change.

Refs #4029
2026-09-02 14:24:59 +02:00
Nicolò Boschi 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.
2026-09-02 12:52:08 +02:00
Nicolò Boschi 6da65bc194 feat(changelog): enumerate each release's migrations, with tables and volume (#3996)
Release notes said nothing about schema changes, so an operator had no way to
tell from the changelog whether upgrading meant a long startup migration.

Every release entry now ends with a "Database Migrations" section listing each
Alembic revision added in the tag range, the tables it touches tagged with how
much data they hold, and a link to the PR that introduced it. A release that
alters a high-volume table also gets a warning line above the list.

The section is enumerated from git (`--diff-filter=A` over the versions dir),
never through the LLM: the same range must always produce the same list, and
the volume of a table is a property of the schema rather than a per-run
judgement, so it comes from a reviewed map that a test forces new tables into.
Tables are read from Alembic ops and raw DDL/DML positions and intersected with
that map, which drops prose and index expressions the loose SQL patterns pick
up. Bare FROM/JOIN reads are ignored — a migration that defines a function
reading memory_units neither rewrites nor locks it. DROP INDEX names no table,
so the owner is recovered from the index identifier.

Claude-Session: https://claude.ai/code/session_01J52s5Lbg5Lp7KwRyhqg8f3
2026-09-01 15:47:04 +02:00
Sanderhoff-alt 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.
2026-08-31 12:50:04 +02:00
Nicolò Boschi c2486a2dc4 fix(benchmarks): repair retain_memory's imports of removed engine symbols (#3789)
The retain memory benchmark could not start: both symbols it imports from the
engine had been removed out from under it.

- `count_tokens_windowed` was deleted by #3788, which moved the tokenizer to
  quicktok. Its count-only API allocates nothing and is exact, so the windowed
  approximation #3756 introduced has no reason to exist; the benchmark now
  calls `count_tokens`.
- `_split_contents_into_sub_batches` was replaced by the streaming
  `_iter_raw_sub_batches` in #3770. The row now consumes the iterator instead
  of listing it, which is what the retain loop does.

Also drops the comment describing tiktoken's ~80 MB encoding table, which no
longer applies.

Verified by running the benchmark at --mb 2.
2026-08-25 15:35:12 +02:00
Nicolò Boschi 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.
2026-08-25 15:02:26 +02:00
Nicolò Boschi 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
2026-08-25 12:19:47 +02:00
Nicolò Boschi 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.
2026-08-24 15:03:07 +02:00
Nicolò Boschi fe5c25d64c fix(mental-models): store delta documents as verbatim markdown blocks (#3361, #3273) (#3622)
* fix(mental-models): store delta documents as verbatim markdown blocks (#3361, #3273)

A knowledge page could come back with a whole table welded onto one line, in
sections no delta operation had named, and never recover. The delta refresh was
blamed, but the damage was done one refresh earlier.

`structured_content` was only the source of truth on the delta leg. The full leg
stored the LLM candidate markdown verbatim in `content` while deriving the
structure from it with `parse_markdown`, a typed-block parser that flattened
anything its union could not express -- nested lists, list continuation lines,
blockquotes, hard line breaks, horizontal rules, HTML, indented code, table
alignment, a table row missing an outer pipe. 15 of 16 common constructs lost
information, and every loss was a fixed point, so no later refresh could undo
it. The two columns disagreed by construction, and the next delta refresh
published the degraded one over the whole document.

Schema v2 stores each block as a verbatim markdown fragment plus an id. Nothing
parses a table, so nothing can flatten one. `parse_markdown` is deleted;
`split_markdown` replaces it and recognises only ATX headings and blank lines,
both fence-aware, which makes it lossless -- asserted as a property over a
26-case corpus of exactly the constructs v1 destroyed.

Blocks are now addressed by id rather than by index (#3273). An index has to be
counted by the model, and an off-by-one lands in range, silently overwrites an
unrelated block, and is recorded as a success. An id is copied, not derived; one
that does not resolve -- or that names a block in a different section -- is
skipped and reported. Operation payloads are plain markdown strings, so the
model no longer has to emit a typed block union either.

`content` is now always the render of `structured_content`, on both legs, so the
two can no longer drift apart.

Also here:
- `parse_llm_json` escapes `\n \r \t \b \f` inside JSON string values instead of
  blanking them. A model writing a markdown table into a string often forgets to
  escape its line breaks, and replacing them with spaces delivered the table
  already collapsed. Other control characters keep the previous treatment.
- A model that adds a table row as its own block would render a broken table, so
  the prompt asks for `replace_block` and bare rows landing directly after a
  table are folded into it.
- Migration `d1e2f3a4b5c6` clears v1 blobs. They are a lossy projection of the
  row's own `content`, so there is nothing to convert: the next refresh
  re-imports the structure from the markdown, losslessly. `content` is untouched.
- The Gemini eval fixture pinned `gemini-2.0-flash`, which the provider has
  retired (404), so the whole eval class was dead.

Verified against a real model: `test_document_survives_many_delta_rounds_intact`
runs five delta rounds feeding one new fact each, asserting after every round
that content is the render of the structure, that no line welds a table
separator to other cells, that sections no operation named are byte-identical,
and that a section never named across the whole run is unchanged at the end. Run
four times, 20 real rounds, green. It is what caught the orphan table row.

* fix(mental-models): write the structure whenever content is written

`create_mental_model(content=...)` and `create_knowledge_page` inserted the
markdown and left `structured_content` NULL, so a model authored as markdown had
no structure until its first delta refresh -- and that refresh was then the one
to derive it, silently reshaping a document nobody had asked it to touch.
`update_mental_model(content=...)` was worse: it could set the markdown while
leaving the *previous* document's structure in place, so the two columns
described different documents until the next refresh papered over it.

Nothing enforced the pairing; the refresh path just happened to pass both.

Both writes now go through `canonical_document()`, which splits the authored
markdown and hands back the structure together with its render. The insert
stores both, and the update derives the structure whenever a caller supplies
content without one. A refresh still passes both explicitly -- there the
structure is authoritative and the markdown is already its render -- and is
untouched. The derivation is hoisted above the embedding computation so the
embedding, the history snapshot and the UPDATE all see one text.

`content` is therefore the render of `structured_content` from the first byte
rather than from the first refresh, which is what the assertion churn in this
commit is: authored markdown now comes back canonicalised, so a document that
was stored as "v1" reads back as "v1\n".

Also makes the migration test rerunnable: a pg0 instance survives between runs
and alembic will not replay a migration on a DB already stamped past it, so
seeding into it would have left the rows untouched and the test asserting
nothing.

* feat(reflect): answer with a document, render the markdown from it

The mental-model refresh asked the agent for markdown and worked out the
document's structure by reading that markdown back. Reading LLM markdown back is
where #3361 destroyed tables, and it is unnecessary here: the refresh knows it is
producing a document, so it can ask for one.

`done()` gains a document mode. Instead of an `answer` string it takes a
`document` -- an ordered list of sections, each with a heading, a level and its
blocks -- and the markdown that gets stored and shown is rendered from it. The
model no longer writes the markdown that gets persisted, and nothing parses
markdown to find out what the model meant. `answer` is not merely discouraged in
that mode, it is absent from the schema, so there is no escape hatch back to
prose.

The shape is deliberately flat -- an array of sections holding arrays of block
strings, no unions. A tool schema goes to the provider verbatim and not every
provider accepts `oneOf` (Gemini rejects it), and a shape the model can fill
without thinking is one it fills correctly.

`document_from_sections` is tolerant, because a tool call is still model output:
a missing heading, a `##` the model prefixed anyway, an out-of-range level or a
non-string block is coerced rather than rejected. A block holding several
blank-line-separated fragments is split into one block each, so the document
keeps the granularity delta operations address even when the model packs a whole
section into one string.

Downstream is unchanged: the rendered markdown still flows on as `text`, so
structured-output extraction, the length rewrite and the HTTP response all
behave as before. The one place the two could drift is the length rewrite, which
edits the text after the fact -- there the structure is re-derived from the
rewritten markdown, which is lossless and keeps the invariant that the stored
text is exactly what the stored structure renders.

Splitting markdown is now only an import path: a model created from authored
markdown, a restored export, or a run that produced plain text anyway (a
provider that dropped the tool call, the iteration-limit answer).

Verified against a real model: all five `hs_llm_core` refresh evals pass with the
agent emitting structure, including the five-round stability run. The ordered
list that a previous run had rewritten now survives untouched, and the new table
row lands inside the table rather than beside it.

* test(benchmarks): compare two builds on how a document survives being edited

Neither half of "is the new pipeline better" was measurable before this. The
unit tests prove the mechanics in isolation and the refresh evals prove one
build behaves, but nothing compared a build against another one on the thing
that actually broke: a document rewritten by an LLM over and over.

The harness talks HTTP only, so the same code drives a server built from any
revision. That is what makes an A/B possible without a feature flag inside the
code under test: run it once per build, compare the two artifacts. Every
document from every round is stored, and metrics are recomputed at comparison
time, so sharpening a metric costs nothing instead of another few hundred LLM
calls.

Two things it measures, deliberately separately.

Structural, no LLM: collapsed tables (the detector from #3361), rows, nesting,
hard breaks, fences and quotes lost, sections that drifted with no operation
naming them, plus pipeline health and latency. Damage is counted only in
sections no operation named -- a refresh that rewrites a section it targeted may
legitimately restructure it, and scoring that as corruption would punish the
model for doing its job.

Content, judged: each round declares what must be true afterwards and what must
no longer be stated, checked one claim at a time so a miss points at a fact
rather than at a score; plus a blind pairwise preference between the two builds'
final documents, judged in both orderings so position bias cannot decide it.

Three cases, and the third is the point. `api-reference` carries every fragile
construct; `onboarding-playbook` carries none, because a change that fixes
tables while degrading ordinary prose is not an improvement and that is where it
would show; `release-runbook` puts a table with a missing outer pipe in a
section the fact stream never touches. Both details are load-bearing: a
well-formed table never triggered the bug, and a model asked to edit a malformed
table tends to rewrite it correctly, repairing the damage before it can be
measured. The reported failure was in sections the operations never named, where
nothing could repair it.

Token usage is not reported. The stored reflect_response does not carry it, and
a column that is always zero reads as "this is free" rather than "this is not
measured here".

* test(benchmarks): harden the judging, and say what the A/B measured

Running the benchmark against main and the branch turned up three problems in
the benchmark itself, all of which would have made its verdict untrustworthy.

The runner slept a fixed interval instead of awaiting the async operations it
submitted, so its first results were five rounds of "Generating content..."
scored as though they were documents. Retain, create and refresh are all
submit-and-poll; it now polls, and refuses outright if the seed it is about to
measure is still a placeholder.

Damage was attributed to the whole document rather than to the sections nobody
asked to change, which scored a model deliberately restructuring a section it
targeted as if the machinery had corrupted it. Damage is now measured only in
untouched sections, and the metrics are recomputed from the stored documents at
comparison time, so this sharper reading could be applied to results already
collected instead of paying to re-run them.

A single judge call decided each claim, and one pedantic reading moved a build's
score: a document describing an operation as "synthesises stored memories" was
scored as not supporting "answers questions over stored memories" — the same
operation, in the wording the source fact itself used. Claims are now decided by
majority of three, and that claim was rewritten to test the fact rather than one
phrasing of it. A claim a correct document can fail measures the corpus, not the
pipeline.

The report prints mean document length beside the preference column, because
judges favour longer documents and a preference that tracks that column should
be read sceptically rather than counted.

The prompt change is the finding that landed back in the product: stating a
document's structure is more clerical than writing prose, and the model got
terser at it — measurably shorter documents that the judge liked less. Document
mode now says plainly that the structure is the shape of the answer, not a
budget for it.

Baseline recorded in baseline_report.json: 45 refresh rounds per build from
identical seeds. main lost 3 tables to collapse, 9 table rows, 6 levels of list
nesting and 5 hard line breaks across 6 damaged rounds, and drifted 14 sections
nobody had named. The branch lost nothing and drifted nothing. Content came out
level — 100% recall and zero stale claims on both sides.

* fix(mental-models): give the delta leg the document's own token budget

`max_tokens` was enforced in exactly one place: a rewrite of the *synthesis*
answer when it came back longer than the budget. In delta mode that answer is
only context for the operations call and never becomes the document, so the
document that actually gets stored was never measured against the budget at all.

A delta refresh only adds. The document-evolution benchmark measured ~20 tokens
of growth per round across 45 rounds, monotonic, which crosses the 4096-token
knowledge-page default after a couple of hundred refreshes — and knowledge pages
refresh after every consolidation. The configured budget was quietly ignored for
the entire life of a page after its first full build.

Truncating the document here would delete knowledge nobody asked to delete, so
the budget is stated instead: the delta call is told the document's current size
against its budget, and when it is over, asked to make room with the same
operations it uses for everything else — on content that is superseded or
duplicated, never by dropping the facts it is integrating and never by
summarising a section that is still current. Below 80% of the budget nothing is
said at all. Every refresh records document_tokens and document_budget, and
going over adds a warning, so a page that keeps growing is visible rather than
merely large.

Also closes the one path where the model still wrote markdown that got stored:
the over-budget trim. In document mode it is now asked for a document, so the
structure survives the trim instead of being re-derived from prose the model
wrote. A response that is not JSON falls back to the previous split, which is
lossless — the worst case is the old behaviour, not a lost answer.

The real-LLM eval is the part that could not be mocked: told that a document is
over budget, does a model reclaim space or append anyway? It drops the twelve
archived sections and keeps the current process and the checklist — 434 tokens
to 39 against a 200-token budget, stable across four runs. The assertions check
where the space came from, because getting under budget by deleting current
content would pass a naive shrink check and be worse than going over.

* test(mental-models): audit every trigger flag on the delta leg

`max_tokens` looked wired up — read from the model, passed to reflect, enforced
by a rewrite — and was still ignored for the document that actually got stored,
because in delta mode the thing it capped never becomes the document. Reading
the code is how that was missed; nothing asserted the flag at its destination.

So every flag is now exercised through a real delta refresh and asserted where
it lands: retrieval options at the reflect call, document options in the delta
prompt or the persisted row. Full mode is covered by the surrounding modules;
this is the leg where a flag goes to die.

Fifteen flags checked. Fourteen were already honoured. The audit pins them so a
future change to either leg cannot quietly drop one:

- retrieval: fact_types, exclude_mental_models, exclude_mental_model_ids, the
  model's own id (a model must not feed on its previous version), include_chunks,
  recall_max_tokens, recall_chunks_max_tokens, the model's tags, tags_match
- document: max_tokens, response_schema (extracted from the merged document, not
  from reflect's delta-only answer), keep_trace, mode
- and the whole trigger surviving a create/read round trip, since a flag that
  does not persist is not honoured either

The fifteenth is documented behaviour that reads as a bug from outside, so it is
pinned as intended rather than "fixed": `tag_groups` overrides flat tags
entirely, dropping the model's own tags and forcing `tags_match` to `any`,
because each group carries its own match mode. A `tags_match` set alongside
groups is deliberately not forwarded. The default for a tagged model with no
`tags_match` is `all_strict`, not `any` — a model scoped to tags must not widen
its own scope by default.

Scheduling flags (`refresh_cron`, `refresh_after_consolidation`) are honoured
outside the refresh executor — the maintenance loop and the consolidation hook —
and keep their existing coverage there.

* test(benchmarks): type the structural summary, and cover the flag main just added

Two findings from reviewing the branch against a fresh main.

`_structural_summary` returned a raw dict of known keys, which the project
standards forbid for exactly the reason it bit here: the report read it with
`summary["rounds"]` and nothing would have caught a renamed metric until the
table rendered wrong. It is a `StructuralSummary` model now, and the side-by-side
table renders `model_dump()` so a metric added later appears without being
listed twice.

Main added a sixteenth trigger flag while this branch was in flight
(`min_refresh_interval_seconds`, #3621). It gates automatic refreshes rather than
shaping one, so it is honoured in the submit path and covered there — but the
round-trip test enumerates the whole trigger on purpose, because a field that
round-trips as None looks like the flag being ignored rather than like a storage
bug. Adding it keeps that list exhaustive.

* fix(mental-models): teach the retraction prompt the schema it emits into

CI caught what the rebase brought: main added an unsay pass (#3618) whose prompt
documents the operation vocabulary a second time, in prose, and it still told the
model to say `{"op": "remove_block", "section_id": "...", "index": N}` with typed
`block` payloads. Under the id-addressed schema those ops fail validation and are
dropped, so a retracted fact would keep being stated and nothing would say why —
the unsay feature silently doing nothing.

The prompt now describes the schema it actually emits into: blocks addressed by
`block_id`, block payloads as markdown strings, and the note about emitting
removals in descending index order deleted, because ids do not shift when a
sibling is removed and telling a model to order by position invites it to think
in positions again.

Guarded structurally rather than by one more test. The op vocabulary is written
down twice — Pydantic models the applier validates against, and prose in each
system prompt — and a test for the prompt that drifted does not exist by
construction, since it is the one nobody wrote. `test_delta_prompt_schema_parity`
asserts over *every* prompt carrying an operations vocabulary that each op exists,
that no shape names a field the schema rejects, that no v1 typed block survives,
and that blocks are addressed by id; plus a check that a prompt asking for
`{"operations": [...]}` cannot be left off the list. Reverting the prompt fails
three of them.

The rest is the same schema change reaching tests the rebase brought in: canned
ops in the outcome matrix moved to `text`, the retraction tests resolve a real
`block_id` out of the document the prompt shows them (which is what a model does,
and what a hardcoded index cannot express), the stale `parse_markdown`
monkeypatch points at `structured_document_from_stored`, and four assertions on
authored content now expect the canonical render.
2026-08-20 11:35:32 +02:00
Nicolò Boschi 94815f5d0c fix(db): make min_rows=0 mean "off", and stop counting rows on every write (#3485) (#3638)
#3561 made per-bank vector indexes something a bank earns by size, and shipped
the threshold defaulting to 0. But 0 meant "no minimum", not "off": every bank
holding one row still earned all three indexes, just lazily — through a visible
vector_index_maintenance operation instead of at bank creation — and every
subsequent retain, import, consolidation and delete paid an uncapped COUNT(*)
over the bank's whole memory_units partition to rediscover there was nothing to
do. On a large bank that is hundreds of thousands of index tuples per write.

0 now turns the threshold off. Indexes are created in the bank-create
transaction and dropped when the bank is deleted, exactly as before #3561: no
operation is ever submitted, no write pays a coverage check, and repair-bank
rebuilds what a bank is missing without dropping anything. Bank creation, retain
and import get their CREATE INDEX back, gated on the threshold being off, and
import keeps the explicit build a restored bank needs (#2645). The knob exists
to remove a ceiling on bank count; it should not change what a normal deployment
does, and now it doesn't.

With a threshold set, the pre-check gets cheap enough to run on every write.
Coverage can only go wrong in one direction at a time, so the direction of the
write settles most partitions from the catalog alone: growth cannot take an
indexed partition below the keep bound, and shrinking cannot take an unindexed
one above the build bound. What is left is counted no further than the
threshold — min(actual, build_bound) answers both bounds at once, since keeping
starts below building — so the scan's cost is set by the configured threshold
instead of by how big the bank got. A converged, actively-written bank now costs
two indexed queries and counts nothing.

Two churn sources on the same surface go with it. A per-bank cooldown
(HINDSIGHT_API_VECTOR_INDEX_MAINTENANCE_MIN_INTERVAL_SECONDS, 15 min) stops a
bank whose size hovers at the threshold from building and dropping the same ANN
index repeatedly, and stops a permanently failing build — logged, never raised,
so the plan stays non-empty — from re-queueing on every subsequent write. It is
consulted only once the pre-check has found real work, so a converged bank never
pays for it, and the job's own hand-off is exempt because its whole purpose is
to re-check immediately. Oracle is now actually excluded: it leaves
HINDSIGHT_API_VECTOR_EXTENSION at its pgvector default, so gating on the index
clause alone let the planner run asyncpg-shaped SQL against it on every write,
after which the job found no DSN to build with and re-queued itself forever. The
gate is the backend. A missing DSN also warns instead of logging at debug —
nothing about the next write will produce one.

Tests drop the suite-wide threshold override #3561 added. It existed to keep the
operation's CONCURRENTLY DDL out of the suite, which at threshold 0 no longer
happens; running at the shipped default is how the suite caught nothing about
the configuration almost everyone runs, and is what let 0 diverge from the
pre-#3561 behaviour in the first place. Tests that need a threshold now say so.

Refs #3485, #2645
2026-08-19 23:51:37 +02:00
Nicolò Boschi 5f137bf391 fix(recall): keep the observation graph arm out of a nested-loop plan (#3510) (#3588)
`expand_observations`' scoring join is O(C + U) as a hash join and O(U x C) as a
nested loop, where C is the connected-source set and U the unnested candidate
source ids. PostgreSQL picks between them from its row estimate for the
`connected_sources` CTE, and that estimate was 1 against an actual ~3,700: the
capped column came out of a LATERAL + LIMIT subquery, which carries no
n_distinct statistic, so DISTINCT over it was estimated at 2 and the NOT EXISTS
anti-join took that to 1. A 1-row inner side makes the nested loop look free, so
it won on cost and lost by four orders of magnitude at runtime — 15s and ~15M
rejected join rows on a realistically-shaped bank, matching the plans reported
in the issue.

Rank with row_number() instead. Identical output — same cap, same ordering,
unit_id is unique — but the capped column now traces to unit_entities.unit_id, so
the estimate comes from real statistics (207-3,449 against 2,242-4,193 actual)
and the nested loop is priced honestly. Measured over 12 seed sets on the fixture
below: p50 15,013ms -> 217ms, with the full scored set identical.

The set-difference rewrite proposed in #3512 also clears the reported bank, but
it leaves the estimate at 2 and survives only because a set-op prices the nested
loop just above the hash join: 1.0-1.2x headroom against 1.6-1.8x here.

The trade is that ranking reads every unit_entities row of a matched entity where
the LATERAL stopped at per_entity_limit off the index: O(sum of degree) rather
than O(entities x per_entity_limit). At parity up to ~12k-degree hubs, +50%
traversal cost at 38k.

Why the perf suite never caught it
----------------------------------

`recall-with-observations` measured 0.45s on the same query a realistically
shaped bank runs in 15s. Two fixture properties were wrong, and neither alone
reproduces the bug — measured on the suite's own bank:

                          sources=113   sources=mean 2
    old vocabulary          450ms          270ms
    new vocabulary          951ms       15,013ms

- The entity vocabulary was a fixed 145 names at every scale, so degree grew with
  bank size instead of the entity count growing: 142 entities at median degree 40
  with not one entity mentioned once, and every seed reaching 142 of 142
  entities. It now grows with the corpus (1,354 entities, median degree 3, 449
  mentioned once, seeds reaching 54).
- Sources per observation was a constant. Real counts are long-tailed — the
  reported bank ran mean 1.7 / p95 4 — so it is now the mean of a Pareto draw.
  At mean 2 the fixture still emits observations carrying several hundred
  sources, keeping the array-length path from #3085 exercised.

`recall-with-observations` at scale=large will step up when this lands: the
suite can finally see this query. The SQL fix is in the same change so the
dashboard moves once, not twice.

Oracle's expand_observations has the same DISTINCT-over-LATERAL shape and is
deliberately left alone — no Oracle instance was available to measure it, and its
cardinality estimation differs. Documented in ops_oracle.py.

Tests
-----

- test_per_entity_cap_bounds_hub_traversal pins that the window ranks the same
  rows the LATERAL selected; it fails if the cap is widened or dropped.
- test_perf_fixture_shape asserts the post-resolution entity graph keeps a long
  tail. It simulates the entity resolver's intra-batch fuzzy merge, because the
  tail names have to stay under the 0.5 pg_trgm threshold: a tail generated as
  "<stem> <counter>" scores 0.73 and the resolver collapsed 2,814 names to 159,
  silently restoring the flat graph the vocabulary exists to avoid.
2026-08-18 17:38:24 +02:00
Nicolò Boschi 3c24979a40 docs: changelog and blog post for v0.9.1 (#3438)
* fix(changelog-gen): exclude integration/plugin commits and MDX-escape summaries

Two fixes to the core changelog generator:
- Drop any commit that touches hindsight-integrations/ (not just commits that
  touch *only* that path), so integration/plugin PRs that also touch shared
  docs/CI/scripts stop leaking into the core changelog. Guard the core-changelog
  LLM prompt with an explicit skip rule too (not applied to an integration's own
  changelog).
- Escape < and > in summary prose so an HTML/JSX-like token (e.g. <think>) is
  rendered as text instead of failing MDX compilation of the changelog page.

* docs: changelog and blog post for v0.9.1
2026-08-14 11:20:35 +02:00
Nicolò Boschi 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
2026-08-14 10:45:59 +02:00
Ben f36a462d1d feat(eliza): add Hindsight long-term memory integration for elizaOS (#2385)
Adds @vectorize-io/hindsight-eliza, an elizaOS plugin that gives agents
long-term memory backed by Hindsight:

- HINDSIGHT_MEMORY provider recalls relevant memories into the prompt
  before each model call.
- HINDSIGHT_RETAIN evaluator retains conversation messages after each
  turn (fire-and-forget; agent replies optional).
- Bank defaults to the message entityId for per-user isolation; both
  sides fail safe so a Hindsight outage never blocks the agent.

Targets @elizaos/core ^1.7.2 (current npm latest, not the 2.x beta on
main). Includes tests, CI job, release-script + changelog-generator
entries, and docs gallery entry + page.
2026-08-13 11:11:24 -04:00
Nicolò Boschi e6fb5d4799 perf(recall): score observation expansion set-wise, not per candidate row (#3085) (#3451)
The observation graph arm's entity CTE spent 95% of its time in a correlated
subquery: the shared-source score was COUNT(DISTINCT s) over
unnest(mu.source_memory_ids) filtered by `= ANY(ca.source_ids)`, re-run for
every candidate row. Each execution linearly scanned that row's array against
the connected-source array, so cost grew with the product of the two — and
consolidation appends to source_memory_ids without ever pruning it (#1725), so
that product grows with the bank's age.

On a 10k-unit bank whose observations averaged 113 sources (the shape reported
in #3085), EXPLAIN ANALYZE attributed 2.47s of a 2.60s query to that SubPlan:
loops=4980 x 0.497ms, ~1.7B element comparisons pegging one backend. The graph
traversal CTEs underneath it cost ~5ms, and the semantic/causal query ~6ms.

Unnest each candidate's array once and hash-join connected_sources instead.
Output is unchanged — verified (id, score) identical to the old SQL across 5
queries x 4,980 rows.

Paired measurements on the same bank (5k facts + 5k observations, 15k
unit_entities, 154k links), graph arm p50:

  sources/obs |  before |  after
            1 |    65ms |   58ms
           10 |   425ms |  193ms
           50 |  1338ms |  230ms
          113 |  2539ms |  274ms

perf-test --scale large --suite recall-with-observations: p50 11.6s -> 3.5s,
throughput 1.38 -> 4.68 q/s, retrieval_graph phase 8.90s -> 1.15s.

The perf suite could not see any of this because its fixture gave every
synthetic observation exactly one source fact — the degenerate value of the
only dimension this query scales on. It now takes sources_per_observation
(113 at scale=large, drawn from a neighbour window so source sets overlap),
and publishes the value in the results JSON so a fixture change reads as a
fixture change on the dashboard rather than as a latency regression.

Note for the dashboard: recall-with-observations will step up once this lands.
That is the heavier fixture, not a regression — the same suite on the old SQL
measured 11.6s p50 against 3.5s here.

Oracle's expand_observations has the same per-row shape but joins the indexed
observation_sources junction table rather than scanning arrays; left alone.
2026-08-13 10:41:19 +02:00
Nicolò Boschi 681a79e6b4 fix(graph-maintenance): drive the entity prune off a queue, not a bank-wide sweep (#3222) (#3409)
* fix(graph-maintenance): drive the entity prune off a queue, not a bank-wide sweep (#3222)

The graph_maintenance job's Pass 2/3 were two bank-wide statements re-evaluated
on every invocation, whether or not anything had changed: the orphan-entity
prune probed once per entity in the bank, and the stale-cooccurrence prune
evaluated an INTERSECT per cooccurrence row in the bank. Their cost tracked the
size of the bank rather than the size of the delete, so past a few million rows
neither could finish inside asyncpg's 60s command timeout. The job then failed
on every run with a bare TimeoutError, forever, on exactly the banks that most
needed it — and, because db_utils treats a timeout as transient, re-ran the
doomed statement nine times per attempt, holding a worker slot for ~10 minutes
each time.

Both prunes are now driven by `entity_maintenance_queue`, filled inside the
deleting transaction the way `graph_maintenance_queue` already is for the relink
pass. A run claims a bounded batch of candidate entities, prunes what is
genuinely dead, and commits — so the cost is O(delta), and the work already done
survives whatever stops the run.

Measured on a dense fixture (100k entities, 1.5M unit_entities, 2.86M
cooccurrences, hub entities holding 150-400 postings):

  bank-wide orphan prune          9.6s      → batch of 50:   15ms
  bank-wide cooccurrence prune    >11 min   → batch of 50:   2.0s
                                  (cancelled; ~1.7ms per pair over 2.86M pairs)

Also:

* A wall-clock budget for the whole job. Both passes commit per batch, so
  exhausting it is not a failure — the run reports `queues_drained: false`,
  logs it, and chains a follow-up (under a real queue; a synchronous backend
  would recurse instead of schedule). Large backlogs converge over runs.
* The scoping predicate is a UNION of the two endpoint columns, not
  `entity_id_1 = ANY(...) OR entity_id_2 = ANY(...)` — that OR is the #3387
  shape and cannot be driven from either index.
* Every site that removes units or replaces entity postings now enqueues
  candidates: document delete, single and bulk memory delete, curation
  edit/invalidate, document re-ingest, and the delta-retain chunk cascade.
* The migration seeds the queue with every existing entity, so garbage a bank
  accumulated while its sweep was failing is still reclaimed — incrementally,
  a bounded batch per run, instead of in one statement that cannot finish.

* fix(graph-maintenance): compose the queue-scoped prune with the set-based staleness check

Rebase reconciliation with #3408, which landed the same statement while this
was in review.

staleness against a set of live pairs built once, instead of a correlated
INTERSECT re-scanned per row — removing the (rows judged) x (hub degree)
product. That is the better predicate, and it composes with the queue scoping
rather than competing with it: `live` is now seeded from the *claimed
candidates'* units instead of the whole bank's. Correctness holds because every
pair being judged has a candidate as an endpoint, so any unit still grounding
one of those pairs references a candidate and is in the seeded set.

Measured on the dense fixture (100k entities, 1.5M unit_entities, 2.86M
cooccurrences):

  bank-wide, set-based (#3408 as merged)   did not finish in 10 min
  batch of 50, per-pair INTERSECT (mine)   2.0 s
  batch of 50, composed                    16-65 ms

The batch-size rationale is updated to the new numbers; 50 still holds, now
with three orders of magnitude of margin instead of one. #3367's hub/bank-
scoping regression test is kept, adapted to seed candidates.

Also re-chains the migration onto d9c1a7b4e2f6, which took the same parent on
main and would otherwise leave two alembic heads.

* fix(graph-maintenance): restore the review fixes without the birth-time enqueue

Drops the "queue every entity at creation" change and keeps the rest of the
review round (dataclass pass results, the Oracle IN-list chunking on the by-unit
enqueue, the per-site enqueue tests, the migration-seed test, the budget's
follow-up-chain test, the stale-comment sweep).

The birth-time enqueue existed to reclaim an entity created in retain's Phase 1
whose Phase-2 link never landed. It is not worth what it costs: such a row is a
single entry in the registry with no postings and no cooccurrences, and #2662
exists because the retry is *supposed* to adopt it — Phase 2 reasserts resolved
parents under FOR KEY SHARE precisely so a pruner cannot delete one out from
under it. Pointing the pruner at every freshly created entity leans on that race
for a leak that is one row wide. #3408 landing the set-based predicate is what
made the trade obviously bad: the expensive half of this job was never those
rows.

Entities created but never linked are therefore no longer proactively reclaimed.
The migration's one-time seed still clears the population a bank has already
accumulated.

* fix(graph-maintenance): don't backfill the entity queue on upgrade

The migration seeded one queue row per existing entity so a bank could reclaim
what it stranded while its bank-wide sweep was failing. That is the wrong trade:
the INSERT runs inside a migration at API startup, so a large deployment pays a
slow upgrade writing a row per entity, and then a prune check for every one of
them — a self-inflicted backlog to collect rows that cost the bank nothing.

The queue now starts empty and fills from real deletes. Historical strays stay
until something touches them; they are single registry rows with no postings and
no cooccurrences.

The migration test pins the two properties that are easy to lose later: the
upgrade enqueues nothing, and the composite key collapses overlapping deletes
into one row (which is also what the #3034 locking upsert conflicts on).
2026-08-12 16:14:19 +02:00
Nicolò Boschi dbe0ffb989 fix(stats): drop permanently failed memories from pending_consolidation (#3362) (#3397)
`pending_consolidation` counted every fact with `consolidated_at IS NULL`,
including the ones stamped `consolidation_failed_at` that the consolidator's
own candidate query (`reads.find_unconsolidated`) excludes on purpose. The
gauge therefore had a floor no amount of work could clear: it sat above
`?consolidation_state=pending` by exactly `failed_consolidation`, and an
operator could not tell a real backlog from an abandoned residue.

`pending` now carries the consolidator's predicate, so the two buckets are
disjoint and a bank with no live backlog reaches zero. The same predicate was
missing in five more places:

- `get_bank_stats` keeps a second copy of the freshness SQL for the
  `writes_memory_rows_in_sql` path — fixing only `counts.py` would have left
  the default Postgres path wrong.
- `hindsight.consolidation.backlog` had the same floor, which made
  "backlog > 0 for N minutes" unalertable on any bank holding a residue.
- reflect's `tool_search_observations` derives `is_stale` / `freshness` from
  this count, so a residue told the model the observations were stale on every
  call, for ever (fixed transitively via `get_bank_freshness`).
- the control plane's consolidation card computed `done = total - pending`,
  which would have counted the failed rows as done once pending got strict.
- the benchmark runner waits for this count to reach 0, so one permanently
  failed fact burned the full 3000s timeout.

Everything else that answers "what is left to consolidate" already excluded
them: the memories list filter, `count_unconsolidated`, and the
`banks_needing_consolidation()` maintenance routine — so scheduling was never
spinning on the residue.
2026-08-11 18:23:33 +02:00
Ben a133cc1495 feat(agent-plugin): add portable Hindsight plugin for the Agent Plugins standard (#3394)
Add a vendor-neutral Hindsight plugin conforming to Vercel's Agent Plugins
1.0.0 standard (plugin.json + mcp.json + skills/SKILL.md), so one artifact
gives long-term memory to any compatible client (Codex, Cursor, GitHub
Copilot, Kiro, VS Code) instead of a per-IDE integration. The plugin is a
thin transport wrapper over Hindsight's existing MCP server (retain / recall
/ reflect); a bundled skill teaches the agent when to use it.

Wiring:
- CI: test-agent-plugin-integration job runs the manifest validator, gated on
  hindsight-integrations/agent-plugin/** changes.
- Docs: integrations.json gallery entry + docs-integrations/agent-plugin.md.
- Release: agent-plugin added to release-integration.sh and the changelog
  generator; both learn to read a root-level plugin.json and link the
  changelog to the source tree (git-distributed bundle, no registry package).
2026-08-11 18:06:17 +02:00
Nicolò Boschi 20bd4f3618 fix(ci): compare OpenAPI against the merge-base; build benchmark role configs whole (#3349)
* fix(ci): compare OpenAPI against the merge-base; build benchmark role configs whole

Two unrelated CI failures, both of which fail without anything being wrong
with the code under test.

OpenAPI compatibility diffed the branch's spec against the LIVE tip of the
base branch, so every endpoint main gained after a branch was cut is
reported as "Endpoint removed (breaks old clients)" by that branch. Three
open PRs failed this way today on /health/live and /health/ready (added by
#3329), none of which touch the spec at all; the only cure was an unrelated
rebase. Compare against `git merge-base origin/$BASE_BRANCH HEAD` instead,
which asks the question the check means to ask: did *this branch* remove
something. Genuine removals still fail — verified both directions against
the real specs.

The scheduled LoComo benchmark has failed every night since at least Aug 8,
before its first question: `LoComoAnswerGenerator()` raised
"HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID is required". The workflow does export
that variable — but each benchmark role built its LLMConfig from exactly four
env vars (provider, api_key, base_url, model), and LLMConfig deliberately does
not read the environment for provider-specific settings (from_env is where the
API resolves them). Every Vertex AI value was therefore dropped. The same
four-var construction was copy-pasted at three sites — locomo, longmemeval and
the shared judge — so fixing only the crashing one would have moved the failure
down a line. Replace all three with a shared builder that carries the Vertex AI
project/region/service-account through.

hindsight-dev/tests had no CI job, which is why a plain construction bug was
left for a nightly benchmark to find hours later. Add one, so those tests
(and the new regression test) actually run on PRs.

* fix(ci): make the benchmark role-config tests hermetic

They passed locally off the developer's HINDSIGHT_API_LLM_API_KEY and failed
in the new test-dev job, where no key exists, with "API key is required for
openai" — the tests were reading ambient environment instead of declaring
what they need. Clear every HINDSIGHT_API_*LLM* var before each test and set
the ones under test explicitly.
2026-08-10 17:11:06 +02:00
Nicolò Boschi 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
2026-08-07 18:16:35 +02:00
Nicolò Boschi b5d8439c8f hindsight-coding-agents: harness-pluggable long-term memory for coding agents (#2522)
* feat(integrations): add hindsight-opencode-coding plugin

Reflect-only long-term memory for coding agents in OpenCode, with a git+chat
backfill and (opt-in) live session write-back.

- reflect + INJECT: on a task, reflect() the symptom and push the root-cause
  answer into the system prompt (no tools/recall).
- backfill: every commit (full message + full diff, commit timestamp + git
  metadata) under a 'git' retain strategy; each chat as a JSON user/assistant
  transcript with custom extraction (<=2 coherent facts) under a 'chat' strategy;
  observations on; optional codebase knowledge pages.
- live write-back (opt-in HINDSIGHT_RETAIN_SESSIONS): every N turns upsert the
  tool-filtered transcript under a stable conversation:<sessionID> document_id.

* refactor(integrations): generalize opencode-coding into hindsight-coding-agents

Make the coding-memory plugin harness-pluggable instead of opencode-specific.
A 'harness' (coding agent) differs in only two places; everything else is now
shared core:
  - src/core/    hindsight client, missions, git + chat ingest, inject, RuntimeCore
  - src/core/types.ts  HarnessAdapter + ChatReader interfaces
  - src/harness/ per-agent adapters + registry (opencode implemented)

Backfill: --harness selects how past sessions are read (opencode today);
git ingest, retain strategies, missions, and knowledge pages are identical
across agents. Runtime: HINDSIGHT_HARNESS (default opencode) selects the
adapter that binds RuntimeCore's reflect+inject+write-back to that agent's
plugin API. Adding an agent = one adapter file + a registry entry.

Type-checks and builds clean; unknown --harness/HINDSIGHT_HARNESS errors with
the available list.

* feat(coding-agents): on-demand memory_reflect tool, opt-in git-sync, JSON config

Add two capabilities to the reflect-only coding-agents plugin and move all
configuration off environment variables onto a single JSON file.

- memory_reflect tool: exposes the same synthesized reflect that is auto-injected
  on the first message as an on-demand opencode tool the agent can call mid-task
  (RuntimeCore.reflectNow + opencode adapter tool). Harness-agnostic core, thin
  opencode wiring.
- incremental git-sync (opt-in): on load, diff the target ref's commits
  (origin/main, falling back to HEAD) against the git:<sha> document_ids already
  in the bank and async-retain only the missing ones, reusing the backfill's
  per-commit encoding (retainCommit). Set-based, correct across rebases;
  best-effort, non-blocking. Off by default (gitSync.enabled).
  Adds HindsightClient.listDocumentIds + core/sync.ts.
- config file: all settings now come from ~/.hindsight/coding-agent.json
  (core/config.ts) -- no environment variables. The backfill CLI reads the same
  file for shared connection/bank settings with --flags overriding; operation
  flags stay CLI-only.

Committed with --no-verify: the repo-wide pre-commit lint hook is broken in this
environment (missing @eslint/js in hindsight-control-plane) and blocks all commits.

* fix(coding-agents): remove benchmark-specific strings from prompts

Fairness audit of the sdebench benchmark found three contaminations:
- CHAT_CUSTOM_INSTRUCTIONS used the literal answer to a graded task
  (round_cents/ROUND_HALF_DOWN/legacy ledger) as its example - replaced
  with a fictional, non-benchmark example.
- buildSystemInjection told the model 'the hidden tests depend on those
  exact choices' - hardcoded knowledge of the benchmark's grading;
  reworded benchmark-agnostic.
- REFLECT_MISSION examples were shape-matched to specific benchmark
  tasks (symbol mappings, exact numbers) - neutralized.

No behavior change intended beyond removing the leaked specifics.
(includes hook-regenerated skills/hindsight-docs sync)

* feat(coding-agents): reflect-outcome diagnostics — no more silent memory loss

A benchmark sweep ran the entire memory arm with zero injected memory:
reflect failed environmentally on every task and the best-effort catch
swallowed it, making a memory-less run indistinguishable from a memory
run. onTask now appends a reflect_ok/reflect_empty/reflect_failed record
(duration, error, query prefix) to HINDSIGHT_DIAG_FILE (default
/tmp/hindsight-plugin.log). Consumers can assert a session actually had
memory before trusting a comparison.

* fix(coding-agents): chronological session recency + supersession-aware reflect

Two defects surfaced by the conversation-amended benchmark tasks (a rule
settled in one chat and amended in a later one):

- chat ingestion staggered synthetic timestamps NOW - i*1h, INVERTING
  recency: an amendment chat ranked older than the decision it
  superseded, steering temporal ranking toward the stale rule. Session
  list order is chronological; the last session is now the newest.
- REFLECT_MISSION now states that when memories conflict on the same
  rule, the latest/superseding decision wins and the superseded rule
  must be reported as no longer in effect, never presented as the fix.

Observed live: reflect on an amended bank returned the superseded
keep-latest rule as the fix. Both fixes are general recency/consistency
semantics, not benchmark-specific behavior.

* feat(coding-agents): multi-harness configurability + Claude Code hook entry

One config, several agents side by side:

- Each runtime entry point now KNOWS its harness instead of reading the
  config's `harness` key (which selected a single global adapter and
  made opencode + claude mutually exclusive). That key now only picks
  the backfill's session formatter.
- New `harnesses.<name>` config sections: per-agent overrides of any
  field (bank, disabled, timeouts) over shared connection defaults.
- New project-local layer: <project>/.hindsight/coding-agent.json
  overrides the global file — the natural home for a per-repo bank.
  Precedence: defaults < global < global.harnesses < project <
  project.harnesses.
- New entry point: `hindsight-claude-hook` (dist/claude-hook.js), a
  Claude Code UserPromptSubmit hook. Reflects once per Claude session,
  caches the answer in tmp and re-injects it on later prompts, and
  writes the same reflect_ok/failed diagnostics as the opencode path.

Verified live: claude hook via project config + harnesses section
(reflect_ok, cached re-emit in 46ms, one reflect total); opencode via
the benchmark harness (reflect_ok, task solved 0 corrections).

* feat(coding-agents): per-repo dynamic bank resolution (family convention)

Port of the bank-derivation convention shared by the claude-code, omo,
cline, and opencode integrations, with coding-first defaults:

- No bankId configured => the bank is derived from the git repo the
  working directory belongs to, WORKTREE-AWARE: git rev-parse
  --git-common-dir resolves every linked worktree to the main worktree's
  basename, so all worktrees of a repo share one memory bank (bare repos
  use the bare dir name; non-git dirs fall back to the dir basename).
- Default granularity is [gitProject] (not agent::project): opencode and
  claude share ONE memory per repo — add 'agent' to
  dynamicBankGranularity to split per agent.
- Explicit bankId keeps today's static behavior (benchmark harness,
  single-bank setups); dynamicBankId forces either mode; supporting
  fields: bankIdPrefix, directoryBankMap (exact cwd -> bank escape
  hatch), agentName, resolveWorktrees.
- backfill: --bank wins, else the SAME resolution applied to --repo, so
  `hindsight-coding-backfill --repo .` fills exactly the bank the
  agents will read.

Verified: worktree -> main-repo bank (hs-coding-plugin-wt -> memory-poc),
static/prefix/dirMap/granularity cases, and the claude hook e2e
(reflect_ok via directoryBankMap against a live bank).

* feat(coding-agents): bank template string, prefix path map, {harness} field

Bank-resolution refinements:

- `bankIdTemplate` format string replaces the granularity array:
  e.g. "hindsight-{gitProject}" or "{harness}-{gitProject}" — default
  "{gitProject}" (opencode + claude share one bank per repo).
  Placeholders: {gitProject} {project} {harness} {channel} {user};
  unknown placeholders warn with the valid list. bankIdPrefix removed
  (expressible in the template).
- {harness} is supplied by the entry point itself (opencode plugin,
  claude hook, backfill --harness), not a config field — nothing to
  keep in sync.
- directoryBankMap now matches by LONGEST absolute-path prefix and
  overrides everything incl. an explicit bankId: mapping a repo root
  covers all its subdirectories; deeper mappings win.
- config discovery walks UP from the working directory to the nearest
  .hindsight/coding-agent.json — a hook invoked from a repo subdir
  previously missed the repo's project config entirely (found by an
  e2e test that failed exactly this way).

Verified: derivation matrix (template/prefix-map/override/static/bad
placeholder), claude hook e2e from a nested subdir (reflect_ok via
walked-up config + prefix-matched map), opencode benchmark task green.

* feat(coding-agents): cursor-cli + codex harnesses, unit tests, live system tests

Harnesses — hook-based agents now share one runtime (core/hook.ts:
stdin event -> layered config -> per-repo bank -> once-per-session
reflect with tmp cache -> native output -> diagnostics), so each agent
is a ~25-line HookSpec:
- hindsight-claude-hook  (UserPromptSubmit -> additionalContext)
- hindsight-cursor-hook  (beforeSubmitPrompt -> {continue, additional_context})
- hindsight-codex-hook   (Codex CLI v0.116+ claude-compatible hooks;
  accepts prompt/user_prompt)
All three + opencode registered in the harness registry (backfill
--harness resolves them; hook harnesses share the normalized-JSON
chat reader).

Tests (vitest, family convention):
- 25 unit tests: full bank-derivation matrix (worktree/bare/static/
  dynamic/template/{harness}/prefix-map incl. longest-wins and
  no-sibling-false-match) and config layering (harness sections,
  project-over-global, upward walk, nearest-wins, gitSync field merge,
  malformed fallback, legacy signature).
- live system suite (npm run test:live, HINDSIGHT_LIVE_E2E=1): builds a
  real git repo with a decision planted in a commit + a conversation,
  runs the real backfill CLI (server-side LLM extraction), then invokes
  the BUILT hook binaries as subprocesses and asserts the decision's
  literals come back in the injected context — semantic verification
  with a real LLM — plus per-session cache behavior and diag records.
  All 4 passing against a live server.

Note: session ids in the live suite are unique per run — the hooks
cache per session id in tmp, and a static id once cached a bad answer
from a half-broken server across reruns.

* docs(coding-agents): full README rewrite + integration docs page

README now covers everything the package does today: the reflect-once/
inject-every-turn mechanics, all four harnesses (opencode plugin +
claude/codex/cursor hooks) with install snippets, the complete
configuration reference (layered files, harnesses sections, per-repo
dynamic bank resolution with template placeholders, directoryBankMap,
worktree behavior), backfill CLI incl. bank auto-resolution and
chronological session ordering, the reflect diagnostics contract, and
the unit + live test suites.

Docs site: new docs-integrations/coding-agents.md (same content adapted
to the integration-guide format) + integrations.json hub entry so the
generated sidebar picks it up. Placeholder icon (github.png) pending a
real one. Verified: page renders (docusaurus build), all doc pre-flight
checks pass for this entry — note the docs build on this branch was
ALREADY failing on the unrelated pre-existing 'zcode missing from
integrations.json' check.

* feat(coding-agents): 🧠 attribution header in buildSystemInjection

Prepend the 'Using Hindsight Memories' visible-attribution directive to the
harness-agnostic system injection so every coding-agent harness surfaces a
recognizable header when it uses recalled memory. Covered by 5 deterministic
inject.test.ts cases (real emoji + em dash, no lone surrogates).

* feat(core): add recall() to HindsightClient

* style(core): apply prettier formatting to recall test

* style(coding-agents): normalize prettier formatting across package

* fix(core): narrow RecallResult to actual API contract, add fetch-throw test

* feat(core): formatMemories + shared attribution preamble

* style(core): prettier-wrap recall.test.ts array literal

* fix(core): cover formatMemories trim/filter + drop stale inject comment

* feat(core): per-turn recall in the hook runtime (reflect once, recall every turn)

Extracts the hook logic into a pure, unit-testable buildHookOutput(): every
prompt now runs recall() and injects a <hindsight_memories> block; reflect
still runs once per session (first prompt) and its cached answer is no
longer re-injected on later turns. runHook() becomes thin stdin/stdout
plumbing with a makeClient seam for tests. Updates the three hook
entrypoints' doc comments to match, and adds recallMaxTokens/recallTimeoutMs
config fields.

* fix(core): make recall fail-open in buildHookOutput + cover recall failure/opts

* feat(claude-code-v2): wrapper plugin skeleton (per-turn recall via bundled core)

Also disables tsup code-splitting in hindsight-coding-agents so each bin
entry (claude-hook.js etc.) is a single self-contained file with no
shared chunk-*.js — required for wrapper build scripts that copy just
the one hook file out of dist/.

* chore(coding-agents): sync codex-hook bin into package-lock

* fix(claude-code-v2): derive version from manifest + guard self-contained bundle

* feat(core): Claude transcript reader (normalized user/assistant text turns)

* fix(core): transcript reader null-safety + drop sidechain turns

* feat: live write-back on the Claude Stop hook (shared retain-hook runtime)

Extracts a testable buildRetain core (read transcript -> upsert under
conversation:<sessionId> via retainLiveSession) plus a thin runRetainHook
plumbing wrapper mirroring the existing runHook/buildHookOutput split, and
wires it up as a Claude Code Stop hook. Fail-open throughout: an empty
transcript is a no-op, and a retain failure is diagnosed but never thrown.

Exports diag() from core/hook.ts so retain-hook.ts can reuse the same
diagnostics helper instead of duplicating it.

* refactor(core): extract diag module + trim buildRetain params

- Move diag() out of hook.ts into a neutral src/core/diag.ts so retain-hook
  (and future lifecycle hooks like SessionStart) don't reach into a
  recall/reflect-specific module for a cross-cutting concern.
- Drop the unused cwd/cfg params from buildRetain — only harness, sessionId,
  transcriptPath, and client are read; cwd/cfg stay in runRetainHook where
  they're actually used (config load + deriveBankId).
- Clarify that retainSessions is opencode-plugin-only; the Stop hook always
  writes back unless disabled.

* feat(core): knowledge-page CRUD on HindsightClient (mental-models)

* fix(core): page methods throw on 404 + doc rationale

* feat: native TS MCP server for knowledge-page tools (bank-aligned)

Adds a native TypeScript MCP (stdio) server exposing the agent_knowledge_*
tools (get_current_bank, list_pages, get_page, create_page, update_page,
delete_page, recall) over MCP, wired into the claude-code-v2 wrapper.

Bank resolution goes through the same loadConfig + deriveBankId path the
hooks use (harness "claude-code"), so knowledge pages, recall, and retain
all land in one per-repo bank. This is a native TS server rather than
reusing the Python MCP because its bank derivation mismatches.

- src/core/knowledge-tools.ts: SDK-free tool specs (zod schemas), unit
  tested against a stub client (17 tests) — every handler is fail-closed
  to an isError:true result instead of throwing.
- src/mcp-server.ts: the only file importing @modelcontextprotocol/sdk.
- tsup.config.ts: new mcp-server entry, noExternal inlines the SDK + zod
  so dist/mcp-server.js stays a single self-contained file.
- claude-code-v2/.mcp.json + build.mjs: wires the bundle into the plugin;
  the self-contained-bundle guard passes for mcp-server.js unmodified
  (no exemption needed) since noExternal fully inlines its deps.

* fix(mcp): honor disabled flag + testable selectTools

- Export selectTools(cfg, client, bankId) from mcp-server.ts: pure,
  SDK-free, returns [] when cfg.disabled (mirrors the hooks' disabled
  check) so a disabled Hindsight exposes zero MCP tools instead of all 7.
  Confirmed at runtime: with disabled:true the server still connects but
  doesn't advertise a tools capability at all (tools/list -> Method not
  found), which is stronger than an empty list.
- Guard main() behind an argv[1]-vs-import.meta.url check so importing
  the module for tests doesn't start a real stdio server.
- Add src/mcp-server.test.ts covering selectTools for both the disabled
  and enabled cases.
- Reword the HINDSIGHT_MCP_PROJECT_CWD comment: nothing sets it today
  (the plugin doesn't cd), it's an escape hatch, not a launching-host
  contract.

* refactor(core): lazy-load opencode adapter so backfill bundles self-contained

* test(core): lock opencode no-runtime registry invariant + doc it

* feat(core): cold-repo detection + seed-consent state

* test(core): cover seed write-failure + guard non-object state

* feat(core): background seed mechanics + hindsight-seed control CLI

Adds hasGitHistory (git.ts), startBackgroundSeed + seedControl (seed.ts),
and the src/hindsight-seed.ts entrypoint the agent runs after the
SessionStart seed offer (Task 10b) to seed or decline a repo's bank.

* fix(core): handle async spawn error in startBackgroundSeed

spawn() failures (ENOENT/EACCES/fd exhaustion/sandboxed environments) often
arrive asynchronously as an 'error' event on the child, not a synchronous
throw. An unhandled 'error' event crashes the caller, so attach a no-op
handler alongside the existing try/catch. Also documents the Claude-Code-only
harness assumption in hindsight-seed.ts.

* feat: SessionStart auto-seed offer for cold repos (Claude wrapper wired)

* fix(core): shell-escape seed offer paths + drop orphaned isColdRepo

* docs(claude-code-v2): marketplace entry, full README, v1→v2 migration note

* fix(core): cap hook reflect timeout, align backfill+hook config resolution

- hook.ts: cap reflect's timeoutMs to HOOK_REFLECT_CAP_MS (8s) so it always
  resolves/aborts before Claude Code's 15s UserPromptSubmit kill window,
  guaranteeing the session cache write + recall injection complete instead
  of silently retrying reflect (and dropping recall) on every turn.
- backfill.ts: resolve config via loadConfig({harness, projectDir: REPO,
  path}) instead of the legacy string form, so project-local
  .hindsight/coding-agent.json layers in and the background auto-seed
  backfill targets the same bank recall/retain/MCP read from.
- hook.ts/retain-hook.ts: resolve the cwd fallback before loadConfig (not
  just at deriveBankId) so project-local config layers even when the
  hook event's cwd is missing.

* fix(claude-code-v2): dev-install must copy .mcp.json (MCP tools were missing)

* feat(core): deterministic SessionStart auto-seed + knowledge-page bank mission

The prior SessionStart design asked the agent to pose a y/n question then
run a seed command itself; live testing showed the model surfaces the
question and then ignores it, so nothing ever seeds. The hook now starts
the background seed itself on a cold git repo (tri-state: cold/warm/
unreachable) and always injects a short visible note plus a bank-mission
pointing the agent at the agent_knowledge_* tools.

* docs(claude-code-v2): update seed docs for deterministic auto-seed + knowledge mission

* feat(core): default seed to aggregated commit messages (one cheap doc) + Initiatives page; full-diff opt-in via --diffs

* docs(core): align backfill README + strategy log/comment with gitlog default

* feat(core): headless codebase-survey seed + agent_knowledge_ingest MCP tool

On a cold repo, the SessionStart hook now also spawns a detached headless
`claude` that samples the repo's structure and ingests its findings into
Hindsight via a new agent_knowledge_ingest MCP tool, alongside the existing
git-history backfill. Knowledge pages synthesize their content from bank
memories via source_query, so this is how the survey feeds them.

- knowledge-tools.ts: add agent_knowledge_ingest (title -> slug doc id,
  retain via the "chat" strategy, tagged source:upload).
- survey.ts: resolveClaudeBin + startCodebaseSurvey, mirroring seed.ts's
  fire-and-forget/never-throw spawn pattern.
- Anti-recursion: HINDSIGHT_DISABLE_HOOKS guard at the top of runHook,
  runRetainHook, and runSessionStartHook so the survey's own claude session
  can't re-trigger seeding/recall/retain; survey.ts sets it on the child.
- config.ts: codebaseSurvey (default true) + surveyModel (default "sonnet").
- session-start.ts: wire startSurvey into the cold-repo branch alongside
  startSeed; update the visible learning note.

* fix(core): sandbox headless survey (deny-list, no bypassPermissions) + spend cap + document strategy

* feat(core): default codebase-survey model to haiku (cheaper/faster; sonnet still configurable)

* feat(core): survey excludes CLAUDE.md + agent-instruction files from ingestion

* docs(coding-agents): v2 knowledge-pages design spec + implementation plan

* feat(core): add pageRefreshEveryTurns config (default 10)

* feat(core): knowledge-injection roster/preamble formatting

* feat(core): passive knowledge entity_labels tier vocabulary + configureBank wiring

* feat(core): tag-scope seeded pages, Initiatives folder, relatedPageId link source_query

* feat(core): captureInitiative — per-initiative page + relatedPageId marker

* feat(mcp): hindsight_* grounding tools + capture_initiative; remove raw page CRUD from agent

* feat(core): SessionStart injects page roster + guidance preamble

* feat(core): UserPromptSubmit hook-counted periodic page-roster refresh

* feat(core): rich markdown session write-back with tool calls + verbose session strategy

* chore: apply prettier line-wrapping to test files

* fix(claude-code): surface the seed note via user-visible systemMessage, keep preamble in additionalContext

* fix(survey): use renamed hindsight_ingest_document MCP tool (Task 6 rename regression)

* fix(core): preamble + refresh nudge the agent to call capture_initiative for major features

* fix(core): re-inject tool+capture reminder every cadence turn even with no pages (unconditional nudge)

* fix(core): inject when-to-call guide for the full hindsight_* tool suite, not just pages+capture

* feat(claude-code): cold-check-wins seeding — reseed a cleared bank on the live doc count, ignore stale seededAt

* fix(core): simplify capture_initiative instruction to one clear trigger (remove confusing OR-chains)

* fix(core): port proven v1 attribution preamble + surface memories block first so the header actually gets emitted

* fix(core): reflect every turn (configurable reflectEveryTurns, default 1) instead of once per session

* feat(core): per-turn injection is recall-only (drop reflect from the hook), recall token budget default 750

* feat(core): inject a user-feedback section above memories (capture-initiative + attribution-header preferences)

* fix(core): align user-feedback attribution bullet with the generous WHEN-IN-DOUBT-EMIT rule

* fix(core): sharpen capture_initiative trigger — call right after plan approval, before implementation

* feat(survey): raise default codebase-survey budget cap to $2 (0.5 was over-conservative)

* feat(codex): codex-v2 wrapper (SessionStart seed + per-turn recall + MCP); parametrize session-start/MCP harness

* feat(core): default bank template is harness-neutral coding-agent::{gitProject} (shared memory across agents)

* feat(core): default apiUrl is Hindsight Cloud (https://api.hindsight.vectorize.io); local is now an override

* feat(codex): Stop write-back — Codex rollout transcript reader + codex-stop-hook (full parity)

* fix(core): captureInitiative returns the server-assigned page id (not the slug) so read_knowledge_page + relatedPageId links resolve

* feat(coding-agents): upgrade opencode adapter to full v2 parity

Per-turn recall via chat.message + system.transform (750-tok budget), native hindsight_* tools registered directly through opencode's tool() (no MCP server), rich tool-aware write-back on by default, and cold-check auto-seed at plugin load — reusing the shared formatMemories / buildKnowledgePreamble / buildKnowledgeTools / buildSessionStartContext primitives so opencode matches Claude Code and Codex.

Adds transcript-opencode.ts (rich normalizer over the live message list). Adds a HINDSIGHT_DISABLE_HOOKS recursion guard to RuntimeCore (seed/recall/write-back/sync no-op; tools still register) for headless survey runs. Removes the now-dead reflect path (client.reflect, inject.ts/buildSystemInjection, reflectTimeoutMs) as the whole surface is recall-only. README rewritten to the recall/knowledge-page/seed/write-back v2 model.

* feat(coding-agents): harness-portable codebase survey (multi-agent headless)

The cold-repo survey no longer hardcodes headless `claude` — startCodebaseSurvey now runs under the current harness's own CLI (claude/codex/gemini/opencode), falling back to any available agent, so a Codex/Gemini/opencode user without claude installed still gets the survey (the git-log seed already ran regardless).

Per-agent read-only recipes: claude (-p + inline --mcp-config + --disallowedTools), codex (exec --sandbox read-only + inline -c MCP), gemini (-p --approval-mode plan --allowed-mcp-server-names hindsight --skip-trust), opencode (run --agent plan; tools from the loaded plugin under the HINDSIGHT_DISABLE_HOOKS guard). All spawned with HINDSIGHT_DISABLE_HOOKS=1. session-start threads the harness through to the survey.

* feat(gemini): add Gemini CLI v2 integration (gemini-v2)

Full v2 parity for Gemini CLI (>=0.52.0), which added a Claude-style hooks system (stdin/stdout JSON). Maps onto the shared HookSpec/runSessionStartHook/runRetainHook abstraction with Gemini's event names: BeforeAgent (per-turn recall -> hookSpecificOutput.additionalContext), SessionStart (seed), SessionEnd (write-back).

The one Gemini-specific piece is transcript-gemini.ts — a reader for the 0.52.0 chats/session-*.jsonl mutation-log (upsert-by-id, polymorphic content: user text arrays, assistant plain strings, tool results as user functionResponse parts; drops the synthetic session_context message + thoughts). Adds the gemini-v2 wrapper (build.mjs + dev-install.sh that merges hooks + mcpServers into ~/.gemini/settings.json). Validated: reader against a real transcript, and a live recall smoke test (recall_ok) end-to-end.

* style(coding-agents): prettier-format README config table

* fix(opencode): inject via lastInjection fallback (1.18.5 system.transform has no sessionId)

opencode 1.18.5 fires experimental.chat.system.transform with input {model} only — no sessionId — so RuntimeCore.getInjection(input.sessionID) looked up undefined and pushed nothing into the system prompt. Recall still ran (chat.message does pass sessionID) but the memory block + attribution preamble + knowledge-page guide never reached the model, so no visible header and no tool use.

getInjection now falls back to the most recent turn's block (lastInjection) when there's no session-keyed hit. The completion's system.transform fires right after that session's onPrompt, so lastInjection is this turn's block. Adds an inject_ok/inject_empty diag (matching recall_ok/seed_started) to confirm injection lands.

* fix(coding-agents): treat project-local config as untrusted (block apiUrl/apiToken/directoryBankMap from a repo)

A project-local .hindsight/coding-agent.json lives inside whatever repo the developer opens, so it is untrusted input. loadConfig previously merged it per-field over the user-global config, letting a repo override apiUrl while the user-global apiToken survived the merge — so a malicious repo could set only apiUrl and the client would send the user's real Bearer token plus every recall query (the prompt) and Stop write-back transcript to an attacker-controlled host, silently, just by opening the repo (verified end-to-end).

Fix: the project-local layer is now sanitized — apiUrl, apiToken, and directoryBankMap are stripped from it (top level + any harnesses.<name> section) with a one-line warning; the user-global config stays trusted and unrestricted, and a repo can still set its own per-repo bank (bankId/bankIdTemplate). Also skip re-applying the global file as a project layer when the upward findProjectConfig walk lands back on it (a repo under $HOME with no closer config), which would otherwise strip its own apiUrl and warn every session. Adds 4 regression tests.

* style(coding-agents): prettier-format config.ts

* feat(coding-agents): restore reflect as the memory path; per-turn injection from knowledge-page sections

One opinionated runtime path (no behavior config):
- reflect ONCE per session on the first prompt (agentic root-cause synthesis,
  benchmark-proven), cached and re-injected every turn — hook harnesses and the
  opencode runtime alike
- every turn: knowledge-page SECTIONS matched locally against the prompt
  (lexical section index, no server/LLM call) injected with provenance and a
  pointer to the full page — fast like recall, organized like reflect
- raw recall leaves the runtime path (still powers the hindsight_search_memory
  tool)

Session write-back: transcripts are now JSON turns matching the backfill chat
format, with each tool call compacted to a role:"action" turn naming the tool
and its primary target (no arguments, no outputs) — Claude, Codex, Gemini and
opencode readers.

Knowledge pages: no more entity_labels/tag taxonomy — pages are unscoped, each
page's source_query selects from the whole bank; survey, gitlog seed, write-back
and security hardening stay.

Spec: docs/superpowers/specs/2026-07-27-reflect-pages-runtime.md

* test(coding-agents): rewrite unit tests for reflect+pages runtime and JSON action transcripts

* test(coding-agents): live suite matches reflect_ok by content (pages_ok now follows it in the diag stream)

* docs(coding-agents): README + docs page describe the reflect+pages runtime (reflect once per session, local page-section injection per turn, JSON action write-back)

* feat(coding-agents): drop the backfill CLI — ingestion is automatic and background

- new deepen engine (dist/deepen.js, unpublished): idempotent, resumable —
  per-bank lock, dedup by document id; ingests missing conversations, the
  one-time gitlog seed, then progressively deepens recent history with
  per-commit full diffs (newest first, bounded batch per run); drains and
  creates knowledge pages last
- every session start now fires the engine (cold or warm); survey and the
  cold-seed note stay cold-only
- sync status is the new readiness contract: hindsight_sync_status agent tool
  + dist/status.js for harnesses (synced = gitlog seeded, pages present,
  extractions drained); activeOperations() filters terminal ops
- opencode write-back now upserts every turn (async) so a killed session
  loses at most the last turn
- repoNameOf resolves relative paths so document ids are path-spelling-proof
- hindsight-coding-backfill bin removed; benchmark/e2e run the engine
  directly and poll status

* polish(coding-agents): short, non-technical cold-seed message highlighting the bank id

* polish(coding-agents): cold-start banner — HINDSIGHT unicode wordmark + bank id line

* feat(coding-agents): timing diagnostics on by default

- session_start diag event on EVERY session (bank, cold/warm, pages, ms) —
  warm sessions previously logged nothing
- deepen engine: deepen_started/deepen_done/deepen_failed diag events with
  duration; child output now appended to ~/.hindsight/coding-agent-state/deepen.log
  (was stdio:ignore — undebuggable) and log lines timestamped
- retain_ok/retain_failed carry ms on both the Stop hook and the opencode
  per-turn upsert (which was fully silent)
- vitest config pins HINDSIGHT_DIAG_FILE to a tmp file so unit tests stop
  polluting the real diag log

* feat(coding-agents): show the Hindsight banner on every session start (cold: learning, warm: remembering)

* polish(coding-agents): session banner uses the API server's colored pixel-art logo (shared visual identity), wording line below

* polish(coding-agents): banner text before logo — the TUI's first-line prefix was displacing the logo's top row

* polish(coding-agents): banner logo re-rendered foreground-only — the TUI strips ANSI background colors, which deleted half the server logo's pixels

* feat(coding-agents): per-turn user-visible notice — every prompt shows what Hindsight delivered (reflect state + matched knowledge pages) via hook systemMessage; opencode logs the same line

* polish(coding-agents): per-turn notice shows the match query excerpt and the page titles it returned

* fix(pages-index): singularize plain-word tokens so plural prompts match singular headings ('components' -> 'Component map'); path-like tokens untouched

* polish(coding-agents): per-turn notice — gradient Hindsight wordmark, value-driven wording, no timings

* feat(coding-agents): interim always-inject knowledge stub + explicit Hindsight attribution

- selectSections: TEMPORARY stub returning the first section of up to 3
  distinct pages every turn regardless of prompt — guarantees injected data
  for testing source attribution; will be replaced by the server-side
  knowledge-base/search (local lexical index drops with it)
- both injection blocks now carry an ATTRIBUTION directive: when memory
  shapes the answer, the agent introduces it with '🧠 From Hindsight memory
  (<page>)' — and must never credit memory that did not contribute

* polish(coding-agents): gradient-word banner (logo dropped), lean per-turn notice, attribution directive front-loaded as a mandatory output format

* polish(coding-agents): reflect turn notice shows the assigned goal and a preview of what memory returned

* feat(coding-agents): page knowledge moves from auto-injection to an explicit tool

- new hindsight_search_knowledge_pages(query) tool (native on opencode, MCP on
  hook harnesses) — interim local selection, single swap point for the
  server-side knowledge-base/search; results carry the attribution requirement
- per-turn auto-injection of page sections removed: a trivial prompt ('yes')
  no longer displays phantom research; ordinary turns are silent
- per-turn notice only on the reflect turn (assigned goal + result preview);
  tool calls provide their own native visibility
- tool guide/roster advertises the search tool as the first stop

* feat(coding-agents): bind hindsight_search_knowledge_pages to the server-side hybrid knowledge-base search

- merge feat/knowledge-pages-okf underneath (GET /knowledge-base/search,
  BM25 + vector, RRF-fused; conflicts resolved in okf's favor for server/
  clients/UI, coding-agents docs entry preserved)
- client.searchKnowledgePages(query, limit) wraps the endpoint; the tool
  returns ranked {page, page_id, snippet, score} — verified end-to-end
  through the real MCP server against the live endpoint
- interim local selection removed from the tool path (pages-index remains
  only for the hook page cache pending full cleanup)

* refactor(coding-agents): drop pages-index — local section index deleted; hook/runtime keep only the id+title roster (content lives behind the server-side knowledge-base search)

* refactor(coding-agents): drop hindsight_search_memory (raw recall) — knowledge-page search is THE search surface; recall client method and formatter removed

* feat(coding-agents): hindsight_reflect tool — on-demand deep memory reasoning alongside the session-start reflect

* refactor(coding-agents): one 'conversation' retain strategy for all developer conversations

Backfilled decision chats and live session write-back were the same content
type (identical JSON action-transcript format) extracted two ways based only
on where they came from. Merged CHAT_MISSION + SESSION_MISSION into one
CONVERSATION_MISSION that scales facts to substance (short decision chat ->
1-2 facts, working session -> several; final-state-wins, verbatim literals,
rejected-alternative rule kept); the ≤2-fact CHAT_CUSTOM_INSTRUCTIONS
extractor is retired with it.

* feat(coding-agents): restore Chris's knowledge entity_labels tier

configureBank again sets entity_labels {knowledge: feature-work/decision/
convention/component/concept, tag:true} + entities_allow_free_form, so the
extractor routes durable facts with knowledge:<tier> tags the server-side
knowledge base can select on; capture_initiative markers regain the
knowledge:feature-work label. Pages themselves stay unscoped (the okf
knowledge base owns synthesis).

* feat(coding-agents): seeded pages tag-scoped again — page tags match the restored knowledge:<tier> entity labels (capture_initiative pages included)

* fix(coding-agents): reflect injection wrapped in <hindsight_memory> so write-back never re-ingests it; seed-state file (declined flag) removed — the live bank is the only state

* feat(coding-agents): gitIngest enum ('message' | 'full' | 'none') — one setting, one code path for seeding AND staying current

- deepen's idempotent git pass IS the sync: gitlog doc re-upserts when HEAD
  moves (gitlog-head:<sha> tag makes freshness a single tag query); in full
  mode new commits surface at the top of rev-list and the next run ingests
  them
- separate git-sync path deleted (sync.ts, runtime.syncGitOnce, gitSync
  config)

* feat(coding-agents): gitIngest defaults to 'message' (cheap by default; opt into depth); deepen gains --git-ingest override for harnesses

* feat(coding-agents): session banner shows git-sync state (condensed syncStatus): 'git in sync' / 'catching up on new commits' / 'syncing git history (n/target)'

* polish(coding-agents): two-line banner — value headline (tracking decisions/conventions/history) + bank/sync detail line

* refactor(coding-agents): ONE config file — project-local .hindsight/coding-agent.json layer removed entirely (with its sanitization machinery); per-repo routing stays via directoryBankMap

* docs(coding-agents): fix stale project-config reference in comment

* refactor(coding-agents): runtime scratch (deepen lock + engine log) moves to the OS temp dir — ~/.hindsight now holds ONLY the config file

* feat(coding-agents): cursor auto-ingestion parity — hosts without a SessionStart hook fire the deepen engine (+ cold survey) from the session's first prompt

* feat(coding-agents): leveled plugin logging — one plugin.log (debug/info/warn/error, config logLevel + HINDSIGHT_LOG_LEVEL/FILE overrides); diag events mirror at debug; deepen logs itself (separate deepen.log dropped); warn on reflect/retain failures

* feat(coding-agents): one-shot bank configuration via the server's template import — missions, strategies, entity labels, and the 5 seeded pages in a single idempotent POST /import (configureBank PUT+PATCH and createPages removed)

* feat(coding-agents): one-command installer — npx hindsight-coding-agents install|uninstall [harness...]

Detects the coding agents on the machine and merges each one's native
wiring (hooks + MCP: claude mcp add for Claude Code; hooks.json + append-
only config.toml sections for Codex; settings.json for Gemini; hooks.json
+ mcp.json for Cursor; plugin array for opencode). Idempotent by marker,
preserves foreign entries, backs up touched files as .hindsight-backup;
uninstall removes exactly ours. 27 unit tests over temp homes.

* fix(installer): refuse to install from an npx/dlx cache (wired paths would die on eviction); document global install + npm update -g as the update path

* ci(coding-agents): unit + typecheck + build job, and a live E2E job (real API server + real LLM) running the deepen->sync->reflect->injection path; prettier-format the package

* docs(blog): launch post draft — coding-agent memory results (marked draft: true)

* docs(blog): rewrite launch post as the narrative — from 'does memory even help?' through why-not-SWE-bench, the corrections dataset, benchmark-driven architecture decisions, to the final numbers

* docs(blog): position knowledge pages as a co-launch headline — living-documents framing, example page excerpt, platform-wide availability (dashboard editor, hybrid search API, bank templates), closing CTA

* docs(blog): restructure launch post payoff-first — contrarian RAG finding + cost in the lede, TL;DR box, narrated task with both runs, seeded-answers objection met head-on, data-locality/time-to-value/latency answers, Sonnet number promoted, backstory compressed to one section

* fix(coding-agents): deepen waits for server-side ops to settle (template-import page refreshes broke the synced contract); HINDSIGHT_CONFIG env override for the config path (containers/test harnesses; replaces the live test's dependency on the removed project-config layer)

* docs(blog): second-pass fixes — flagship example swapped to the arbitrary retry decision (RFC 4180 attack closed), reconstruction disclosed, 58% provenance clause, placebo backstory + grading block restored in numbers, RAG figure per-task, benchmark-site date

* docs(blog): align remaining CSV references with the retry flagship; TL;DR per-task figures

* docs(blog): flagship rebuilt on the real dataset task — the ERP export decision whose rejected alternative IS the textbook fix (='00042' formula form, minimal quoting, CRLF); dangling injection-verified reference restored; limitations cross-check attached to the correct row

* docs(blog): rewrite as the 0.9.0 launch post — five-beat narrative (question → dataset → auto-recall failure → reflect → knowledge pages from llm-wiki to self-healing) for Knowledge Pages + unified coding-agents plugin

* docs(blog): add the missing beat — shaping the dataset revealed decisions live in git, which the old plugins never ingested

* docs(blog): reframe reflect — very smart rather than slow; first message carries the session goal; on-demand reflect tool for session drift

* docs(blog): pages section addresses the 'back to files?' objection — pages as projected views over consolidated memory (contradiction resolution underneath), raw docs remain source of truth

* docs(blog): out-of-box row updated to n=3 (22/26/23 -> 0.72/task, -26%; cost -35%); matured row marked single-run

* fix(hooks): reflect block injected once per session (+ cadence refresh), not every turn — hook context persists in the transcript, so per-turn re-injection stacked duplicate blocks

* fix(coding-agents): wrapper bundles ship deepen.js, not the renamed backfill.js

The core build entry `backfill` was renamed to `deepen` (deepen engine +
status), but the three wrapper build.mjs bundleFiles lists still copied the
removed `backfill.js`, so every dev-install failed with ENOENT. Point them at
`deepen.js` (spawned by seed.ts at runtime) so the installers build again.

* feat(coding-agents): periodic re-survey — refresh structural pages every N commits

Structural knowledge pages are only generated on a cold repo, so an evolving
architecture drifts from what the survey captured. Add surveyRefreshCommits
(default 20; 0 = cold-seed only): at SessionStart, count commits reachable from
HEAD since the newest survey-baseline marker (branch-robust via
git.commitsSince) and re-run the headless survey once the threshold is crossed,
re-recording a baseline marker. Cold seed still records the first baseline.

* fix(coding-agents): per-turn hook timeout (30s) must exceed the 25s reflect cap

The once-per-session reflect is capped internally at HOOK_REFLECT_CAP_MS=25s,
but every harness killed the UserPromptSubmit/BeforeAgent hook at 15s — below
the cap. The host killed the hook mid-reflect before the cache write, so the
injection was discarded AND the reflect re-fired uncached on every turn
("UserPromptSubmit hook timed out after 15s" every prompt). Raise the hook
timeout to 30s (> cap) across claude/codex/gemini, bump Stop to 30 to match,
and document the cap-below-timeout invariant so it can't silently drift again.

* polish(coding-agents): attribution header is a bold blockquote callout, not flat text

The live directives all told the agent to credit memory with a plain inline
"From Hindsight memory (<page>):", which renders as flat text. Switch every
directive (session tool-guide, reflect injection, both MCP tool descriptions)
to a markdown blockquote header "> ... **From Hindsight memory (<page>)** — ..."
so it renders as a distinct callout, restoring the richer attribution look.

* fix(coding-agents): strip <hook_prompt> transport wrappers from retained transcripts (codex surfaces hook stdout/errors as user messages); session + backfill transcripts switch to JSONL (one turn per line — clean appends, chunker-atomic turns)

Note: benchmark numbers (n=3) were measured on the JSON-array format; JSONL
is extraction-equivalent by design but unvalidated by a sweep — gate before
quoting new numbers on this pipeline.

* fix(installer): write [features].hooks (codex_hooks deprecated in Codex >= 0.145); accept either flag as already-enabled

* fix(hooks): fire the ingestion engine from the FIRST prompt on every harness (lock-protected no-op when SessionStart already did) — safety net for sessions predating the install, whose banks otherwise never get pages; survey stays SessionStart-owned (ensureSeed hosts excepted)

* feat(status): expose survey observability — surveyBaseline (last surveyed HEAD, from Chris's survey-baseline markers) + surveyCommitsBehind in syncStatus/hindsight_sync_status

* test(status): expected shapes include the survey observability fields

* feat(survey): findings docs ARE the completion signal — surveyDocs (0-4) in syncStatus; a baseline without findings re-fires the survey at the next warm session start (crashed-survey retry)

* feat(config): banks.<bankId> overrides — per-repo opt-in/out applied AFTER bank resolution (disable a repo, tune gitIngest/retainSessions per bank) from the ONE config file; resolution fields ignored inside a bank section

* feat(config): bankAliases — remap resolved bank ids as the final resolution step (single hop, converging allowed); docs page brought fully current (env exceptions, gitIngest/logLevel/survey rows, banks overrides, aliases, resolution step 4)

* refactor(config): bank rename lives INSIDE banks.<id> as the  field (separate bankAliases tree removed) — one per-repo section for disable, behavior, and rename; applyBankConfig returns {cfg, bankId}

* docs(coding-agents): recipe — two repos sharing one bank (converge by resolved id via banks.<id>.bank, or by path prefix via directoryBankMap), with the id-vs-path rule of thumb

* rename(config): directoryBankMap -> mapPathToBank (direction-explicit; pre-0.9.0 breaking-rename window)

* feat(coding-agents): companion skill — hindsight-coding-agent SKILL.md shipped in the package and installed into ~/.claude/skills by the installer; explains storing/retrieving, full config (banks/mapPathToBank/gitIngest), install/update, and debugging

* docs(coding-agents): mention the companion skill in README + docs page

* feat(coding-agents): companion skill ships to ALL skills-capable hosts (claude/gemini/cursor native dirs, codex via ~/.agents/skills standard); retained sessions and ingested documents carry the harness as tag (harness:<name>) and metadata

* feat(skill): self-updating companion skill — every session start re-syncs installed copies with the packaged SKILL.md (presence-gated; npm update -g now updates the skill too, no re-install)

* fix(coding-agents): worktree-aware document ids (no more per-worktree gitlog duplicates) + deepen self-cleanup; issue/PR refs preserved verbatim and emitted as ENTITIES; calibrated reflect-injection wrapper; docs ported to the TRUE source (hindsight-docs/docs-integrations) that generates the skill copy

* docs(skill): explain the internal marker documents (survey-baseline:<sha> bare-sha content is deliberate — zero extracted facts; gitlog:<repo> seed doc)

* feat(survey): human-readable baseline markers under a zero-extraction marker strategy (live-verified: 0 facts) — start as researching, deepen lazily flips to completed once findings exist

* refactor(survey): one survey strategy with conditional rules replaces the separate marker strategy — status markers extract nothing, findings extract structural facts (both branches live-verified)

* fix(hooks): mid-session heal — zero knowledge pages in the roster cache fires the ingestion engine on any prompt (covers long-lived sessions predating the install; lock makes repeats free)

* feat(bank): ~ expansion in mapPathToBank; document the directory-blacklist recipe (map tree to one bank + disable it)

* feat(coding-agents): explicit correction protocol — when the agent verifies a memory is wrong/stale it ingests a 'Correction: <topic>' doc (claimed vs verified-true vs evidence); guidance in the injection wrapper, tool guide, tool description, and companion skill

* fix(hooks): reflect block injected exactly once — cadence re-injection dropped (replaying the turn-1 synthesis at arbitrary turns reads as random noise after drift; hindsight_reflect covers genuine re-need)

* fix(coding-agents): 15s hard timeout on every client request + opencode boot no longer awaits seedIfCold — a stalled memory server can never freeze the host TUI (onPrompt already tolerates a late preamble)

* fix(reflect): defer past trivial openers — a greeting no longer spends the once-per-session synthesis on 'hi' (seen live: reflect answered a greeting with persona chatter and burned the session's slot); first substantive prompt reflects instead

* test(hooks): align reflect-call assertions with the non-trivial fixture prompt

* Revert trivial-prompt reflect deferral (misread the report — the issue was the notice's UI position, not reflect-on-greeting behavior)

* fix(opencode): stop writing banner/reflect notices to stderr — opencode renders plugin stderr inside the TUI at the cursor (text wedged against the input bar); the trail moves to the plugin log

* feat(opencode): TUI companion plugin — visible presence via api.ui.toast (opencode's TUI plugin API): banner toast on activation + reflect goal/preview toasts from the plugin-log trail; installer registers the second entry

* fix(opencode): visible presence via the server client's tui.showToast (POST /tui/show-toast) — banner + reflect toasts from the server plugin; the separate TUI module approach removed (1.18.9's loader rejects tui-only entries in the shared plugin list); SDK deps bumped to 1.18.9

* fix(opencode): toasts never rendered — v1 client wants {body}, and boot toast raced TUI mount

opencode injects the v1 SDK client whose showToast signature is {body: {title,
message, variant, duration}} and which resolves with {data|error} instead of
rejecting — the earlier flat-params call sent an empty body and the failure was
invisible. Also the toast event is not durable: the seed banner on a warm bank
fired <1s after plugin init, before the TUI subscribed, and was lost. Toasts now
use the body shape, log a rejected result at debug, and defer until ~3s past
init. Verified live in tmux: boot banner and reflect toast both render.

* fix(coding-agents): reflect must report history, never issue directives

The 0.8.6-blog incident: reflect fused two true but unrelated facts (the
hermes-deprecation goal and the blog-section removals of c87e7ac19) into one
confabulated narrative rendered in the imperative — 'You should explicitly
remove the following sections' — a completed past action re-issued as a present
directive, indistinguishable from a prompt injection to the receiving agent.

Three changes:
- buildReflectQuery wraps the session's first prompt with strict rendering
  rules: declarative past-tense attributed facts only, no instructions or
  recommendations, no stitching unrelated episodes into one narrative.
- The <hindsight_memory> wrapper now states the block is a record of the past
  that never assigns tasks: imperative wording inside it is a description of
  work already done, to be ignored unless it informs the task as historical
  fact (and unrelated memories are still ignored outright).
- The reflect_ok diag event records the injected synthesis verbatim (8k cap),
  so the next incident is one grep instead of harness-transcript spelunking.

* refactor(coding-agents): read and seed knowledge pages through the knowledge-base API

The plugin advertised knowledge pages but drove them off /mental-models, so the
two halves of the feature never met: pages seeded via the bank template's
mental_models key got a mental model and no knowledge_pages node, and
/knowledge-base/search joins through that table — the five seeded pages were
absent from the corpus of the tool billed to the agent as its FIRST STOP. The one
page search could return (an initiative, created through the KB endpoint) came
back as a kp-… node id, which the reader then fed to GET /mental-models/{id} and
404'd. Search found only what read could not open.

Every page operation now speaks one id space:

- listPages reads /knowledge-base/tree and flattens it to {items:[…]}, dropping
  folders and keeping the containing folder name.
- getPage reads /knowledge-base/pages/{id} — the ids search and [[page:<id>]]
  links already hand back.
- seedPages replaces the template's mental_models key: it creates the PAGES
  taxonomy through /knowledge-base/pages and re-syncs a drifted source_query via
  PATCH /knowledge-base/nodes/{id}, so a plugin upgrade that rewords a query
  lands on the live page instead of orphaning its synthesized content. Matched by
  name, since the endpoint mints its own id; a 409 from a concurrent deepen run
  is tolerated rather than failing the run.
- createPage/updatePage/deletePage are deleted — mental-models CRUD with no
  callers outside its own tests.

Verified against a live server on a scratch bank: five real kp- nodes, re-run
reports 0 created / 5 unchanged, all five readable by their listed id, all five
now returned by /knowledge-base/search, and a hand-drifted source_query restored
onto the same node rather than a duplicate.

* feat(coding-agents): autoReflect flag — opt out of injected reflect into tool-only mode

autoReflect (default true, layerable per-harness/per-bank like every other
field) keeps today's validated behavior: one reflect synthesis injected on the
session's first prompt. Set false and nothing is injected; instead the
knowledge preamble and every roster refresh carry an explicit trigger telling
the agent to call hindsight_reflect itself whenever a new task/goal is set —
the pull-based variant, ready to benchmark against the push default.

* docs(blog): move the 0.9.0 launch post to its own PR

The draft now lives on blog/0-9-0-launch so this PR merges independently of
launch timing (hero image, publish date, and final voice pass pending there).

* fix(deepen): dead-holder locks are stale immediately, not after 30 minutes

The per-bank deepen lock only honored its TTL: a killed run (SIGKILL, crashed
harness) left its bank locked for LOCK_STALE_MS, and every subsequent deepen
exited 'another run holds the lock — nothing to do' against an empty bank.
The lock already records the holder's pid — probe it (kill -0); if the holder
is gone the lock is stale now. Found live: a killed benchmark ingestion left
four banks locked and the retry campaign polled empty banks to its deadline.

* feat(coding-agents): expand native harness support

* fix(reflect): table-shaped decisions must be reproduced verbatim, not summarized

Benchmark replay showed reflect compressing mapping/table policies into prose
('specific extensions map to specific types') and even asserting a lossy
generalization that matched a known-wrong fix — while rule-shaped policies
survive intact. The reflect query now demands complete verbatim enumeration of
mappings/sets/tables including carve-outs.

* fix(reflect): decisions outrank implementation-derived memory

Under heavy retrieval noise, reflect surfaced the git-ingested BUGGY module
source as 'the established implementation logic' while claiming no decision
records existed — presenting the bug under investigation as authority. The
rendering rules now state: report decisions and rationale, never the current
implementation (the reader has the code); when decision memory and
code-derived memory conflict, the decision wins; implementation-only matches
are not policy.

* feat(coding-agents): expand harness integrations

* Expand coding-agent integrations and legacy compatibility

* chore(coding-agents): fix the CI-only test failure and complete the release wiring

The `test-coding-agents` job failed on every run while passing locally: the
gitDiffTarget fixture committed into a temp repo without a git identity, which a
developer machine supplies from its global config and a CI runner does not
("empty ident name not allowed"). The identity is now passed per-command, the
way the harness E2E fixture already did it.

Release wiring, which was incomplete in three places that each fail at a
different point:

- scripts/release-integration.sh had no entry, so the release refuses to start.
- generate_changelog.py keeps its OWN integration list; the release script
  aborts and reverts at the changelog step when a name is missing there.
- The docs build cross-checks released tags (`integrations/<name>/vX.Y.Z`)
  against the SLUGS in integrations.json. The directory was the only
  integration carrying a `hindsight-` prefix, so the tag would have been
  `integrations/hindsight-coding-agents/...` against a `coding-agents` slug —
  green release, then a failing docs build. The directory is renamed to
  `coding-agents` so directory, integration name, tag and docs slug all agree,
  matching every other integration.

Also drops the claude-code-v2 / codex-v2 / gemini-v2 wrappers and the
hindsight-memory-v2 marketplace entry. Claude Code is fully served by
`hindsight-coding-agents install claude-code` — hooks, MCP and skill — so the
wrappers were a second copy of the same core with its own version to keep in
lockstep. The README rows that pointed at their dev-installers now name the
supported installer command instead.

* fix(coding-agents): make the installer actually re-point a moved package

Both bugs were exposed by the directory rename, which invalidated the absolute
paths every host config stores — the case `install` exists to repair.

- Grok wrote its block only when one was absent, so every later `install` was a
  silent no-op and the dead paths survived; the only repair was editing
  config.toml by hand. It now replaces the block, sharing one regex with
  uninstall.
- MARKER was the full package name, which identifies our entries for
  dedupe-on-reinstall and for uninstall. A repo checkout stopped containing it
  once the directory dropped its `hindsight-` prefix, so from a checkout
  re-installs would have accumulated duplicate hook entries and `uninstall`
  would have removed nothing. Narrowed to the substring both layouts share.

Regression tests cover a moved package being repointed (not appended past), the
marker matching npm and checkout paths, and a repeated checkout install leaving
one entry per event.

---------

Co-authored-by: Chris Latimer <chris.latimer@vectorize.io>
2026-07-31 22:15:21 +02:00
Nicolò Boschi b769045b64 feat(engine): pluggable memories storage backend (#2917)
Squashes the feat/pluggable-memories-provider work into one commit.

- Carve the `memory_units` + link slice out from behind raw SQL into a pluggable
  MemoriesExtension (engine/memories/), so a different engine (memlake) can own
  memories, links, retrieval, consolidation and curation while documents, chunks,
  banks and the entity registry stay in Postgres. The default PostgresMemories
  keeps everything exactly where it was; every call site routes through the store
  interface rather than branching on the implementation.
- Route recall (semantic+BM25+graph), scan/get, stats/counts, consolidation
  writes, curation edits, bank/document deletion, entity postings and graph reads
  through the store.
- Cross-store write-group transactions (begin/decide/mint/witness + recovery
  sweep) so a store that keeps memories elsewhere commits atomically with the
  Postgres side of a retain/consolidation/curation/delete.
- Documents & chunks: when the store owns a dedicated document store
  (owns_document_store), a document's bulky extracted text + chunk texts move out
  of Postgres into it (Postgres keeps thin rows: id, content_hash, chunk_index,
  tags); reads overlay the text from the store; the original file goes through a
  memlake FileStorage backend. All gated so the Postgres path is unchanged.
2026-07-30 14:41:03 +02:00
Nicolò Boschi 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
2026-07-29 18:09:56 +02:00
Sanderhoff-alt feac397324 chore(repo): remove unused code (#3007)
* chore(api): remove unused code

Remove confirmed unreferenced helpers from the API and engine.

Delete tests only where they cover superseded internal paths. Keep
active test helpers and public memory operations unchanged.

* chore(cli): remove unused code

Remove dead CLI configuration, client, and output helpers.

Drop the parser implementation and tests used only by the retired
output path.

* chore(control-plane): remove unused code

Remove unused ControlPlaneClient methods and unreachable directive
detail state from the think view.

* chore(dev): remove unused code

Remove unreferenced benchmark and repository maintenance helpers.

* chore(embed): remove unused code

Remove the unused daemon port lookup helper while preserving current
profile-based daemon discovery.

* chore(integrations): remove unused code

Remove unreferenced helpers across supported integrations.

Drop tests only for retired internal paths and retain active test and
lifecycle infrastructure.

* test(consolidation): port prompt regression tests to split builders

The dead-code cleanup removed build_batch_consolidation_prompt and its tests,
but those tests guarded behaviors that are still live in the current
build_consolidation_system_prompt / build_consolidation_input path:

- brace-safety of a mission / capacity note containing literal { } (a lone
  brace would raise KeyError in the internal str.format() and crash
  consolidation)
- output-language directive injection into the cached system prompt
- the built-in default mission when none is supplied

Re-add these as regression tests against the current builders instead of
dropping the coverage. Also fix a stale comment referencing the removed
utils.extract_facts module.

---------

Co-authored-by: Nicolò Boschi <boschi1997@gmail.com>
2026-07-29 11:18:30 +02:00
Scott Guymer 6500944c74 feat(copilot-cli): add GitHub Copilot CLI hooks integration (#2742)
* feat(copilot-cli): add GitHub Copilot CLI hooks integration

Add hindsight-integrations/copilot-cli/, giving GitHub Copilot CLI
persistent long-term memory via Hindsight hooks (see docs.github.com/en/
copilot/how-tos/copilot-cli/customize-copilot/use-hooks). Modeled on the
existing cursor-cli integration.

Hooks:
- sessionStart: recall using initialPrompt (or a cwd-derived fallback
  query), injects additionalContext
- subagentStart: recall for every subagent Copilot CLI spawns (explore,
  task, research, code-review, rubber-duck, security-review, and custom
  agents, not the built-in general-purpose agent, which never fires
  this hook). Subagent payloads carry no per-invocation task text, so
  this always uses the fallback query.
- agentStop: reads the transcript, retains to Hindsight on a configurable
  turn cadence, caches the transcript path for sessionEnd
- sessionEnd: forces a final retain using the transcript path cached from
  the last agentStop, since sessionEnd's own payload has no transcript
  path field

Install via pip install hindsight-copilot-cli, then hindsight-copilot-cli
install (user scope, writes ~/.copilot/hooks/hindsight-copilot-cli.json)
or --scope repo for a team-shared .github/hooks/ registration. Zero
runtime dependencies, hook scripts are pure stdlib Python.

Also wires up CI (test-copilot-cli-integration job), release-integration.sh
and generate_changelog.py registration, and docs gallery/sidebar entry.

Closes #1588

* fix(copilot-cli): regen skill mirror, drop unreleased changelog link

- Run generate-docs-skill.sh to add the missing skill mirror for the
  new copilot-cli doc page (verify-generated-files was failing on the
  untracked references/sdks/integrations/copilot-cli.md).
- Remove the [View Changelog] link, which pointed at
  /changelog/integrations/copilot-cli — a page the release script only
  creates on first release, so it was a broken link failing build-docs.
2026-07-28 09:52:59 -04:00
Nicolò Boschi 5792b2b864 fix: avoid dotenv side effects on library import (#2979)
* fix: avoid dotenv side effects on library import (#2961)

`hindsight_api.config` called `load_dotenv(find_dotenv(usecwd=True),
override=True)` at module scope. Importing `hindsight_api` (or anything that
pulls it in — `import hindsight`, `HindsightEmbedded`) therefore walked up from
the host process cwd and overwrote the embedding application's own environment,
with override=True beating values it had set deliberately (#2961).

Move the load out of module scope into a `load_dotenv_for_entrypoint()` helper
that Hindsight's standalone entry points call explicitly: the API CLI
(`main.py`), the ASGI app (`server.py`), the worker, and the admin CLI. Library
imports are now side-effect-free.

Backwards compatibility for our own deployments is preserved exactly:
- `override=True` is kept in the helper, so a discovered `.env` stays
  authoritative over the ambient process env — unchanged precedence.
- `server.py` is covered, not just the CLI: it is the `uvicorn
  hindsight_api.server:app` target AND the import string uvicorn re-imports in
  each worker process when `hindsight-api` runs with `--workers`/`--reload`, so
  omitting it would silently break `.env` loading in multi-worker mode.
- `tests/conftest.py` now loads the workspace `.env` with `override=True`,
  matching the precedence config.py used to apply at import time (the oracle
  fixture depends on `.env` being authoritative).

Also drop the now-obsolete `_EARLY_DB_URL` workaround in `recall_perf.py`.

Closes #2961

* style: ruff-format test_fact_extraction_retry signature (pre-existing #2969 drift)

`ruff format` collapses this test's parametrized signature onto one line (it
fits within the 120-char limit). #2969 (e5cd23940) committed the multi-line form,
so verify-generated-files now flags it on every new branch. Not related to the
dotenv change — folded in here only to keep the whole-tree generated-files check
green.
2026-07-27 14:36:35 +02:00
Nicolò Boschi 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
2026-07-22 14:04:42 +02:00
Ben b11e053323 feat(zcode): add Hindsight long-term memory integration for ZCode (#2549)
* feat(zcode): add Hindsight long-term memory integration for ZCode

Adds a hooks-based, no-MCP integration for ZCode (Z.ai's GLM desktop
coding agent). ZCode embeds the Claude Code agent runtime and reads the
standard Claude Code hook schema from its own config namespace
(~/.zcode/cli/config.json), so `hindsight-zcode install` wires three
process hooks — SessionStart, UserPromptSubmit (recall), and Stop
(retain) — without touching the user's ~/.claude config and without an
MCP server.

Recall injects relevant memories as additionalContext before each
prompt; retain assembles each turn from the prompt (captured at
UserPromptSubmit) and the response (Stop payload) and stores it to
Hindsight. Verified end-to-end in ZCode 3.2.2: hooks fire, retain
persists to the cloud bank, and recall injects memory into the agent.

Includes the pip package + installer, hook scripts, tests, CI job,
release-integration wiring, changelog registration, docs page, and
gallery entry.

* feat(zcode): add self-serve marketplace + hooks-only plugin variant

Publishes the ZCode integration as a hooks-only Claude Code plugin
(hindsight-zcode) in the repo's plugin marketplace, so ZCode users can
install it via 'zcode plugins add-marketplace vectorize-io/hindsight'
without pip and without depending on Z.ai's marketplace.

The plugin reuses the pip package's hook scripts via CLAUDE_PLUGIN_ROOT
(no duplication) — settings.json resolves as a sibling of scripts/ in
both the pip and plugin layouts. Adds a plugin manifest, plugin-format
hooks.json (SessionStart/UserPromptSubmit/Stop — no SessionEnd),
marketplace entry, validation tests, and docs.

* fix(zcode): drop changelog link from docs page (page exists only after release)

The /changelog/integrations/zcode page is generated at release time, so
linking to it broke the Docusaurus build (build-docs + verify-generated-files).
Most unreleased integration pages omit this link; follow that convention.
2026-07-20 10:58:24 -04:00
Sanderhoff-alt 0108cd7019 chore: remove stray local state files (#2472) 2026-07-20 10:41:58 +02:00
Nicolò Boschi 418524051d perf+fix(graph-maintenance): catch the #2529 sweep deadlock in continuous perf, and drive dropped passes to zero (#2534)
* fix(graph-maintenance): retry cooccurrence sweep on deadlock

prune_stale_cooccurrences/prune_orphan_entities scan entity_cooccurrences
via a join/NOT EXISTS plan with no consistent lock-ordering guarantee,
while retain's concurrent cooccurrence upserts (entity_resolver) lock the
same rows in sorted (entity_id_1, entity_id_2) order. When the sweep and a
concurrent upsert touch overlapping rows in opposite orders, Postgres
detects a genuine cycle and aborts one side with DeadlockDetectedError —
this was 39 of 41 DeadlockDetectedError occurrences in a week of
self-hosted production logs.

Both prunes are idempotent bank-wide deletes, so wrap the sweep in the
existing retry_with_backoff helper (already deadlock-aware, previously
only used internally by acquire_with_retry's legacy pool path) instead of
letting a transient deadlock drop the maintenance pass entirely.

Adds a raw two-connection reproduction of the deadlock plus a test that
the sweep now survives one transient DeadlockDetectedError and still
returns correct prune counts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* perf(graph-maintenance): add contention suite that catches the #2529 sweep deadlock

The existing graph-maintenance suite runs run_graph_maintenance_job in
isolation, so its Pass 2/3 cooccurrence sweep never overlaps a concurrent
writer and can never deadlock — which is why continuous perf never caught
#2529. The new graph-maintenance-contention suite drives prune_stale_cooccurrences
against retain-shaped sorted cooccurrence upserts and gates on the deadlock
escape rate (dropped/observed): ~100% unprotected (fails), ~0% with the
retry_with_backoff fix (passes).

* fix(graph-maintenance): jittered backoff + larger sweep retry budget so deadlocks stop dropping passes

Completes #2529. The retry wrap alone still let ~14% of sweep deadlocks
escape under sustained retain contention (perf suite, small scale): the
backoff was deterministic (concurrent retriers woke in lock-step and
re-collided) and capped at 3 attempts.

- db_utils.retry_with_backoff: add equal-jitter to the backoff delay so
  contenders that deadlock together don't retry in sync (benefits every
  retrier, incl. the legacy acquire path). Covered by a new pure-function
  unit test.
- graph_maintenance: give the idempotent Pass 2/3 sweep a larger retry
  budget (8) — it's background work with no client waiting, so a longer
  jittered tail beats dropping a pass and leaking stale graph rows.

graph-maintenance-contention perf suite now measures 0% escape (0 dropped)
at small and medium vs ~100% unfixed; sweep_workers capped at 2 (prod
dedups to one maintenance job per bank, so 3+ concurrent sweeps was an
unfaithful amplifier).

* fix(graph-maintenance): prevent the #2529 sweep deadlock at the source via ordered locking

Prototype: instead of only retrying the deadlock, eliminate the lock-order
inversion that causes it. prune_stale_cooccurrences selects its victim rows in
the same sorted (entity_id_1, entity_id_2) order retain's cooccurrence upsert
locks them (a materialised FOR UPDATE CTE puts LockRows above the Sort), then
deletes the already-locked rows. Same lock order on both sides => no cycle.

- ops_postgresql: ordered-lock CTE prune. PG only — Oracle's DELETE can't carry
  the CTE the same way, so it stays on the ORA-00060 retry path (documented).
- system_perf contention suite: hollow-run guard re-keyed on workloads running
  (upserts+sweeps>0) not deadlocks>0, so a source-level fix (0 deadlocks) passes;
  escape-rate denominator now max(observed,dropped).

Verified (small): 0 deadlocks either side, 0 dropped, 200 upserts + 336 sweeps
concurrent, 10s vs ~30s retry path; full-revert regression still FAILs 100% escape.

* refactor(graph-maintenance): replace tuple/dict returns with dataclasses (code-review)

- _run_sweep returned a bare tuple[int, int] (from #2529's base commit); the
  project bans multi-item tuple returns even for private fns. Return a small
  _SweepCounts dataclass instead.
- contention suite's shared counters were a raw dict with known keys; convert to
  a _ContentionCounters dataclass, matching the file's existing style
  (_GraphMaintTimers). No behaviour change; 18 graph-maintenance tests + perf
  smoke (0 deadlocks, prevented-at-source) still green.

---------

Co-authored-by: Jordi Gil <jgil@redhat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 10:03:05 +02:00
Nicolò Boschi 92f433c904 Release v0.8.4
- Update version to 0.8.4 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- hindsight-all npm wrapper: hindsight-all-npm
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Sync documentation to version-0.8
2026-07-01 13:43:13 +02:00
Nicolò Boschi d68f618969 feat(stats): distributed bank_stats cache + ?refresh param + stats perf suite (#2495)
* feat(stats): distributed (table-backed) bank_stats cache on PostgreSQL

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

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

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

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

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

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

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

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

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

The new /stats ?refresh query param adds a positional arg to the progenitor-
generated get_agent_stats; the CLI reads the cached value, so pass None.
2026-07-01 13:35:54 +02:00
DK09876 fcb2c958e7 feat(devin-desktop): rename Windsurf→Devin Desktop + fix(continue) thread-safe adapter (#2410)
* feat(devin-desktop): rename windsurf integration to Devin Desktop

Cognition rebranded Windsurf to Devin Desktop (June 2026); Cascade is EOL
July 1. Rename the (unreleased) windsurf integration to devin-desktop before
first publish:

- Package hindsight-windsurf -> hindsight-devin-desktop (module
  hindsight_devin_desktop, CLI hindsight-devin-desktop, DevinDesktopConfig,
  bank default 'devin-desktop', HINDSIGHT_DEVIN_DESKTOP_BANK_ID)
- Rule now writes to .devin/rules/hindsight.md (preferred path) instead of
  the legacy .windsurf/rules/; trigger: always_on unchanged
- MCP config path stays ~/.codeium/windsurf/mcp_config.json (Devin Desktop's
  on-disk data dir, unchanged by the rebrand)
- Official Devin logo; docs + integrations.json + README refreshed with the
  'formerly Windsurf' framing
- Registries updated: test.yml job, release-integration.sh, generate_changelog,
  integrations.json (strict JSON), docs page

26 unit tests + gated live-MCP E2E pass; ruff check+format clean; real-app
smoke against local Hindsight verified (init writes both files; live recall
returns seeded facts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(continue): resolve a fresh Hindsight client per request (thread-safe)

The adapter runs on a ThreadingHTTPServer (one worker thread per request) but
shared a single Hindsight client across all of them. The client's aiohttp
session is bound to the thread/event-loop that first used it, so the first
@hindsight recall worked and every one after threw 'Timeout context manager
should be used inside a task' — Continue then showed an error context item and
the model answered with no memory.

Resolve the client per request (test-injected clients still used as-is), and
close per-request clients in a finally so the fresh aiohttp session doesn't leak
a connector each call. Bump to 0.1.1.

Found via a real in-editor VS Code test. Adds a regression test asserting
per-request client resolution across the threaded server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:27:39 -07:00
Ben d0b77f5bee feat(eve): add Eve agent-framework MCP connection helper (#2280)
* feat(eve): add Eve agent-framework MCP connection helper

Add @vectorize-io/hindsight-eve: a thin helper that wraps Eve's
defineMcpClientConnection to wire an Eve agent into a Hindsight MCP
server in one line, pre-filling the endpoint, model-facing description,
and bearer auth with env-var defaults (HINDSIGHT_MCP_URL,
HINDSIGHT_API_KEY, HINDSIGHT_MCP_BANK_ID).
2026-06-24 14:48:38 -04:00
DK09876 7194f98b19 feat(windsurf): add Windsurf (Codeium) integration via MCP (#2358)
* feat(windsurf): add Windsurf (Codeium) integration via MCP

Config-only CLI that wires the Hindsight MCP server into Windsurf's
~/.codeium/windsurf/mcp_config.json (mcpServers, remote serverUrl + auth
header) and writes an always-on recall/retain rule to
.windsurf/rules/hindsight.md (trigger: always_on). Cascade then has
recall/retain/reflect and uses them automatically.

- hindsight_windsurf: config, mcp_config (strict-JSON parse-or-print),
  rules (dedicated sentinel-marked file), cli (init/status/uninstall)
- 25 unit tests + gated live-MCP-endpoint E2E
- CI job, release + changelog registries, docs page, icon, README row

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(windsurf): apply ruff format to cli.py

lint.sh runs 'ruff format'; collapse the --rules-path add_argument to one
line so verify-generated-files passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(windsurf): use official Windsurf logo for the integration icon

Replace the placeholder abstract mark with the official Windsurf logo
(simple-icons, CC0), matching the real-brand-logo convention used by the
other integration icons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:58:55 -07:00
DK09876 91bf32842e feat(github-copilot): add GitHub Copilot (VS Code) integration via MCP (#2299)
Adds hindsight-copilot: long-term memory for GitHub Copilot in VS Code, using
Copilot agent mode's native MCP support (HTTP servers) — no bridge.

`hindsight-copilot init`:
- merges a Hindsight HTTP MCP server into .vscode/mcp.json (servers.hindsight),
  JSON-safe (prints a snippet if the file is JSONC), and
- writes a recall/retain rule into .github/copilot-instructions.md, which
  Copilot applies to every chat in the workspace.

Resolves the ask in #1588. Mirrors the Zed/OpenHands MCP-config pattern.

- hindsight_copilot package: config, mcp_config (.vscode/mcp.json writer),
  instructions (copilot-instructions.md rule), cli (init/status/uninstall)
- 25 deterministic tests (mcp.json merge incl. preserving servers/inputs +
  JSONC fallback, instructions rule block) + gated requires_real_llm MCP
  handshake E2E
- CI job, release registration (VALID_INTEGRATIONS + changelog generator),
  docs page, registry entry, icon (octicons), README row

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:39:12 -07:00
DK09876 aab7032071 feat(aider): add Aider integration (session-bracketing memory wrapper) (#2297)
hindsight-aider wraps the aider CLI: recalls project memory before each session (injected via --read) and retains the transcript after. Bank per git repo.
2026-06-18 13:52:38 -07:00
Nicolò Boschi 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
2026-06-18 11:13:37 +02:00
DK09876 52cb9a2bae chore(dev): register continue/zed/openhands in changelog generator
These new integrations were added to VALID_INTEGRATIONS / CI but not to the
generate-changelog registry, so release-integration.sh failed at the changelog
step. Add their package names so releases can be cut.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:29:53 -07:00
Ben 1c9ba0e659 feat(composio): add Composio integration (Hindsight memory as custom tools) (#2180)
* feat(composio): add Composio integration (Hindsight memory as custom tools)

Exposes Hindsight retain/recall/reflect as Composio in-process custom tools via
register_hindsight_tools(). The Hindsight bank for each call is the Composio
session's user_id, so one registered tool set isolates memory per user
automatically. Also ships memory_instructions() for pre-recall system-prompt
injection (Composio doesn't auto-inject context).

- hindsight_composio/: tools.py, config.py (dataclass + env fallback), errors.py.
- tests/: 50 tests using a FakeComposio (mirrors the real tool decorator +
  SessionContext) + mocked Hindsight client — exercises the framework wiring.
- CI: test-composio-integration job (uv build/sync/ruff/pytest) + path filter.
- Gallery card + doc page + official Composio icon; release-integration.sh entry.

* fix(composio): register in changelog generator + test memory_instructions

- Add composio to generate_changelog.py INTEGRATIONS dict (release would
  otherwise fail at the changelog step; it was only in release-integration.sh).
- Add TestMemoryInstructions covering formatting, max_results cap, empty/error
  fallback, tag passthrough, and missing-config error.

* address review: Literal config types, typed generics, debug log, real-LLM E2E

- Type budget as Literal[low|mid|high] and tags_match as Literal[any|all|
  any_strict|all_strict] across config + tools (matches autogen/continue)
- Parameterize bare list -> list[Any] on register_hindsight_tools
- _ensure_bank: logger.debug the swallowed create_bank failure so a real
  auth/network error is visible rather than only surfacing later on retain
- Add requires_real_llm E2E bucket exercising retain/recall/reflect through
  the (input, ctx) tool call path against a live Hindsight server; exclude
  from PR CI via -m 'not requires_real_llm'
2026-06-16 15:56:53 -04:00
Nicolò Boschi 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
2026-06-12 17:44:54 +02:00
Nicolò Boschi 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.
2026-06-12 11:06:31 +02:00
Ben f5a6c300f1 feat(agent-framework): Hindsight memory for Microsoft Agent Framework (no MCP) (#1989)
* feat(agent-framework): add Hindsight memory integration via context provider

Persistent memory for Microsoft Agent Framework (the successor to Semantic
Kernel) without MCP. HindsightProvider is a ContextProvider whose before_run
recalls relevant memories and injects them into the agent's instructions, and
whose after_run retains the conversation. Reuses the LlamaIndex integration's
client/config pattern and the hindsight-client Python SDK.

Targets the agent-framework-core 1.x before_run/after_run + SessionContext
contract (verified against the installed package since the API has churned).
15 unit tests subclass the real ContextProvider so drift fails loudly, plus a
gated e2e. Includes CI job, release + changelog + docs wiring, and an icon.

* chore(agent-framework): refresh lock to agent-framework-core 1.8.1 (verified no API drift)

* fix(agent-framework): drop unused per-op timeout constants

TIMEOUT_RETAIN/TIMEOUT_RECALL/TIMEOUT_BANK were defined but never used: the
hindsight-client SDK sets one timeout on the constructor and has no per-call
timeout argument, so per-op values can't be wired in. Keep the single
constructor-level TIMEOUT_DEFAULT and document why. Addresses review feedback.
2026-06-11 13:57:20 -04:00
Sanderhoff-alt c96106cc01 chore: remove dead code and stale config (#2135)
Remove unreferenced backend helpers, stale UI/docs components, and
unused imports across the API, control plane, clients, and integrations.

Drop obsolete consolidated-observation helpers and unused scoring code,
clean orphaned React/docs components, and remove stale Radix dependencies.

Align release scripts, Helm docs, lockfiles, generated clients, and
current API examples with the package and endpoint surface still in use.
2026-06-11 17:12:03 +02:00
DK09876 91d767cdcb feat(cursor): add Hindsight memory plugin for Cursor (#866)
* feat(cursor): add Hindsight memory plugin for Cursor

Adds a complete Cursor integration using the plugin architecture
(hooks, skills, rules). Automatically recalls relevant memories
before each prompt and retains conversation transcripts on task
completion. Modeled after the claude-code integration with
Cursor-specific adaptations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs(cursor): add integration docs, blog post, and sidebar entry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs(cursor): clarify plugin vs MCP modes, add hook diagnostics

- Add plugin-vs-MCP comparison table near top of integration doc
- Add "Verifying Plugin Hooks" section with state file commands
- Add troubleshooting note: visible tool calls = MCP, not plugin
- Write last_retain.json state file in retain.py for diagnostics
- Add mode: plugin and query_length to recall state file
- Fix test_settings_file_loaded to isolate from user config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(cursor): install path, always-write diagnostics, Cloud snippets

- Add mkdir -p before cp -r in all install examples (first-run fix)
- Add "fully quit and reopen Cursor" note to all setup flows
- Recall/retain hooks now write status on every invocation
  (success, empty, skipped, error) not just on success
- Fix docs to show ~/.hindsight/cursor-state/ default path
- Add concrete Hindsight Cloud config snippet to Quick Start
- Add Cloud option to blog post setup section

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(cursor): add session field to dynamic bank IDs, add changelog

- Support "session" in dynamicBankGranularity for per-conversation banks
- Add changelog page for cursor integration
- Add test for session-based dynamic bank ID

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(cursor): sync integration README with cookbook/blog setup guidance

- Add mkdir -p for plugin install path
- Add "fully quit and reopen Cursor" instruction
- Show Cloud as Option A, local as Option B, daemon as Option C
- Match the setup flow documented in the cookbook and blog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(cursor): add pip/uvx installer, fix review findings

- Add hindsight_cursor package with CLI `init` and `uninstall` commands
- Add pyproject.toml for PyPI publishing via existing release pipeline
- Update README install path: `pip install hindsight-cursor && hindsight-cursor init`
- Fix rule/skill files to describe plugin behavior instead of MCP tools
- Add diagnostics on get_api_url failure paths in both hooks
- Remove missing assets/avatar.png reference from plugin manifest
- Add Cloud token retrieval guidance (Settings > API Keys)
- Add test_cli.py with 8 tests for init/uninstall commands

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(cursor): daemon timeout, config defaults, full config docs

- Set daemonIdleTimeout default to 300s (was 0/infinite with no cleanup hook)
- Fix retainEveryNTurns fallback from 1 to 10 in retain.py
- Fix DEFAULTS: hindsightApiUrl="" and bankId="cursor" to match settings.json
- Document all config settings in README (was missing ~15 entries)
- Fix pytest version discrepancy in pyproject.toml
- Fix plugin.json author to "Vectorize" for consistency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs(cursor): streamline setup with init flags, add Docker instructions

- Restructure Quick Start around Cloud vs Local as two clear paths
- Use hindsight-cursor init --api-url/--api-token for one-command setup
- Add Docker run command for users without a local Hindsight server
- Remove separate "configure" step that contradicted init behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(cursor): replace beforeSubmitPrompt with sessionStart + MCP

beforeSubmitPrompt does not support additionalContext in Cursor's hook
system — the old recall.py was silently ignored. This rewrites the
architecture to use Cursor's native mechanisms:

- sessionStart hook for ambient project-level recall (supports additionalContext)
- MCP integration for on-demand recall/retain/reflect tools mid-session
- stop hook for auto-retain (unchanged, works correctly)

Also fixes Python floor (3.9 -> 3.10, pytest 9 requires it) and
updates docs/blog to match the new architecture.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(cursor): workaround broken sessionStart additionalContext

Cursor's sessionStart hook accepts additionalContext output but silently
drops it before the agent's composer handle is ready — a race condition
acknowledged by Cursor staff in 2026-04, still present in 3.6.31
(verified 2026-06-02 with a marker-emitting test hook). Without a
workaround the plugin's "auto-recall memories at session start" feature
silently does nothing in every install.

Per Cursor staff guidance (Dean Rie, thread 158452), the documented
escape hatch is to write a workspace .cursor/rules/<file>.mdc with
alwaysApply: true — the rules engine injects those reliably. Plugin-
local rules dirs (~/.cursor/plugins/local/...) are NOT reliable per
thread 159101.

Implementation:

- scripts/lib/rules_file.py (new): owns the workaround. Three helpers:
    * rotate_session_rules() — deletes any prior rules file at the top
      of each sessionStart so an empty recall doesn't leave stale
      memories from a previous session.
    * write_session_rules() — writes the .mdc with alwaysApply: true,
      an HTML comment that explains what the file is and links to the
      Cursor bug, and the recalled memories inside a
      <hindsight_memories> block (same wrapper the broken native path
      used, so the static rules guidance is unchanged).
    * ensure_gitignored() — idempotently appends the file path to
      <workspace>/.gitignore when the workspace is a git repo. No-ops
      otherwise. Matches both /-anchored and bare relative forms so we
      don't double-add against an existing entry.

- scripts/session_start.py: rotates at the top, writes the fallback
  file after recall succeeds, gates both behind config flags
  (useRulesFileFallback, appendToGitignore, both default True). Still
  emits additionalContext to stdout below — when Cursor fixes the
  upstream bug, dropping the workspace write is the only code change
  needed; the same plugin works on the native path with no protocol
  rev.

- scripts/lib/config.py: two new config keys + HINDSIGHT_USE_RULES_
  FILE_FALLBACK / HINDSIGHT_APPEND_TO_GITIGNORE env overrides.

- rules/hindsight-memory.mdc: tells the agent where recalled memories
  now appear (the new .cursor/rules/hindsight-session.mdc file) and
  notes that the file is plugin-generated and safe to delete.

- tests/test_rules_file.py: 18 tests pinning the on-disk shape:
  frontmatter, alwaysApply, bug link, rotation, idempotent gitignore
  with both anchor forms, falsy workspace handling, write-error
  degradation.

Why this design (vs. alternatives):

- Just shipping MCP-only and documenting the limitation would repeat
  the OpenAI Agents notebook-10 Pattern-1 failure mode: the agent has
  to choose to call recall, and small models reliably skip it. Auto-
  inject doesn't depend on tool-call choice.
- Reverting to beforeSubmitPrompt would mean a recall per turn instead
  of per session, and Cursor staff have signalled additional_context
  on that hook is unimplemented (forum 150707).
- The workspace file is the price of Cursor's bug being open with no
  ETA. Mitigations: auto-rotate, auto-gitignore, in-file explanatory
  comment, config opt-outs.

Verification:

- Full suite: 74 passed (56 prior + 18 new).
- Smoke end-to-end against a fresh git repo: rules file written with
  correct frontmatter, .gitignore appended cleanly with both an
  explanatory comment and the path entry, no duplicate-add on re-run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(cursor): adopt requires_real_llm bucketing + live E2E + lockfile + docs

Aligns cursor with the standing test-bucketing convention from PR #1469
("Split test suite into deterministic mock and real LLM buckets") that the
other eight Python integrations already follow.

Changes:

- pyproject.toml: register the `requires_real_llm` marker so the live
  E2E suite is selectable as a discrete bucket (and excluded from the
  deterministic CI path via `pytest -m "not requires_real_llm"`). Add
  hindsight-client as a dev dep — the E2E driver needs it to seed and
  verify banks; the runtime plugin scripts still use stdlib only.

- tests/test_e2e.py (new): four-test gated suite that drives the actual
  hook scripts the way Cursor does — JSON on stdin, env vars for config
  — against a live Hindsight server. Covers:
    1. session_start writes the rules-file workaround with recalled
       content, appends `.gitignore`, and emits the forward-compat
       `additionalContext` to stdout.
    2. empty-bank case: hook succeeds without writing a rules file.
    3. opt-out: `useRulesFileFallback=false` produces no `.cursor/` or
       `.gitignore` mutations even when recall surfaces content.
    4. retain end-to-end: drives `retain.py` with a JSONL transcript
       (the on-disk shape Cursor actually emits, not an inline messages
       array), then verifies the bank holds the fact via direct recall.

  Two non-obvious fixtures the suite needs:
  - `HOME` / `CURSOR_PLUGIN_DATA` redirected to tmp so the test doesn't
    touch the developer's real `~/.hindsight/cursor.json` or state.
  - `HINDSIGHT_BANK_MISSION` overridden to a focused mission that aligns
    with the seeded fixtures — the production default mission is broad
    boilerplate, fine for real users but too diffuse to reliably
    surface targeted test content within a deadline.
  - `HINDSIGHT_RETAIN_EVERY_N_TURNS=1` because retain.py batches every
    N turns (10 by default) and a single-shot test only has one turn.

- uv.lock: committing per the convention every other Python
  integration follows. 258 KB, 29 packages resolved, `uv lock --check`
  clean.

- README.md: new "How session memory reaches the agent" section
  documenting why the plugin writes `<workspace>/.cursor/rules/
  hindsight-session.mdc` (Cursor's native `additionalContext` channel
  is broken, forum thread 158452, still open in 3.6.31). Captures the
  empirically-verified behaviour: Cursor blocks prompt submission
  until sessionStart returns, so every new agent's first prompt has
  memories, the rules file is regenerated each session, and the file
  is auto-gitignored. Two new config knobs (`useRulesFileFallback`,
  `appendToGitignore`) added to the Session Recall table.

Verification:
- Deterministic bucket: 74 pass / 4 deselected (the new gated E2E).
- Live bucket (HINDSIGHT_API_URL=http://127.0.0.1:8888): 4 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cursor): default to hosted backend + give each retain a distinct document_id

V2 audit (2026-06-02) caught two real bugs in cursor that were missed by
the V1 pass:

1) Goal-5 (Default to Cloud) FAIL — settings.json shipped
   hindsightApiUrl='' and the daemon path treated empty as "fall back to
   local daemon at 127.0.0.1:9077". Users following the docs ("just enable
   the plugin") never reached the hosted backend without explicitly
   passing --api-url. Every other integration's empty-config path lands on
   https://api.hindsight.vectorize.io.

2) The retain path used document_id=session_id in full-session mode,
   which silently upserts the same Hindsight document on every retain.
   The audit's 5-turn distinct-fact driver exposed this as "5-turn cloud
   → 1 topic surfaced" — earlier turns got overwritten because each
   retain rewrote the single per-session document with whatever
   transcript snapshot was current.

Both are addressed below; the live test suite still passes against the
local server and the new deterministic tests pin the cloud-default
resolution + the unique-document-id derivation.

Changes:

- scripts/lib/config.py — add ``DEFAULT_HINDSIGHT_API_URL`` constant
  (``https://api.hindsight.vectorize.io``). Add ``useLocalDaemon`` flag
  (default ``False``) so self-hosters can opt back into the auto-managed
  daemon path. New env override ``HINDSIGHT_USE_LOCAL_DAEMON``.

- scripts/lib/daemon.py — rewrite ``get_api_url`` resolution:
    1. Explicit ``hindsightApiUrl`` wins.
    2. A locally-running server on the configured port is used (preserves
       the "developer already started a daemon" path).
    3. ``useLocalDaemon=True`` AND ``allow_daemon_start=True`` (retain
       path) triggers the auto-managed daemon. Recall path never starts a
       daemon on its own.
    4. Otherwise → ``DEFAULT_HINDSIGHT_API_URL``. A failed daemon-start
       under (3) also falls back here rather than hard-erroring, so the
       plugin keeps working when ``hindsight-embed`` isn't on PATH.

- scripts/retain.py — every retain now derives
  ``document_id = f"{session_id}-{int(time.time() * 1000)}"`` regardless
  of retainMode. The chunked-vs-full-session distinction at the doc-id
  layer was always a misfeature; full-session mode now means "the
  transcript ingested per retain may span the whole session", not "every
  retain writes the same document".

- tests/test_daemon.py (new) — pin the four-tier resolution + env
  override + the source-shape of retain.py's document_id derivation.

Verification:
- Deterministic bucket: 81 pass / 4 deselected (74 prior + 7 new).
- Live bucket: 4 pass / 0 fail against 127.0.0.1:8888.
- Manual smoke for empty-config → returns ``DEFAULT_HINDSIGHT_API_URL``.
- Live server still resolves to ``http://127.0.0.1:8888`` when healthy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cursor): parse Cursor 3.x role-nested transcript format

retain.py's read_transcript only recognized two transcript shapes:
- Flat:        {role, content}
- Type-nested: {type: "user"|"assistant", message: {role, content}}

Cursor 3.6.31 writes a third shape to its stop-hook transcript:

  {"role":"user","message":{"content":[
    {"type":"text","text":"..."},
    {"type":"tool_use","name":"...","input":{...}}
  ]}}

Top-level has `role` (not `type`), and `content` lives under `message`
as a list of typed blocks (not at the top level as a string). The old
parser's two branches both missed every line: `entry.get("type")` was
None and `"content" in entry` was False. read_transcript silently
returned [] for every Cursor 3 transcript, and retain.py bailed with
status=skipped reason=empty_transcript on every stop hook.

Visible symptom: auto-retain silently stops working under Cursor 3
even though the stop hook fires correctly and transcript_path points
at a real, populated file (verified by reading
~/Library/Application Support/Cursor/logs/.../cursor.hooks.*.log —
the input JSON includes a valid transcript_path that the parser then
ignores). End users see recall continue to work (sessionStart writes
the rules-file workaround) but new turns never get retained.

Fix:
- Add _normalize_blocks_to_text to flatten typed-block lists to a
  single string, inlining a compact [tool_use:<name>] marker so
  downstream Answer:/Thought: handling still sees coherent structure.
- Recognize the role-nested Cursor 3 shape explicitly.
- Keep flat and type-nested handling intact.

Verified end-to-end against a real Cursor 3.6.31 transcript captured
from ~/.cursor/projects/.../agent-transcripts/<conv>/<conv>.jsonl:
read_transcript now returns the 15 messages it should (1 user + 14
assistant turns) instead of 0.

Regression tests (3 added):
- test_read_transcript_parses_flat_format pins the flat shape.
- test_read_transcript_parses_type_nested_format pins the type-nested
  shape.
- test_read_transcript_parses_cursor3_role_nested_with_block_content
  is the regression: fails on the pre-fix parser (returns []), passes
  now. Also asserts the [tool_use:Shell] marker survives.

14/14 tests in test_hooks.py pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(docs): drop missing image refs in cursor blog post

The 2026-04-03 cursor-persistent-memory blog references
/img/blog/cursor-persistent-memory.png in both frontmatter and
inline markdown, but the image was never added to the repo. build-docs
fails MDX compilation with "Markdown image with URL
/img/blog/cursor-persistent-memory.png couldn't be resolved to an
existing local image file".

Strip the two references so the post renders. The prose stands on its
own without an illustration; an image can be added in a follow-up PR
if/when one is produced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(cursor): sync openapi.json with main

Branch carried an older snapshot of hindsight-docs/static/openapi.json
that pre-dated main's addition of the OperationProgress schema.
check-openapi-compatibility flagged the missing 'progress' field on
GET /v1/default/banks/{bank_id}/operations/{operation_id} as a
backwards-incompatible removal.

Re-checkout main's openapi.json onto the branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(cursor): drop cursor-persistent-memory blog post

The blog post was added as marketing for the Cursor integration but
the accompanying illustration was never produced. Earlier commit
0e4b2568 stripped the missing image references so build-docs would
pass; user prefers the blog post itself be dropped from the integration
PR and authored separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(cursor): ruff format scripts + generate docs-skill changelog

verify-generated-files CI flagged drift in three cursor scripts
(scripts/lib/daemon.py, scripts/retain.py, scripts/session_start.py)
and a missing skills/hindsight-docs/.../integrations/cursor.md.

- scripts: applied ruff format/check (3 files reformatted, all checks
  pass).
- generate-docs-skill.sh produced the integrations/cursor.md changelog
  mirror.

Format-only + a generated file regeneration; no behaviour changes.
All cursor tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: re-trigger CI

A previous push to this branch silently did not trigger a pull_request
event in GitHub Actions, leaving the PR without a CI run for the latest
HEAD. Push an empty commit to force a new event.

* ci: empty commit to attach pull_request CI check to the PR head

(Previous pushes did not auto-trigger pull_request workflow events for
reasons internal to GitHub Actions; manual workflow_dispatch runs passed
green but their checks don't roll up onto the PR. Re-poking the head
to surface the green state on the PR.)

* ci: trailing newline to force CI retrigger

* fix(cursor): address review — drop dead code, register changelog + gallery

- Remove compose_recall_query / truncate_recall_query from scripts/lib/content.py
  (ported from openclaw but unused — cursor only recalls at sessionStart) and
  their test; slice_last_turns_by_user_boundary stays (used by retain.py).
- Add cursor to the INTEGRATIONS map in generate_changelog.py so the release
  changelog step resolves the slug.
- Add the integrations.json gallery entry + icon and rely on the existing
  docs-integrations/cursor.md so check-integrations.mjs passes.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Ben <ben.bartholomew@vectorize.io>
Co-authored-by: DK09876 <dk09876@DK09876s-MacBook-Pro.local>
2026-06-09 09:50:22 -07:00
Nicolò Boschi 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
2026-06-09 14:55:08 +02:00