Skip to content

Chat memory bridge: archive chat turns to the SQLite memory store - #8

Open
TimothyNguyen wants to merge 20 commits into
rag-chatbot-managerfrom
chat-memory-bridge
Open

Chat memory bridge: archive chat turns to the SQLite memory store#8
TimothyNguyen wants to merge 20 commits into
rag-chatbot-managerfrom
chat-memory-bridge

Conversation

@TimothyNguyen

Copy link
Copy Markdown
Owner

Stacked on #7 (rag-chatbot-managermaster). Base is rag-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.

  • A separate chat_memories table. The existing memories table is a byte-level cross-language contract with Go's internal/memory/store.go and cannot gain a workspace column. chat_memories lives in the same memory.db with its own workspace-scoped schema and indexes; Go ignores tables it does not know. No new code reads or writes memories.
  • A sink layer that is allowed to fail. ChatMemorySink has three implementations: NullChatMemorySink (what a server with no memory database injects, so call sites stay unconditional), SyncChatMemorySink (does the work, swallows store failures), and ThreadedChatMemorySink (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 frozen errors list and are drained once. Only exc.__class__.__name__ reaches the wire; full detail goes to stderr.
  • Off the request thread. Embedding a turn can be a 30s HTTP call when KB_CORE_UI_EMBED_URL is 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.
  • Three workspace-scoped routes, on the existing single handle_rag_workspaces dispatcher rather than the mux: GET /api/rag/workspaces/{id}/memory, GET .../memory/search, DELETE .../memory. Every branch passes workspace_id to 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.
  • Deleting a workspace clears its archive. WorkspaceManager.delete_workspace cascades to the sink. This is an explicit correction to the spec, recorded in the handoff doc.
  • Web surface to browse and search a workspace's archived turns, using snake_case payload keys (created_at, workspace_id, thread_id, turn_id, seq). The global memory surface's createdAt shape 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:

  • A full queue used to discard the oldest pending item to make room, which could drop a delete or let a write outlive the delete meant to remove it. Writes stay expendable; deletes now wait for the sink timeout instead.
  • drain_errors was 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 and ChatManager's own buffer, and the sink's buffer is lock-guarded.
  • The HTTP DELETE talked 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 passed
  • pytest -k isolation with RAG_ENABLE=false — 9 passed, existing Graph/Bots/Memory/REST/MCP/SQLite/static graph.json paths unaffected
  • pytest (harness) — 128 passed
  • harness rag --backend fake — passed, 22/22 stages, new chat_memory_persistence stage green
  • harness rag --backend falkordb against pinned falkordb/falkordb:v4.20.4 — passed, 22/22, same stage green
  • pnpm test (web) — 62 passed
  • pnpm lint (oxlint) — clean
  • pnpm build — succeeded
  • Live pass against a real kb-core-ui serve process with the built web/dist and 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_persistence harness 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

TimothyNguyen and others added 20 commits August 31, 2026 18:48
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant