Repository navigation
Conversation
edwinyyyu
marked this pull request as draft
September 9, 2026 20:22
edwinyyyu
force-pushed
the
feat/event-memory-handoff-speedkick
branch
2 times, most recently
from
September 10, 2026 19:20
4aa701e to
8ed2bfc
Compare
edwinyyyu
force-pushed
the
feat/event-memory-handoff-speedkick
branch
from
September 10, 2026 20:00
8ed2bfc to
a699bae
Compare
edwinyyyu
added a commit
to edwinyyyu/MemMachine
that referenced
this pull request
Sep 10, 2026
…pes from the table Refines the segmenter and deriver tables to the shape MemMachine#1597 implements: `BlockSegmenter[B]` and `BlockDeriver[B]` take exactly one kind, typed by the block class, since the table has already dispatched and a multi-kind handler would only re-narrow and grow back the raise it removes; handlers return `Piece`s and blocks and the table builds every segment and derivative envelope, because each envelope field is the event's or the segment's by contract. The text handlers keep their names; `PassthroughSegmenter` goes, the passthrough being the table's fallback. The handoff moves the tables from "Not in this change" into their own section. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
edwinyyyu
force-pushed
the
feat/event-memory-handoff-speedkick
branch
2 times, most recently
from
September 10, 2026 22:15
d88d098 to
554d234
Compare
edwinyyyu
force-pushed
the
feat/event-memory-handoff-speedkick
branch
11 times, most recently
from
September 10, 2026 23:57
e787466 to
624a376
Compare
edwinyyyu
force-pushed
the
feat/event-memory-handoff-speedkick
branch
2 times, most recently
from
September 11, 2026 17:36
c7d8754 to
2f1ae2d
Compare
edwinyyyu
commented
Sep 11, 2026
edwinyyyu
commented
Sep 11, 2026
edwinyyyu
commented
Sep 11, 2026
edwinyyyu
commented
Sep 11, 2026
edwinyyyu
commented
Sep 11, 2026
edwinyyyu
commented
Sep 11, 2026
edwinyyyu
commented
Sep 11, 2026
This was referenced Sep 16, 2026
edwinyyyu
added a commit
to edwinyyyu/MemMachine
that referenced
this pull request
Sep 16, 2026
Mechanical: the store now holds events, segments and links, 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 and the sample configs. No behavior changes; tables are recreated, as on MemMachine#1597. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
This was referenced Sep 17, 2026
Contributor
Author
edwinyyyu
commented
Sep 17, 2026
| return _conjoin(clauses) | ||
|
|
||
|
|
||
| # The context part kinds rendering prints, in the order they are printed. |
Contributor
Author
There was a problem hiding this comment.
Orphan comment: nothing under it is a list of context-part kinds. That model (Context built from parts, parts=("author",)) is #1611's; this line is left over from the first commit of the handoff branch.
Contributor
Author
edwinyyyu
added a commit
to edwinyyyu/MemMachine
that referenced
this pull request
Sep 18, 2026
…1597) Port of MemMachine#1597 to main: the tree of feat/event-memory-handoff-speedkick at 80f0c71 applied onto port/chain-main4 at e7e4d54, the commit that closes MemMachine#1606's port. The one adaptation is in LongTermMemory's construction of EventMemoryParams: main's EventBackendParams carries no metrics factory (MemMachine#1523 is not ported) and MemMachine#1597 removed the reranker from the memory, so neither is passed. MemMachine#1597 keeps its review history on speedkick; this commit is the same content, squashed. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
edwinyyyu
added a commit
to edwinyyyu/MemMachine
that referenced
this pull request
Sep 18, 2026
…1597) Port of MemMachine#1597 to main: the tree of feat/event-memory-handoff-speedkick at 80f0c71 applied onto port/chain-main4 at e7e4d54, the commit that closes MemMachine#1606's port. The one adaptation is in LongTermMemory's construction of EventMemoryParams: main's EventBackendParams carries no metrics factory (MemMachine#1523 is not ported) and MemMachine#1597 removed the reranker from the memory, so neither is passed. MemMachine#1597 keeps its review history on speedkick; this commit is the same content, squashed. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
This was referenced Sep 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the session/source/expansion part of
design/event_memory_handoff.md(branchdesign/tenant-lifecycle, PR #1579): the accepted parts of the server redesign that live inEventMemory,SegmentStoreand their data models. Eviction is split out into a second PR stacked on this one, and the context-part and block-kind model and the segmenter/deriver tables into a third stacked on that; here the producer/null context union and theblock_typediscriminator stay as they are. Nothing is renamed; nothing in the server is rewired beyond calling the new API.Sits directly on
speedkick(rebased 2026-09-14 onto a8322a7, after[vector store 1/13]#1606 and the SQLite store stack #1612, #1607-#1610; before that 2026-09-10 over #1598, #1596, #1604 and #1603, adopting #1598's post-review namessegment_by_derivative/seed_cosine_similarities). Independent of #1599. Split 2026-09-10: eviction is #1617, stacked on this PR, and the second half (context parts, block kinds, segmenter/deriver tables) is #1611, stacked on #1617. Stands in for[vector store 2/13]#1621, closed: this PR routes the filters and writes the record the way the vector store slices from 3 on assume, and those slices rebase onto it.What changes
Data models (
event_memory/data_types.py)Event,Segment,Derivativegainsession_id: str(required, non-empty) andsource_id: str | None, bounded toID_MAX_BYTESby the model and copied verbatim by the segmenter and the deriver; every field carries a description, positions are validated non-negative, and timestamps areAwareDatetime: a naive value is rejected on the models and on the typed boundssinceanduntil, since it names no instant and a guessed zone would silently shift an event in the order. The session is required because it is the unit the order is partitioned into: every event belongs to one stream. A null source is a missing record key, so every backend's "missing" implementsIS NULL. A typed id list (session_ids,source_ids) holds ids only; a list leftNoneadmits everything and an empty one nothing. Property values stayNone-free, so absence is the only no-value state.Derivativelosescontextandproperties: a deriver reads the segment's context while composing the text it embeds, and the memory reads a derivative's timestamp, session, source and block kind for the record; nothing read the copies. A derivative is the derived content and the fields a record carries.QueryHit(score, seed, neighborhood)replacesScoredSegmentContextandQueryResult: the segment the query matched and theNeighborhood(before, after)around it, the shapeexpandreturns, so walking further from a hit composes withexpand;window()is the flat list for rendering.Reserved keys (
common/property_keys.py,event_memory.py)property_keys.pyis taken as-is fromedwinyyyu/MemMachine@27b3279b.emfor brevity within the identifier budget:memmachine_em_timestamp,memmachine_em_session,memmachine_em_source,memmachine_em_block_kind.expected_vector_store_collection_schemadeclares the four, and a record carries the four and nothing else: the user's properties stay on the segment, whereproperty_filterreads them, so the collection schema a memory needs is exactly the four (the server's locator declares no more; the adapter's_episode_uid-style fields are user properties to the memory)._timestampis gone; a user key in the namespace is rejected at_validate_events(throughvalidate_user_property_key, so an illegal key now fails before any segment is written instead of at the vector upsert).system_predicates(...)builds the tree the vector store gets from the typed values. The inverse, recovering typed values from a tree a caller wrote, is not built: nothing calls it.Segment store
segment_store_sggainssession_id VARCHAR(255) NOT NULL,source_id VARCHAR(255) NULL,block_kind VARCHAR(255) NOT NULL(the type ofpartition_keyand of the vector stores' key columns, for every string column the store compares on: on PostgreSQL the same storage and index behavior astextplus the length check, on SQLite text affinity either way, and on MySQL, SQL Server and Oracle the only one of the two that can be an index key), projected from the segment at insert (block_kindfromsegment.block.kind, never a separate input).segment_store_sg__in_se_ts_ev_ix_of (incarnation, session_id, timestamp, event_uuid, index, offset)replaces the timestamp ordering index, since every walk pins a session. No index for a walk filtered by source or by kind: the store indexes the reads it has (decision 7). Every index is one a read chooses (measured below); none onsource_idalone or onblock_kind.(timestamp, event_uuid, index, offset). A walk is confined to the seed's session.get_segments(uuids, *, since, until, session_ids, source_ids, block_kinds, property_filter) -> dict[UUID, Segment]is a filtered lookup, andget_segment_neighborhoods(seed_uuids, *, before, after, since, until, source_ids, block_kinds, property_filter) -> dict[UUID, Neighborhood]walks the order around seeds named by uuid: a seed is located whether or not it passes any filter, the filters select the neighbors, and an unknown seed is absent. Filters select what a read returns: a search fetches its seeds with the filters and walks from the ones that pass.session_idsis the lookup's visibility filter and is not a walk parameter: a walk pins its seed's session, so a session filter on it could only repeat the confinement or empty the answer.sinceis inclusive,untilexclusive, both bound as UTC instants. An emptysource_ids/block_kindsadmits nothing.startup()keepscreate_all, so the new columns exist on a fresh database and an existingspeedkickdatabase is recreated, asdesign/segment_store_shared_tables.mdalready says. Schema migration is deferred until the lifecycle/DDL changes.EventMemory
FormatOptionsisDateTimeFormat: date and time styles, locale and zone, and nothing else, frozen (a value); every parameter that took it isdatetime_format, and the derivers default to one shared_DATE_ONLYinstance (a full date and no time) rather than taking None.DateTimeFormatandDateTimeStylespell a date and a time as two words, as CLDR spellsdateTime;datetime_formatspells them as one, as Python does. The segmenter's unused format parameter, a pass-through since the memory took one, is gone.EventMemoryParams:rerankerleaves, and so does the per-callformat_optionsonencode_events: the deriver owns the format of what it embeds (WholeTextDeriver(datetime_format)andSentenceTextDeriver(datetime_format), default a full date and no time, the shipped ingest recipe), since a deriver decides the text it embeds and one format per memory would assume every deriver wants the same one. Display rendering keeps taking the caller's options per call.encode_events(events): segment, derive, embed,add_segments,upsert. Every call adds; encoding an event a second time stores a second copy, as speedkick's encode does, and a follow-up rejects a reused event uuid at the store instead. The branch'sserialize_encodelock is not ported.query(query, *, vector_search_limit, min_cosine_similarity, expand_context, since, until, session_ids, source_ids, block_kinds, property_filter) -> list[QueryHit]: the vector stage only. Seeds are resolved throughget_segment_uuids_by_derivative_uuids, as on Answer with cosine scores and uuids, not vectors and stale properties (speedkick) #1598; a derivative whose segment is gone is skipped. The seeds are fetched with the filters throughget_segments, then walked from withget_segment_neighborhoods; no walk is asked for whenexpand_contextis zero.rerank(query, hits, *, reranker, datetime_format): static, the second stage for a caller with a reranker; every hit comes back rescored in descending score, and cutting and thresholding are the caller's.expand(seed_uuid, *, before, after, since, until, session_ids, source_ids, block_kinds, property_filter) -> Neighborhood, the filters a search takes: the seed is a segment uuid, walked from after a lookup withsession_idsalone when sessions are named, so a seed outside them is not found;LookupErrorfor an unknown seed. Expansion is by segment only; an event is never a seed.render_segments(segments, *, datetime_format)replacesstring_from_segment_context/string_from_segment_contexts, with_is_continuationdeciding headers (a new header when the piece is not the very next one of the same event; no gap marker).build_query_result_contextandstring_from_query_resultare gone.Server (
long_term_memory.py):_episode_to_eventsetssource_id = producer_id, writessession_id = DEFAULT_SESSION_ID(memmachine_default: the API carries no conversation id, so a partition's events are one stream under the one reserved session name, a stop-gap until the API requires a session; any other name is a caller's to use) and keeps the producer context as before;_search_scored_eventlifts the mapped fields out of the filter (decision 3), callsquerythenEventMemory.rerankwhen a reranker is configured, and readsQueryHits throughseedandwindow().Decisions the handoff left open, and deviations
Each of these is my call and can be reversed:
speedkickdatabases are recreated. Rows written before this change would not decode anyway (their discriminators werecontext_type/block_type), so an upgrade would have had to rewrite payloads as well as add columns. Repo-wide boot-path DDL policy stays with Schema provisioning runs from every process's boot path: create_all races on cold boot and never evolves a table, Alembic runs destructive migrations on first use #1570.ID_MAX_BYTES = 255boundssession_idandsource_id(the handoff said "the same limit as a property string value", and no such limit exists onspeedkick). A value that long fits the store's 255-character key columns, and the database enforces the bound the model does. Whenproperties.max_string_byteslands as a setting this should follow it.property_filteris the segment store's post-filter and never reaches the vector store, and user properties are never written there. The typed filters name the memory's own fields, which the record carries under reserved keys, so the vector store evaluates them during the search;property_filteris the caller's, over properties the vector store does not hold, applied by the segment store to the seeds and their neighbors. A vector record is the vector and the four reserved keys, so a store that declares what it indexes and refuses the rest ([vector store 9/13]) takes every record the memory writes. A selective filter returns fewer thanvector_search_limithits rather than costing more work. The server's legacy API carries the fields its ingestion maps onto the event in the filter tree, soLongTermMemorylifts every top-level conjunct on a mapped field back into the typed parameter before the call:timestampandcreated_atbounds intosinceanduntil,producer_id =andINintosource_ids(their intersection when several; empty admits nothing); other operators, and anything under a disjunction or negation, stay post-filters. The store-side field mapping (m.<key>to the properties JSON, baretimestampto the column, other bare names to_<field>) is kept, since the server still writes its fields that way.(incarnation, event_uuid), a lookup index;get_segment_uuids_by_event_uuidspromises no order, since nothing reads one.get_segment_neighborhoodswith zero counts still locates the seeds and returns two empty lists for each, so presence in the mapping means the seed exists.session_id, and the lateral pinssession_idto it, an equality the planner parameterizes per seed so the session-led index serves each; one statement per direction for every seed. An earlier version of this PR ran a statement pair per distinct seed session and cost a search with twenty seeds in twenty sessions 53 ms; a boundunnestrow set measured the same as the subquery and was dropped for it. A fresh PostgreSQL table misplans until its firstANALYZE, whatever the indexes: with no statistics the planner tied the primary key with(incarnation, event_uuid)for the link table's foreign-key check and scanned the partition per link (ingest quadratic in the rows imported inside that window: 129 to 1,249 ms per thousand over a 20,000-row import), and ran a sequential scan for the lookup by uuid (search-shaped read 29 ms against 7.9 afterANALYZE). Autovacuum analyzes a table within about a minute of its first rows and the cached plans are replaced (measured on one connection: 403 ms per batch before, 35 after); an initial bulk import should be followed byANALYZE, PostgreSQL's documented practice. Leading the indexes with another column, or dropping the constraint, only changes the planner's candidates and was not kept.CREATE INDEX CONCURRENTLY, migration-time)._normalize_column_valuefrom the branch is not ported: onspeedkickthe filter nodes already normalize datetimes at construction (theFilterExprcontract), and the typed bounds normalize in the store. The test that a zoned bound compares as an instant on SQLite covers the new path.get_neighbor_events,AnnotationContext/CompositeContext/find_contexts, the gap marker, theserialize_encodelock. The claude-memory engine's move of session and author into the two fields lives onagentic_expansion, not on this branch.Verification
uv run pytest packages/server/server_tests packages/common packages/client: 2229 passed, 2 skipped (the defaultnot integrationselection; SQLite).... pytest packages/server/server_tests/memmachine_server/episodic_memory/event_memory/segment_store -m "integration or not integration": 220 passed (SQLite and PostgreSQL via testcontainers).ty check --project packages/server,ruff check,ruff format --check: clean.Review rounds of 2026-09-11 to 2026-09-14, one commit per iteration on top of the original (field descriptions and model bounds, id lists without
None,rerankwithoutlimitandmin_score, the lookup-and-walk split andQueryHit, the required session,DateTimeFormat, the reserved keys, the post-filter and the record that carries only the reserved keys, the key column type): the suites rerun after each; the counts above are from the last.The store was benchmarked against
speedkick's (a8322a7) on the same data: 20,000 segments over 40 sessions and 8 sources, ingested in batches of 1,000 with one derivative link each; 20 seeds per search-shaped read with a 2+4 walk, one seed per expand-shaped read with a 5+5 walk, 60 timed calls after 10 warm ones, old and new alternating, two rounds; PostgreSQL on one testcontainer with the table analyzed after ingest, SQLite on a file. Medians per call, the two rounds' range:m.tag = 'a'm.src = 'p3'(JSON)source_ids=['p3'](typed)What the differences are: ingest and delete pay for a six-column walk index in place of a five-column one, plus the three new columns (the table's figures were taken with a second, source-pinned walk index that was dropped afterwards, so the ingest and delete differences are an upper bound). A search-shaped read runs one statement more than before, the walk's locate of seeds the lookup already fetched, the price of a walk keyed by uuid; a filtered one is faster because the filter selects the seeds before any walk, where the old store filtered inside every walk. A walk is confined to the seed's session, so with sessions interleaved in time its neighbors are rows 40 apart instead of adjacent ones, which is the JSON-filtered expand's difference (a session-led scan past the other sources versus adjacent rows); the typed source filter takes the source-pinned index and pays only the statements. The unfiltered PostgreSQL search was 53 ms before the one-statement walk and the fresh-table ingest 690 ms per batch before the index order, both in this PR's own history (decision 6).
A walk filtered by source or by kind scans past the session's other rows on the session-led index; measured on PostgreSQL with 200,000 segments, a source-pinned walk index turned a rare-source walk from 0.106 ms into 0.031 ms and left a common-source walk unchanged, and a walk naming several sources or none used the session-led index either way. A walk index is paid for on every insert and added later in a one-off background build (
CREATE INDEX CONCURRENTLY, 2.5 s per million rows here), so none is kept until a workload shows walks filtered by source or by kind.Each new store assertion was run against a mutated store (session predicate removed;
untilmade inclusive; neighborhoods seed filtered; bound left unnormalized) and failed there.🤖 Generated with Claude Code
https://claude.ai/code/session_01MuAu353FiSmCJjLX1LWDQW
https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE