Context and Memory Workspace - Technical
README · ARCHITECTURE · TECHNICAL · source
Five decisions that shaped the workspace: keeping chat responsive, choosing where memory lives, and turning long conversations into something I can find again.
1. Derived memory stays off the request path
Summarization, embedding, knowledge-graph writes and attractor updates are all model calls or multi-step database work. Doing any of them inline means a user waiting for a chat response is also waiting for the system to think about its own memory.
In this version, the request path does four things: authenticate, persist, stream, and enqueue. A queue consumer handles the derived work afterward, and scheduled jobs can add work when nobody is using the system.
The queue cases include conversation summaries, attractor and graph updates, embeddings, wiki snapshots, and housekeeping. None of those tasks runs synchronously from a route handler in the source snapshot described here.
Memory is eventually consistent with the conversation. A summary can be minutes behind the messages it describes, and the attractor can be a cycle behind that. For a system whose job is carrying context between sessions this is acceptable; for one that needed within-session recall it would not be.
There is no visible job status yet. If summarization fails, the symptom I see is a conversation that never gains a summary. That makes a useful status view the next obvious improvement.
2. The attractor lives in KV, not D1
The attractor state is read on nearly every conversation start and rewritten wholesale after each update. It is one document, not a set of rows.
Two KV keys: attractor:state and attractor:history, the
latter capped at ten snapshots. Reads parse JSON and return null on any failure
rather than throwing.
A hot single document with no partial updates is what KV is for. Modelling basins as D1 rows would mean a join and a transaction on every read to reconstruct a document that is always read whole.
KV is eventually consistent. A state written by a queue job is not guaranteed to be visible to the next read immediately.
A detail for repeatable comparisons. That eventual consistency is why any evaluation of the attractor must use a frozen state with an explicit version record rather than reading live — recorded in the study plan. The ten-snapshot history cap is also already limiting: it is enough to compute a trajectory and not enough to study one.
3. Summarization is split into a selector and a worker
A single "summarize everything that needs it" job either times out or does too little, and a failure loses the whole batch.
summarize_batch runs on the six-hourly cron, selects up to 20
conversations with messages newer than their last summary and at least four
messages total, and enqueues one summarize_conversation job per conversation.
Splitting "decide what to do" from "do it" keeps every job small enough to finish inside one worker invocation, and makes a retry cost one conversation instead of twenty.
Two queue hops instead of one, and the batch size is a fixed number rather than adaptive. A corpus with more than 20 stale conversations takes several cron ticks to catch up.
The >= 4 message floor is a starting threshold I have not compared yet. It
avoids paying for a summary of a two-message conversation; a content-length
measure may turn out to be a better rule.
4. Conversations are embedded as their summaries, not their transcripts
Semantic search over raw transcripts returned conversations that shared vocabulary rather than subject matter. A transcript is mostly turn-taking.
prepareConversationText composes the summary with the derived
vibes, concepts, and turning points into one string, which is what gets embedded.
Wiki pages, branches and themes each get their own preparation function, and all
four content types share one Vectorize index with a content_type tag in the
metadata.
The summary gives search a shorter account of what the conversation was about. I found that easier to work with than matching the vocabulary of an entire transcript. This is a workflow observation, not a measured retrieval benchmark.
Search can only find what summarization captured. A detail mentioned once and not carried into the summary is unreachable by semantic search, though it remains findable by keyword search over messages.
bge-base-en-v1.5 caps input at 512 tokens, so MAX_INPUT_CHARS
is 1800 and long summaries are truncated rather than chunked. Wiki pages are
chunked, to a maximum of three chunks; conversations are not. That asymmetry is not
principled. It is where I stopped for this version.
5. The knowledge graph grows from summarization, not from tagging
Keeping manual tags current became another task. I wanted the graph to grow from what I was actually discussing instead.
Summarization proposes branch names with relevance scores.
processBranchUpdate slugifies each name, finds or creates the branch, and links
the conversation to it. Every new branch enters in state seedling and is queued
for embedding so it is semantically searchable from the moment it exists.
Making recurrence the mechanism means the graph reflects what actually kept coming back rather than what was once declared a project.
The graph is only as stable as the model's naming. Slugification
merges Cloudflare Workers and cloudflare workers, but not Cloudflare Workers
and Workers infrastructure — those become two branches for one thing. This is the
same near-synonym problem Attractor — Knowledge Graph solves with an explicit
consolidation pass, and the workspace does not have that pass. It is the
clearest borrowable fix between the two projects.
There is no merge or rename operation. Once the graph has two branches for one idea, nothing in the system can join them.