Chat memory bridge: archive chat turns to the SQLite memory store - #8
Open
TimothyNguyen wants to merge 20 commits into
Open
Chat memory bridge: archive chat turns to the SQLite memory store#8TimothyNguyen wants to merge 20 commits into
TimothyNguyen wants to merge 20 commits into
Conversation
Chat rows need a workspace column and `memories` cannot grow one -- that table is a byte-level contract with the Go reader in internal/memory/store.go. So `chat_memories` is a separate table in the same file, which Go ignores. The store carries its own lock. `Store` relies on the REST layer's lock in server/app.py, but the threaded sink will write here from a worker thread that never holds it. Ids are derived from (workspace_id, turn_id) rather than random, so re-recording a turn replaces its row instead of racing the unique index. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
A chat turn that answered correctly must not be reported as failed because a secondary archive write did not land, so every failure here becomes a string in the response's errors list rather than an exception. Those strings reach the browser, so they carry only the exception class name. Detail goes to stderr -- the same split memory/embedder.py uses for a failed embedding, and the reason a raw sqlite message (which contains the database path) never leaves the process. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
ChatManager now takes an optional chat_memory_sink and records every persisted turn to it, on both the blocking and streaming paths. Thread and workspace deletions cascade to the sink so the archive cannot outlive the history it mirrors. The archive is best-effort: a sink that raises is caught, logged to stderr, and reported as a class-name-only string appended to the response's errors list. An answered turn is never turned into a failed request by a secondary write. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
The chat archive lives in SQLite, outside the FalkorDB graph, so delete_workspace's graph drop never reached it. A deleted workspace would vanish from the registry while its chat turns stayed searchable. WorkspaceManager now takes the same optional sink ChatManager does and cascades the delete. The cascade is guarded: a broken archive logs to stderr rather than blocking the workspace delete it follows. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
Archiving a turn embeds its text, which with an HTTP embedder is a network call carrying a 30-second timeout. Running that on the request thread of a chat turn that already has its answer delays the response for work the caller does not wait on. ThreadedChatMemorySink drains a single FIFO queue on one worker thread. FIFO is the ordering guarantee: a delete queued behind a write is applied after it, so a pending write can never resurrect a deleted thread. Deletes wait for their own item with a bounded timeout, so a stalled worker degrades into a late delete rather than a hung request. An overflowing queue drops its oldest pending write and says so. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
Three routes under /api/rag/workspaces/{id}/memory: list (optionally
narrowed to one thread), search, and delete. They join the existing RAG
workspace path table rather than the mux, so they inherit its shared
exception mapping.
Every branch passes the workspace id to the store, which scopes its own
SQL by it. An unknown workspace raises rather than reading as an empty
archive, and the routes answer 404 outright when no chat memory store
was injected.
Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
serve now opens a ChatMemoryStore over the same SQLite file the global memory store uses, wraps it in a threaded sink, and hands that one sink to both the chat manager and the workspace manager so archiving and its cascading deletes share a queue. The store is best-effort like the memory store beside it: if it cannot be opened, chat still answers and the memory routes simply 404. On shutdown the sink closes before the store it writes to, so queued writes land rather than hitting a closed connection. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
Both workspaces now share one chat memory database and one sink, the same way they already share one history backend. A row that was not scoped by workspace would surface in the other workspace's search here, where the per-layer suites would not see it. Also asserts the two new memory routes stay absent when GraphRAG is disabled. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
Adds the three client functions for the chat memory routes and a section on the Memory page that lists and searches them, with a workspace selector of its own since workspace selection in this app is component-local. The section renders nothing when the workspace list is unavailable, so a build with GraphRAG off looks exactly as it did. All requests go through the existing backend client; the browser never learns a graph or provider credential. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
The harness is what proves the composed system works against a real backend, so the chat archive needs a required stage of its own: a turn must land in SQLite scoped to its workspace, stay searchable there, and disappear when the workspace is deleted through WorkspaceManager. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
Records what landed: the chat_memories table beside the untouched Go contract, the sink layer and its threaded variant, the three scoped routes, the WorkspaceManager deletion correction, and the new harness stage. States plainly that recall is not implemented and that the live pass exercised the real server over HTTP rather than a browser DOM. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
A full queue previously discarded the oldest pending item to make room, which could drop a delete or reorder work so that a queued write outlived the delete meant to remove it. Writes stay expendable -- losing one loses a turn from the archive -- but a dropped delete leaves rows the caller asked to remove and releases that caller as though it had worked. Deletes now block on the queue for the sink's timeout instead of being dropped, and nothing already queued is discarded. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
Archive failures ride out on a chat response, and both buffers holding them were global: a store failure caused by one tenant's turn could be drained onto another tenant's response. drain_errors now takes a workspace id and returns only that workspace's messages, in both the sink and the ChatManager's own buffer for a sink that raises. The sink's buffer is also now lock-guarded, because the worker thread appends to it while request threads drain it. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
The DELETE route talked to the store directly while archive writes were still queued on the background sink. That delete stepped over the queue, and the pending write then reinserted rows the caller had just asked to remove -- a window as wide as an embedding call, which can be a 30s HTTP request when KB_CORE_UI_EMBED_URL is set. The route now goes through the sink when the server has one, so the delete queues behind those writes and inherits the sink's FIFO guarantee. The sink's delete methods return the row count so the response still reports one. A server with no sink has no queue and still uses the store. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
The graph-side delete has already happened by the time the chat memory sink is called, so a sink that raises would report a completed delete as a failure. Guard both delete paths the same way WorkspaceManager already guards its own call. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
ChatMemoryStore.get/remove and its context manager, and the sink's flush/pause/resume, existed only for tests. pause/resume in particular put a lock in the worker's hot path purely so a test could stall it; the tests now stall a real archive write instead, which is the stall the queue is actually sized for. make_chat_id/chat_source were module-public but are used only inside their own file, and @runtime_checkable bought nothing with no isinstance check anywhere. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
close() queued its stop sentinel with a blocking put, so a full queue held the caller until the worker drained a slot. Shutdown runs on the thread that owns the process, and an archive write can be a 30s HTTP call. Bound the put by the sink's own timeout instead; the worker is a daemon thread and goes away with the process either way. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
_record_memory took Any and _default_workspace_manager took a bare default, so nothing checked that a caller passed something the sink protocol actually satisfies. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
Clearing a thread swallowed its rejection, so a failed delete rendered exactly like a success, and a failed list rendered as an empty archive. Both now route to the error banner the rest of the page already uses. The section also had no load state, so it claimed nothing was archived during every fetch and the 300ms debounce ahead of it. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
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.
Stacked on #7 (
rag-chatbot-manager→master). Base israg-chatbot-manager, so the diff here is only the 14 commits of this branch.Summary
Chat turns now persist to both FalkorDB (as before) and the SQLite memory store, so an answered turn becomes a searchable archive entry alongside the graph history.
chat_memoriestable. The existingmemoriestable is a byte-level cross-language contract with Go'sinternal/memory/store.goand cannot gain a workspace column.chat_memorieslives in the samememory.dbwith its own workspace-scoped schema and indexes; Go ignores tables it does not know. No new code reads or writesmemories.ChatMemorySinkhas three implementations:NullChatMemorySink(what a server with no memory database injects, so call sites stay unconditional),SyncChatMemorySink(does the work, swallows store failures), andThreadedChatMemorySink(runs the sync sink on one worker thread). An archive write never blocks a chat response and never turns a successful turn into a failure — failures become strings on the frozenerrorslist and are drained once. Onlyexc.__class__.__name__reaches the wire; full detail goes to stderr.KB_CORE_UI_EMBED_URLis set, which must not sit on a request that already has its answer. Work is a single FIFO queue, so a delete queued after a write is applied after it — a pending write can never resurrect a deleted thread.handle_rag_workspacesdispatcher rather than the mux:GET /api/rag/workspaces/{id}/memory,GET .../memory/search,DELETE .../memory. Every branch passesworkspace_idto the store, which scopes its own SQL. An unknown workspace 404s rather than reading as an empty archive. The routes do not exist at all when GraphRAG is off.WorkspaceManager.delete_workspacecascades to the sink. This is an explicit correction to the spec, recorded in the handoff doc.created_at,workspace_id,thread_id,turn_id,seq). The global memory surface'screatedAtshape is untouched.Recall is not implemented. Archived turns are stored and searchable through the UI, but the chat workflow does not retrieve from them.
The last three commits fix correctness bugs found in self-review after the feature was otherwise complete:
drain_errorswas global, so a store failure caused by one tenant's turn could be drained onto another tenant's response. It now takes a workspace id, in both the sink andChatManager's own buffer, and the sink's buffer is lock-guarded.DELETEtalked to the store directly while writes were still queued, stepping over the FIFO. It now goes through the sink when the server has one.Test plan
pytest(python) — 289 passedpytest -k isolationwithRAG_ENABLE=false— 9 passed, existing Graph/Bots/Memory/REST/MCP/SQLite/staticgraph.jsonpaths unaffectedpytest(harness) — 128 passedharness rag --backend fake— passed, 22/22 stages, newchat_memory_persistencestage greenharness rag --backend falkordbagainst pinnedfalkordb/falkordb:v4.20.4— passed, 22/22, same stage greenpnpm test(web) — 62 passedpnpm lint(oxlint) — cleanpnpm build— succeededkb-core-ui serveprocess with the builtweb/distand pinned FalkorDB, driving the exact HTTP calls the UI makes. The DOM itself was not driven — there is no browser automation in this repo, and the handoff doc says so explicitly rather than claiming otherwise.The
chat_memory_persistenceharness stage is the standing gate: it runs two tenants end to end over HTTP and asserts each one's archive holds only its own turns, that search never crosses the boundary, and that deleting a workspace empties its archive while the other tenant's is untouched.🤖 Generated with Claude Code