Skip to content

[coding agents 1/3] Serve the v1 event-memory API: tenants, query, expand, events - #1690

Draft
edwinyyyu wants to merge 43 commits into
MemMachine:feat/horizontal-scalingfrom
edwinyyyu:feat/event-memory-v1-api-main
Draft

edwinyyyu wants to merge 43 commits into
MemMachine:feat/horizontal-scalingfrom
edwinyyyu:feat/event-memory-v1-api-main

Conversation

@edwinyyyu

@edwinyyyu edwinyyyu commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Slice 1 of design/coding_agent_integration.md (#1579): the tenant-scoped v1 HTTP API over the event memory, with the bodies design/server_redesign.md ("Server API") specifies, on today's server. Stacked on #1688, the top of the event memory stack (below). Rebuilt on 2026-09-28 with the stack: the last two commits are the route's side of the capture kinds (#1692), which register in the event memory below the route, so the route names the kinds and tests them here. Restacked on 2026-10-19 without #1715: the event memory holds no sessions, so this API keeps each event's session as an event property (Sessions, below). It goes through neither the v2 router nor Episode.

One verb from EventMemory.query up: the operation is a query, its route query, its hits query hits; "search" names only the vector store's technique.

Routes (prefix /v1)

  • PUT /tenants/{tenant} 201 on creation, 200 when it existed; GET 200 / 404; DELETE 204, idempotent. A tenant is an opaque name, at most 255 bytes.
  • POST /tenants/{tenant}/episodic-memory/query: query, limit, min_cosine_similarity, expand_context, rerank (candidates, min_score, or null), since, until, session_ids, source_ids, block_kinds, filter, datetime_format, parts. Hits are score, seed, segments (uuid, event_uuid, index, offset, timestamp with offset, session_id, source_id, context, block, properties) and text, the window rendered by render_by_session in the store's order, a [session:"..."] line wherever the session changes and a [segment:<hex>] marker on every line, the handles a client continues from.
  • POST .../episodic-memory/expand: anchor, before, after, the filters, datetime_format, parts; answers before and after as segment lists plus before_text and after_text. The walk follows the tenant's timeline across sessions; session = "<id>" in filter keeps it to one.
  • POST /tenants/{tenant}/events: a list of events (id optional, minted if absent; timestamp optional, server time if absent; session_id required; source_id; context; blocks; properties) → encode_events, 200 {stored: [...]}. Ingest is synchronous, so ?wait= is accepted and ignored.
  • POST .../events/delete: {ids} → forget_events.

Errors answer {"error": {"code", "message"}} on the v1 routes only: tenant_not_found 404, component_not_enabled 404, event_exists 409, invalid_request 422, internal 500 with the traceback logged and never returned. The 409 is a commit of its own, before the two capture-kind commits: it adds the code to ErrorCode and maps EventMemoryStoreEventAlreadyStoredError from #1840 onto it, with a route test; a review comment on this PR describes the tree before that commit.

The seam

TenantEventMemories (create, exists, delete, resolve -> event memory, reranker, defaults) is all a route's logic depends on; the router's default dependency constructs the one implementation, EpisodicSessionTenantEventMemories, which makes a tenant one episodic-memory session under the key v1/tenants/<name>, created with the event backend and no short-term memory, every resource the deployment's. It cannot collide with the v2 API: a v2 session key is <org>/<project> with ids that cannot contain a slash, so it holds exactly one, and a v1 key holds at least two. Delete drops the session and with it the partition and the collection. LongTermMemory gains two accessors, event_memory_partition, its handle on the session's partition, and reranker, with no episode translation on this path.

Departures from the redesign, stated in the route descriptions

  • Tenant by name, not UUID (the design's decision).
  • No position on segments and no watermark route: there is no event store yet. Segments carry event_uuid where the design writes event_id.
  • A batch naming an event the tenant already holds is rejected whole with 409 event_exists, naming the uuids; ids are the client's dedupe key.
  • session_id is required on an event where the design has it optional: it is what the rendering groups by and session_ids selects, and this path supplies no default the way the episode translation does.
  • expand takes a segment uuid as its anchor, not a segment or event uuid, and answers before_text and after_text rather than one text per side.
  • filter is the existing string grammar; no JSON-tree parser exists on this tree and none was written.
  • Scores are in the tenant's similarity metric where the design has cosine similarity throughout, and the request and response keep the design's shape: min_cosine_similarity passes through as the score threshold on a tenant that scores by cosine similarity and is refused with 422 invalid_request by any other, and without a reranker a distance is negated on the way out, so a hit's score stays higher-is-better and the hits stay in descending score. Edwin, 2026-10-19: keep the API unless a change cannot be avoided.
  • rerank.reranker is rejected with invalid_request rather than ignored: naming a reranker would put the resource manager into a route, and the tenant's reranker is the deployment's.
  • Defaults live in one place, EpisodicMemoryDefaults (query limit 10, expansion 2, rerank candidates 40, expand 5 before and 5 after); no episodic-memory setting existed for them.

Sessions

The event memory on this line holds no sessions, so this API keeps the session_id it takes on each event as its own application property, _session (SESSION_PROPERTY). #1715, which would give the event memory sessions, is deferred and off the line; it may become this PR's base later.

  • A caller's property key must be a legal user property key ([property keys] Refuse an illegal metadata key before any store writes, and reserve one key prefix #1828's validate_user_property_key): lowercase letters, digits and underscores, at most 32 bytes, outside the reserved prefix memmachine_. It also may not begin with an underscore, which marks the fields this API writes. Another key is 422 invalid_request. A segment's session_id is read back from the property, and the properties a response carries leave the underscore keys out.
  • session_ids on a query becomes an In on session, joined with AND to filter; a bare session in filter names the same property, and the router maps it to the property before the filter reaches the memory. A filter naming a reserved key as m.<key> is 422 invalid_request. The property is not a declared key of the vector store, so the vector search cannot evaluate it: it applies to the segments the search returns, and fewer than limit hits may remain. That is the retrieval-quality cost of a session-scoped query without [event memory, deferred] Add sessions to EventMemory (port of #1597's session half) #1715. The same filter keeps each hit's window inside the named sessions.
  • A window or an expansion side is rendered in the store's order, [session:"<id>"] heading each stretch of one session's lines, so a walk that crosses sessions reads as one timeline and its first and last lines are its edges.
  • expand walks the tenant's timeline across sessions; session = "<id>" in filter keeps the walk to one session.

Choices to review

An anchor the tenant does not hold is 422 invalid_request (the closed set has no segment-level 404); events/delete answers 200 with a JSON null body, since the route returns nothing and the redesign's head position does not exist here, where the tenant delete answers 204; a delete racing a resolve that still holds the manager's reference surfaces as 500 rather than a 409 outside the closed set.

Verified

docs/openapi.json regenerated by docs/tools/generate_openapi.py, which now loads the v1 router as well: five paths and their schemas added, the capture kinds' schemas in EventSpec's block union from the start, nothing of v2 changed; after every commit that touches the routes the document compares equal to the generator's output. The v1 suite holds 73 tests, 52 on the routes through the test client over the real EventMemory on the in-memory fakes and 21 on the resolver. At this head, on #1828's copy 4fe221b on #1840 a82fc6e (2026-11-03): ruff, ruff format, ty as CI runs it on packages/server, uv lock --check and the OpenAPI comparison pass. Unit and integration suites run again when this PR comes up for review; they last passed on 2026-10-07 on #1663 e0dedbd, before the chain moved onto the event memory recovery PRs and when it scored by cosine similarity only and with #1715 beneath this PR: 2266 server tests, 136 PostgreSQL store integration tests and 258 client tests.

Stack

Two stacks, one line of branches. Every PR but #1693 targets feat/horizontal-scaling, so a diff shows everything below it on that branch until that merges; #1693 is client-only, branches from main and targets it. Rebuilt on 2026-09-28: #1684 split into time bounds and sources (#1684) and sessions (#1715), the block kind moved to #1687, and each PR restacked in dependency order. Rebased on 2026-10-01 after #1713 merged, dropping the merge commit that carried it; on 2026-10-06 after #1733 was squash-merged into feat/horizontal-scaling; on 2026-10-07 after that branch took main's #1707, which makes episode uids UUIDs; and on 2026-10-09, after #1736 was squash-merged into feat/horizontal-scaling, onto #1663's head c0a2bbd, and the same day onto fb38a72, after #1628 moved beneath #1663 and #1813 was squash-merged into feat/horizontal-scaling; on 2026-10-13 onto 1982ad3, after #1702 changed how the vector store's part of a filter is chosen; on 2026-10-14 onto 251dc60, after #1702 and #1670 merged into feat/horizontal-scaling; and on 2026-10-15 onto 250f6d5, after that branch was rebased onto main d5a39a8, then onto 3f27430, the same tree with #1627's history in three commits, then onto 0cc31da, where the vector store's lookup is open_partition and the partition API changes move to #1625, then onto 8866cb8, where #1627 drops the per-partition schema record; and on 2026-10-16 onto 203be42, where the vector store's interface loses its store-level properties and validate_identifier becomes is_valid_identifier, which #1684 and #1687 now call, then onto 4134c43, where the vector store's raising checks are named validate_*, then onto 1d6e17f, which renames a configuration check; and on 2026-10-19 onto 6f42ba1, after #1824 dropped the purge queue's unread logical key, with #1715 taken out of the line: #1688 sits on #1692 and marks segments only, and the v1 API (#1690) keeps an event's session as an event property. #1715 stays open on #1692, deferred, and nothing depends on it; #1688 and the coding-agent PRs above it stay deferred with the coding-agent features. The same day onto c14c06d, which changes one word of a design document, and then onto #1628's head 0375a60, with #1663 taken out of the line at Edwin's request: event memory scores by the vector store partition's similarity metric again, eviction applies one rule under every metric, and the v1 API keeps min_cosine_similarity for a tenant that scores by cosine similarity and a higher-is-better score for every tenant. The same day onto #1628's head 35e60c9, after #1627 let DatabaseManager return the existing store for a repeated name, onto b077a30, with #1627's second-review fixes to its tests, docstrings and documents, and on 2026-10-20 onto 0bef137, where #1627's comments, docstrings and documents were corrected, then onto the event memory recovery PRs, #1839 and #1840 (head 52e1805), which give EventMemory a store's shape with a handle per partition and keep its two stores consistent through failed ingests and forgets: their write path and deletion take the place of #1686's write transaction and deletion order, so #1686 keeps the rename alone, #1685's eviction takes their write path, and the chain's changes to EventMemory's data operations land on the partition handle. On 2026-10-26 onto #1840's head 5b16f04, after #1844's SQLite write transactions came in between #1627 and #1628, #1841's upsert packing onto #1628, and #1839 and #1840 were rebuilt on them: EventMemory takes its vector store's schema in its parameters, checks the reserved keys against it once and gives each partition handle the declared keys and the similarity metric, which a handle read from its vector store partition before; every session is served from the deployment's one EventMemory; and every event session's reranker, segmenter and deriver are the deployment's. On 2026-10-27 onto #1840's head 75982b9, after #1627 was squash-merged into feat/horizontal-scaling as 805ea21 and the PRs between were rebased onto it; the tree changed only in two Milvus tests' request timeouts. The same day onto #1840's head 94b3588, after #1628 and #1844 swapped places with Edwin's approval, #1628 now directly on feat/horizontal-scaling; the tree did not change. The same day onto #1840's head cff96f2, after #1628's review commits removed the supported filter nodes and UnsupportedFilterError, folded declared_properties.py into utils.py and renamed sql_columns.py to sqlite_properties.py; nothing in this chain uses them. The same day onto #1840's head b631cd8, after #1628's second review round moved is_valid_identifier into the vector store's data_types; #1684 and #1687 import it from there. On 2026-10-28 onto #1840's head f4b4870, after #1628 took a docstring and dropped a flag that did nothing; the tree here did not change otherwise. On 2026-10-30 onto #1840's head 004e6c7, after #1831, #1628 and #1858 were squash-merged into feat/horizontal-scaling and #1844, #1841, #1839 and #1840 were rebased onto it. On 2026-11-02 onto #1840's head 96097c2, after #1844 was squash-merged, with the rename regenerated so that the event memory store carries #1840's SQLite 3.35 check and the shared validate_engine_connections. The same day onto #1840's head cda5fac, after Edwin dropped #1841's size-based upsert packing: its cancellation contract is now #1840's first commit, and #1839 sits on feat/horizontal-scaling. Then onto the key rule: Edwin moved #1828 below #1684 and set it parallel to #1839 on feat/horizontal-scaling, so the chain sits on a copy of #1828 rebuilt on #1840 (head 4fe221b, on #1840 a82fc6e and #1839 8319ec6). #1684's key-rule part moved into #1828, the filter syntax is left to the filter language and the API, and #1690 keeps an event's session as its own application property. #1839's search is query, LongTermMemory takes the session's memory or partition directly, and EventMemory takes its schema from the vector store.

Event memory, on #1828's key rule rebuilt on #1840 (on #1839):

# PR Base Content
1/6 #1684 #1828 (copy on #1840) time bounds, sources and expansion (port of #1597 without session)
2/6 #1686 #1684 the rename to the event memory store (port of #1659's rename)
3/6 #1685 #1686 eviction at ingest (port of #1617)
4/6 #1687 #1685 the block kind column, parameter and record key; context parts; kind-keyed tables (port of #1611)
5/6 #1692 #1687 the capture block kinds: tool_call, tool_result, injected, thinking
6/6 #1688 #1692 segment id markers in rendering, lines in the store's order (port of #1632 without session blocks)

Off the line, on #1692: #1715, sessions in the event memory: the session column, index and walk, session_ids, deferred; nothing above depends on it.

Coding agents, slices of design/coding_agent_integration.md (#1579), on the event memory stack; 3/3 shares no code with the server, so its branch is on main, but it configures the endpoint 2/3 serves and writes the kinds #1692 registers, so it merges after both:

# PR Base Content
1/3 #1690 (this PR) #1688 the v1 event-memory API: tenants, query, expand, events
2/3 #1691 #1690 memory_query and memory_expand served at /v1/mcp
3/3 #1693 main Claude Code and Codex in the client: the MCP entry, the Stop hook, and capture

🤖 Generated with Claude Code

https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE

@edwinyyyu
edwinyyyu marked this pull request as draft September 17, 2026 20:57
@edwinyyyu edwinyyyu changed the title Serve the v1 event-memory API: tenants, search, expand, events [coding agents 1/4] Serve the v1 event-memory API: tenants, search, expand, events Sep 17, 2026
@edwinyyyu edwinyyyu changed the title [coding agents 1/4] Serve the v1 event-memory API: tenants, search, expand, events [coding agents 1/4] Serve the v1 event-memory API: tenants, query, expand, events Sep 17, 2026
@edwinyyyu
edwinyyyu marked this pull request as ready for review September 17, 2026 21:13
@edwinyyyu
edwinyyyu force-pushed the feat/event-memory-v1-api-main branch from 5e3d1b8 to 27ba848 Compare September 17, 2026 21:48
@edwinyyyu edwinyyyu changed the title [coding agents 1/4] Serve the v1 event-memory API: tenants, query, expand, events [coding agents 1/5] Serve the v1 event-memory API: tenants, query, expand, events Sep 17, 2026

@edwinyyyu edwinyyyu left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. The 409 event_exists promise (line comment) has no path to be kept by the PRs named.
  2. events/delete returns None at 200, which FastAPI serializes as the JSON literal null, and the OpenAPI 200 response advertises application/json; the description says "no body". 204 would make that true.

An event's `id` is the client's dedupe key: a client that retries a
batch supplies the ids it used, and a batch without them is stored
again. A tenant holds an event once: a batch naming an id the tenant
already holds is rejected whole with `event_exists`, and nothing in it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ErrorCode (errors.py) is the closed set tenant_not_found | component_not_enabled | invalid_request | internal, and #1686's EventMemoryStoreEventAlreadyStoredError is a plain Exception, so once #1686 is absorbed a repeated id is answered 500 internal by the except Exception arm, not 409 event_exists. Either the code and the mapping land here (or with #1686's absorption), or this text and the PR body should say 500.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This describes the branch before its last commit, "Answer a batch naming a held event with 409 event_exists" (7c43319 after today's rebase): it adds event_exists to ErrorCode, maps EventMemoryStoreEventAlreadyStoredError onto 409 in the envelope route, and tests the route. The body now names the commit.

@edwinyyyu edwinyyyu changed the title [coding agents 1/5] Serve the v1 event-memory API: tenants, query, expand, events [coding agents 1/3] Serve the v1 event-memory API: tenants, query, expand, events Sep 17, 2026
@edwinyyyu
edwinyyyu force-pushed the feat/event-memory-v1-api-main branch 2 times, most recently from 7c43319 to 18b71fb Compare September 18, 2026 00:08
@edwinyyyu
edwinyyyu marked this pull request as draft September 18, 2026 16:45
@edwinyyyu
edwinyyyu force-pushed the feat/event-memory-v1-api-main branch from 18b71fb to 2dad8e8 Compare September 18, 2026 19:50
@edwinyyyu
edwinyyyu force-pushed the feat/event-memory-v1-api-main branch from 2dad8e8 to 3fd3250 Compare September 28, 2026 18:02
@edwinyyyu
edwinyyyu force-pushed the feat/event-memory-v1-api-main branch from 3fd3250 to cdfa711 Compare October 2, 2026 00:18
@edwinyyyu edwinyyyu added the poc Proof-of-concept implementation for a solution, feature, idea, etc. label Oct 2, 2026
@edwinyyyu
edwinyyyu force-pushed the feat/event-memory-v1-api-main branch 2 times, most recently from 2cd4fda to effd7ac Compare October 7, 2026 20:11
@edwinyyyu
edwinyyyu changed the base branch from main to feat/horizontal-scaling October 9, 2026 00:51
@edwinyyyu
edwinyyyu force-pushed the feat/event-memory-v1-api-main branch 2 times, most recently from fb9a8a0 to c09fb68 Compare October 9, 2026 17:56
edwinyyyu and others added 2 commits November 3, 2026 09:22
The metadata field's description gives the key rule in words instead
of a pattern. EventMemory's filter check and the adapter's conversion
state what holds for a reserved key, and `MemMachine.add_episodes`
says its refusal comes before any store is written.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
EventMemory and the segment stores spelled out how a client names a
property in a filter, and EventMemory checked a filter's fields against
that spelling before it searched. The spelling belongs to the filter
language and to the API that accepts a client's filter.

EventMemory no longer checks a filter's fields: the long-term memory
adapter checks a client's filter, and the segment stores refuse a field
that names neither a property nor the timestamp. For the vector search,
EventMemory translates the timestamp to its reserved key and a property
to its key, and passes any other name unchanged. The docstrings describe
a filter over the segments' properties and timestamp.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@edwinyyyu
edwinyyyu force-pushed the feat/event-memory-v1-api-main branch 2 times, most recently from 850fc27 to ea55191 Compare November 3, 2026 17:40
edwinyyyu and others added 24 commits November 3, 2026 09:57
The docstrings of EventMemory's query and filter translation, the
segment stores' filter, and the adapter's filter mapping named the
timestamp as a special field. They return to their wording on the
base, changed only where it no longer holds: an event memory base
property translates to its reserved key, and a segment store refuses
an unknown field.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…hine#1597, without session)

The part of MemMachine#1597 that is not session: events and segments carry a
nullable source id; query and expand take since/until and source_ids;
expand walks outward from a seed, which is an address located whatever
the filters say, and returns a Neighborhood that excludes it; query
answers QueryHits (score, seed, neighborhood); rendering is
render_segments with a DateTimeFormat; the segment store answers
get_segments and get_segment_neighborhoods in place of
get_segment_contexts; the v2 adapter lifts timestamp/created_at bounds
and producer_id conjuncts out of the filter into the typed parameters;
the timestamp column holds a UTC instant.

On this base:
- A neighborhood is MemMachine#1713's walk: the partition's order next to the
  seed, filters applied inside its window of 1,000 segments per side.
  The ordering index keeps main's shape.
- The vector stage gets MemMachine#1684's predicates on the reserved keys and,
  joined with AND, the part of the property filter that names declared
  fields alone, as MemMachine#1702's `extract_filter` routing has it; the segment
  store still gets the whole filter, and its seed lookup leaves out a
  seed the filter rejects. A record's declared properties are its
  event's, which every segment carries unchanged, as the segmenter
  contract now states.
- On the event memory recovery PRs (MemMachine#1839, MemMachine#1840), the data operations
  and expand are EventMemoryPartition's, and rerank and render_segments
  are static on EventMemory; the reranker leaves EventMemory's
  parameters, since LongTermMemory reranks with the deployment's. A
  pending derivatives record holds its event's properties once
  (segment_store_pd), and its derivatives their source id and no
  context (segment_store_pd_dv), following Derivative, so encode and
  the background work build a record from the same properties. The
  search's cleanup of unlinked records stays a helper of its own.
- On MemMachine#1828's key rule, the source is EventMemory's own reserved key,
  `memmachine_em_source`, which `source_ids` reaches. A timestamp
  condition in the filter still reaches the vector search under its
  reserved key, beside the since/until predicates.
- Episode uids are UUIDs since MemMachine#1707: the search path reads each hit's
  episode uid back as a UUID, and the tests key their episodes by
  `_uid(name)` as main's do.
- Session and block kind are split out: the block kind column,
  parameter and record key go to the kinds PR, which is their first
  consumer, and session goes to its own PR.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
Mechanical: the store now holds events, segments, links, pending
derivatives and tombstones, so its name follows its dependent.
`SegmentStore` and every derived name become `EventMemoryStore` and
theirs, the module and test directories move with them, the tables and
indexes take the `event_memory_store_` prefix, and the configuration key
`segment_store` becomes `event_memory_store` in the API spec, the client,
the docs, the sample configs, the Helm chart and the compose files. No
behavior changes; tables are recreated. Applied by the same
substitutions to this tree rather than rebased.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
Ported from the agentic_expansion branch, where it was cosine only.
EventMemory takes an optional EvictionOptions: a score threshold, by
the vector store partition's similarity metric, at or better than which
eviction is considered, the number of stored derivatives at or better
than it fetched per new derivative, and how many of a new derivative
and those stored ones to keep when there are more. None keeps every
derivative and issues no query.

Every metric has one rule: a derivative is a near-duplicate when its
score is at least as good as the threshold, in the metric's direction
and units. The batch scores its own pairs by the partition's metric
(cosine similarity or dot product, Euclidean or Manhattan distance), so
the batch's decision and the store's neighbor query agree. Under dot
product over vectors that are not normalized, a vector's length shifts
what a fixed threshold means; no metric is refused.

At encode, after embedding, `_decide_eviction` settles the whole
decision before anything is written: the batch's predecessors of each
derivative are computed from the batch's own embeddings, earlier
indices only, so a batch evicts what serial ingestion would; one
batched neighbor query fetches the stored derivatives at or better
than the threshold; the stored ones' timestamps come from their segments
through the store's lookup, since the vector store answers uuids and
scores only, and a neighbor whose segment is gone is not a member;
then, per derivative, the members over target_size are trimmed from
the temporal middle, the earliest target_size // 2 and the latest
remainder kept. The batch is sorted by timestamp first, so the
predecessor rule matches serial order.

On the event memory recovery write path (MemMachine#1840), the decision only
changes what the encode writes. A skipped batch derivative is left out
of its event's pending derivatives record, so it is neither linked nor
written, by the encode or by the background work. A displaced stored
derivative keeps its link and its segment; its record leaves the
vector store after the batch's records are written and its pending
records deleted, best effort, and a delete that fails leaves the record
searchable until a later eviction or a forget removes it. The store
gains no operation for it.

Tests: the branch's eviction tests on the partition handle, a batch
and serial ingestion trimming alike under every metric, and a later
batch displacing stored records while their links stay.

The surviving records carry their event's declared properties, as every
record does, and the eviction's neighbor query and deletes go to the
vector store partition.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
…ives

An ingest decides eviction before its transaction and carries it out
after it: skipped derivatives are never linked or written, and displaced
records are deleted after the pending derivatives. A stranded ingest's
derivatives were written by the background work as they were, with no
eviction, so a cluster could grow past its target through recovery.

The background work now decides eviction for the derivatives it writes,
against what is stored then, through the same decision the ingest uses,
which moves to module level so both share it. A record the stranded
write may have stored is the derivative itself, so the claim's own
derivatives are not counted as neighbors, and the neighbor query asks
for that many more so `search_limit` others still fit. Skipped
derivatives are not written. Once the claim is finished, their records,
which the stranded write may have stored, are deleted with the
displaced ones, best effort, as the ingest deletes its displaced
records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Moved here from MemMachine#1684's port, where every block was text and the column
would have held one value with nothing dispatching on it; this PR's
kind-keyed segmenter and deriver tables are its first consumer. The
segment row carries its block's kind in a column of its own, filled by
the store from the block, since the codec's bytes are opaque to SQL;
query, expand, get_segments and get_segment_neighborhoods take
block_kinds; and the vector record carries the kind under its reserved
key, which the vector stage filters with an In predicate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
…s (speedkick)

The second half of the event memory handoff, on top of the
source/expansion/eviction change: the content model and the processing
steps become registered families keyed by kind.

Blocks. `Block` is an ABC with `kind` as the discriminator every
registered family uses and `render(options) -> str | None`; the union
stays closed over `TextBlock`; the segment row's `block_kind` reads
`block.kind`.

Context. `Context = Mapping[str, ContextPart]`, keyed by part kind,
never None; `Author` is the first registered kind, an unregistered
kind decodes to `UnknownPart` and round-trips unchanged; `with_part` composes one. `ProducerContext`, `NullContext` and the
discriminated union go; the codec encodes a context as `{kind:
fields}`. The server puts the producer id in an `Author` part beside
`source_id`, so rendering keeps its `producer: text` shape.

Segmenter and deriver. `Segmenter` and `Deriver` are tables from block
kind to handler, built from handlers in order with a later handler
replacing an earlier one for its kind. `BlockSegmenter[B]` and `BlockDeriver[B]` are the one-kind
handler contracts, typed by the block class: `split(event, block) ->
list[Piece]` and `derive(segment, block) -> list[str]`. The table
builds every envelope; a handler decides pieces or texts and nothing
else. A kind with no handler passes through as one segment and derives
nothing; nothing raises on a kind. `TextSegmenter`, `WholeTextDeriver`
and `SentenceTextDeriver` keep their names as `text` handlers;
`PassthroughSegmenter` and its configuration name go: one segment per
block is the identity segmentation, and a default gets no name, so an
omitted `segmenter` means it.

Composition. A derivative is text (`Derivative.text`, the text to
embed, plus the segment's `block_kind` for the record), so every
derivative gets the same context processing. `format_header` is the
one composition point for the embedded text and the rendered header:
the timestamp, then the context parts `parts` names in that order
(default `("author",)`), then the content. Parts carry no order; a part
not listed contributes nothing; a new kind is placed by listing it.
`parts` is a parameter of the composers, the handler and `render`,
not a field of `DatetimeFormat`, which writes a timestamp and nothing
else. A handler owns the composition of what it embeds, its
`datetime_format` and `parts`, so a kind or a handler can embed under
its own; the Deriver table composes each derivative's text with them;
rendering for display takes the caller's per call.

Tests: table dispatch, override order, envelope copying; text handlers returning bare
content; composition order, unlisted parts, kind-name validation, and
the anchor over a derivative; context and block round-trips including
an unregistered part kind.

On this base events carry no session, so no segment, derivative or
record copies one; the test harness builds records on the vector store
partition; main's splitting-segmenter test compares the default
segmenter with a text handler; and the event sample configuration main
added documents the text handler as the other sample configurations do.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
RegisteredBlock as the field type left three type errors where a
handler's or the codec's Block was assigned to the field. The fields
are typed Block, decoded through the registered union by a
before-validator and serialized as the concrete kind; the handler
tests keep a typed block in hand instead of narrowing the field.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
A context was a mapping the caller keyed by hand, and the rule that
the key equals the part's kind lived in prose. Context is a class now:
built from parts, read by kind, so there is no key to get wrong; two
parts of one kind are rejected at construction. It owns its wire form
through a Pydantic core schema, so a model field of the type accepts
an instance or `{kind: fields}` and serializes to `{kind: fields}`,
and the per-model validators, `with_part`, `encode_context` and
`decode_context` go.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
The kind-keyed tables removed its config, its service-locator branch
and its test, and the module survived with no consumer. The long-term
memory's over-fetch comment named it too; it now says why a segmenter
can carry one episode several times. The expand_context comment main
added names it as well; it now says "no segmenter handler".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
A coding agent's transcript carries more than messages: the tool calls
the agent makes, what those tools return, and text that entered the
conversation without a user typing it. Each is a block kind of its own,
registered so the v1 events route accepts it and the timeline holds it.

`tool_call` carries the tool's name and its input. The input is a JSON
object rather than a mapping of property values: a tool's arguments
nest, and a block is content the event carries, not something the event
is filtered by. `tool_result` carries the tool's name, its output, and
whether the tool failed instead of returning; a result says nothing
about failure unless it says so. `injected` carries the text and what
put it there: a hook, a skill, a compaction, a reminder, a command, or
something else.

Each kind renders on one line, through the same `render` a timeline
reader already gets for a message: a call's input as compact JSON, a
failed result behind an `[error]` marker, injected text behind its
source. A window mixing messages and tool events reads as one timeline.

None of the three declares a segmenter or a deriver, so the tables'
fallbacks decide: one segment per block, however long, and no
derivative and no vector record. A tool event is on the timeline
and off the search surface, reached by expanding from a
message, which is the capture policy the coding-agent integration
settled on.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
The kinds' round trip through the codec, their encoded form, and what
each renders: a call as its tool and its input on one line, a result as
its tool and its output with an `[error]` marker when it failed, and
injected text as its source and its text. An empty or overlong tool
name, an unlisted injection source and an unregistered kind are
rejected.

Segmentation and derivation are asserted where the tables decide them:
a long tool result is one segment while the text handler splits the
message beside it, and the text deriver derives nothing from any of the
three. End to end, an event carrying a message, a call and a result is
three segments and one vector record, the query reaches the message,
and expanding from it returns the two tool events.

The route's tests go with the v1 route, which sits above this change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
Capture holds everything the session log holds, never what a model
selected, so the agent's reasoning is a kind of its own rather than
something dropped on the way in: `{"kind": "thinking", "text": ...}`,
holding the reasoning as the transcript recorded it.

It is treated as the tool events are. It declares no segmenter and no
deriver, so reasoning is one segment however long and yields no
derivative and no vector record: it is on the timeline and off the
search surface, reached by expanding from a message. It renders as
`thinking: <text>`, one line of the same timeline a message and a tool
event render into.

The v1 route, which sits above this change, accepts it through the
registered union and names it with the other kinds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
`render` takes any segments, drops one given twice, and lays them out in
the store's order, one line per run of adjacent pieces of one event.
Nothing in the text says whether two lines are adjacent in the store,
since a filtered walk can omit a neighbor and no segment carries a
position.

`ids` marks what a reader can name back: `"segment"` starts each line
with `[segment:<hex>]`, or `[segments:<first>..<last>]` when the line
holds more than one, so the marker says which id opens the event and
which closes it without a word of prompting, and a one-segment event
carries one id. Uuids are 32 hex digits. With no ids, the text of
store-ordered segments without duplicates is what `render` produced
before, which `rerank` relies on.

Port of MemMachine#1632 without its session blocks: the event memory on this
chain has no sessions (MemMachine#1715 holds them), so a caller that groups
segments by a session of its own does that itself, as the v1 API does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
A caller that speaks events rather than episodes needs the handle on
the session's event memory partition that LongTermMemory opened and the
reranker it scores with; both were private, so the only way in was the
episode translation the event API has no use for. The two accessors
state what holds: the partition is None on the declarative backend,
and the reranker is None when the deployment has none configured.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
The v1 API addresses a tenant by an opaque name and never sees a
lifecycle, so the routes ask one seam, TenantEventMemories, for the
tenant's partition of the event memory, its reranker and the defaults
a request may omit.
What a name resolves to is the deployment's concern: replacing the
implementation moves every tenant to another substrate without touching
a route, which is what the tenant registry will do.

The default implementation gives each tenant one episodic-memory session
keyed `v1/tenants/<name>`, configured with the event backend and without
short-term memory. A v2 session key is an organization id and a project
id joined by a slash, and neither id may hold one, so a v2 key carries
exactly one slash and a v1 key carries at least two: no tenant name,
whatever it contains, addresses a v2 project, and the partition keys the
event memory store derives differ with them.

Creation is idempotent and materializes the partition and the collection,
so a deployment that cannot serve the tenant says so at creation rather
than at the tenant's first search. A session whose configuration names no
event memory this deployment can build is reported as the component not
being enabled, which is what it is from a client's side.

Defaults live in one place, EpisodicMemoryDefaults, because the episodic
memory settings carry no limit, expansion or rerank width to take them
from.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
Seven routes under /v1, beside the v2 router and changing nothing about
it: a tenant's creation, reading and deletion, search and expansion over
the tenant's episodic memory, and the ingestion and removal of events.
Search runs the vector query, reranks with the tenant's reranker when the
request asks and it has one, cuts to the limit, and renders each hit's
window with the session and segment markers a further request continues
from; expansion walks out from a segment uuid and renders each side the
same way.

Failures answer `{"error": {"code", "message"}}` under the design's
closed set of codes, mapped by the router's own route class so no other
router's errors change shape. A failure with no code of its own is
`internal`, whose traceback is logged and never answered with.

Three departures this deployment forces, each stated in the route
descriptions so a client written now keeps working when they lift: a
segment carries no ingestion position, because there is no event store;
ingest is synchronous, so `wait` is accepted and ignored and a repeated
event id stores a second copy rather than being rejected; and `filter` is
an expression in the server's filter grammar, which is the only filter
this tree parses. Naming a reranker is rejected rather than ignored: the
tenant's own reranker is the one the server scores with.

The event memory under this API holds no sessions, so the API keeps the
session id it takes on each event as its own application property,
`_session`. A caller's key must be a legal user property key, by MemMachine#1828's
key rule, and may not begin with an underscore, which marks the fields
this API writes; a filter names the session as `session`. A segment's
`session_id` is read back from that property, and a response's
`properties` leave the underscore keys out; `session_ids` on a query
narrows the filter to those sessions, applied to the segments the vector
search returns; and a rendered window keeps the timeline's order, a
`[session:"<id>"]` line read from that property heading each stretch of
one session's events.

The event memory scores by its vector store partition's similarity
metric, which the partition handle reports, and the API keeps a cosine
request and a higher-is-better response for every metric:
`min_cosine_similarity` is passed through as the score threshold on a
tenant that scores by cosine similarity and refused with 422 by any
other, and without a reranker a distance is negated on the way out, so
a hit's score stays higher-is-better and the hits stay in descending
score.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
The routes are driven through the test client against a resolver whose
event memory is the real one over the in-memory event memory store and
vector collection, so a test exercises the rendering, the filters and
the reranking rather than a mock's return value. Ordering is asserted
only under the angle embedder, since the plain fake embedder ties every
score.

Covered: each route, the error mapping including the traceback that never
leaves the process, the id markers and the seed's place in a hit's
window, the session kept as a property a caller cannot write, a
caller's key under the user key rule and a reserved key in a filter
refused, the walk
that crosses sessions in timeline order unless the filter names one,
reranking on and off with its score floor, a higher-is-better score
under every similarity metric, `min_cosine_similarity` and its refusal
by a tenant that scores otherwise, and the defaults filling in the
counts a request omits.

The default resolver is tested against a mocked MemMachine: the session
it configures, creation that finds the tenant already there, deletion of
both the memory and the row, and every resolution failure. Its namespace
is checked by the shape of the key it builds, which no v2 session key can
have.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
The document is what the clients are generated from and what the API
reference renders, so the v1 routes belong in it with the descriptions
that state where this deployment departs from the API a client will
eventually meet. The generator loads the v1 router beside the v2 one,
names its three tags, and strips FastAPI's generated suffix from v1
operation ids as it already does for v2, so a generated client calls
`search_episodic_memory` rather than the path spelled out. No v2 path,
schema or operation id changes.

On this base the capture kinds are registered below the v1 route, so
the events route accepts them from the start and the document carries
their schemas in EventSpec's block union; regenerated with the tool.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
`EventMemory.query` is the verb the memory uses, so the API uses it too:
the route is `POST /v1/tenants/{tenant}/episodic-memory/query`, its
function and operation id are `query_episodic_memory`, and the bodies
are `QueryRequest`, `QueryHitBody` and `QueryResponse`. The hit body
takes the `Body` suffix that `SegmentBody` and `TenantBody` carry,
because the memory's own `QueryHit` keeps its name and the two are used
side by side in the router.

The tenant's default is `query_limit`, and every docstring, description,
tag description, test name and comment that called the operation a
search now calls it a query. "Vector search" stays where it names the
vector store's technique rather than the operation: the stage that runs
before the reranker, and `vector_search_limit`, the parameter the memory
takes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
The event memory store holds an event once, so `encode_events` raises
`EventMemoryStoreEventAlreadyStoredError` when a batch names an event
the tenant already holds, and the batch stores nothing. The v1 error
contract gains the code `event_exists` for that, and the route class
answers the error with 409 and a message naming the uuids, so a client
retrying a batch learns which ids it may not reuse and that the rest
of the batch was not stored either.

The add-events description says so: a repeated id is a rejection of
the whole batch, not a second copy. The route declares the 409 and the
generated OpenAPI document carries both, and a router test reingests a
held id beside a fresh event and finds neither stored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
`EventSpec.blocks` decodes through the registered union, so the route
accepts a thinking block, a tool call, a tool result and injected text
with no change to its code, and a kind no registration names stays a
validation failure, answered 422 `invalid_request` like any other
malformed body.

What the route says about the field changes: the description names the
five kinds and which of them a query matches, so a client reading the
document knows that a message is the search surface and everything
else is reached by expanding from one. The kinds' schemas are in
docs/openapi.json since the route arrived; only the description
changes here, regenerated with docs/tools/generate_openapi.py.

The route side of the capture kinds, which register in the event
memory below the route.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
A batch of one message event and four capture events (thinking, a tool
call, a tool result, injected text) is stored, only the message is a
hit, and expanding from it renders the four on their own lines; a
block missing a field its kind requires is 422 `invalid_request`
naming the field.

The route tests of the capture kinds, which register in the event
memory below the route; their codec, segmenter, deriver and memory
tests stay with the kinds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

poc Proof-of-concept implementation for a solution, feature, idea, etc.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant