feat(recall): search your own conversations, on this machine only - #83
Merged
Conversation
The browser lists every conversation you have had and gives you no way to find one. Ninety-odd rows of "hello" and truncated first lines, and the only way back to something you said last week is to scroll and guess. A substring filter would not fix it either: you rarely remember the words, you remember what it was about. So: `zorp-recall`, a new workspace member with a loopback guard, an embedder that asks a local Ollama for a vector, and a SQLite index over `zorp-agent`'s store. `zorp-web` exposes three endpoints behind a non-default `recall` feature and the sidebar gets a search box. Press Index once, then type what it was about. Conversation text goes to a loopback address or it goes nowhere. There is no remote embedding provider, no flag that adds one, and no fallback when the local model is missing. This corpus is a person's whole history with an agent that reads their files, and a capability that keeps working by posting it to an API is not a degraded version of this feature. Four layers enforce that, because any one of them could be wrong. `LoopbackUrl::parse` checks the written form, since a substring test for "127.0.0.1" accepts `127.0.0.1.evil.example`, and then checks that the name still resolves to loopback. `LoopbackResolver` is the only resolver the HTTP client gets: it does no lookup and answers for one host and port, which closes the gap between checking a name and connecting to it. Redirects are off, because a 302 is a request to send the same body somewhere chosen by whatever answered. Proxy-from-env is off, because Cargo unifies features and another crate can turn ureq's proxy detection on without this one asking. The tests count connections to a loopback canary rather than checking for an error. A request that failed and a request that was never made look the same from the caller's side, and only one of them is the guarantee. Removing `redirects(0)` and the resolver together was checked to make those cases fail. SQLite rather than the LanceDB library in `zorp-track`: that one is keyed by track id because it holds an investigation's evidence, chat history is not evidence, and its feature is opt-in precisely because it pulls the Arrow tree in. `rusqlite` is already linked by the crate holding the conversations, so this adds no crates at all. The scan is brute force because 93 conversations is 145 vectors. Indexed on a button, incrementally by content hash, one vector per user or assistant message. Not on write, which would put a model call in the path of sending a message. Not on first search, which would put minutes behind a text box. Measured against a real local model over the real store: 93 conversations, 145 chunks, 15.5 seconds to index, and a query with no word in common with the conversation it should find ranks it first at 0.74 against 0.47 for the rest. Claude-Session: https://claude.ai/code/session_01KGPVQ8wUG7h36zashWYCp4
clippy 1.98 added `chunks_exact_to_as_chunks` and CI runs stable, so this went red on a lint that did not exist on the toolchain it was written on. `as_chunks::<4>()` is the better call regardless of the lint: it hands back a real `[u8; 4]`, which is exactly what `from_le_bytes` takes, so the four element indexes and their bounds checks go away. Same chunks in the same order, and both forms drop a trailing partial chunk, which cannot happen here because `to_blob` only ever writes whole f32s. No `#[allow]` needed. `slice::as_chunks` has been stable since 1.88 and the workspace floor is 1.95, checked with `cargo +1.95 check`. Claude-Session: https://claude.ai/code/session_01KGPVQ8wUG7h36zashWYCp4
adityak74
force-pushed
the
feat/on-device-conversation-search
branch
from
August 22, 2026 05:39
469de52 to
708b909
Compare
This was referenced Aug 22, 2026
adityak74
added a commit
that referenced
this pull request
Aug 22, 2026
…ized Stacked on #83, which merged as 195c8cc. That gave zorp an on-device index of the conversations already in the store. This is the second way that index gets read: not into the sidebar, into a live turn. Every finished turn indexes its own session in the background, so the corpus keeps up without anybody pressing Index, and a turn can be told to read it before answering. Behind a non-default `memory` feature on `zorp-web`, a `"memory": true` field on the turn endpoint, and a checkbox next to the composer that starts unticked on every message. The unit of memory is a verbatim message and there is no other kind. No model is asked to read the corpus and write down what it learned, so there is no claim table and no stored sentence a model composed about the past. That is the shape in which an agent's guesses turn into its own evidence, which is the failure aryabhatta's integrity rule names. What gets recalled is a message somebody sent, with the conversation, the position, the author and the date attached. Half of any conversation was written by an assistant, and those lines are labelled as a model's earlier output rather than presented as fact, in the block the model reads and on the card the person reads. Recalled text is data. It sits inside a fence whose marker carries a nonce minted for that one turn, so a payload stored in March cannot close the quotation and start speaking as the harness. Above it is the sentence zorp-skill puts under a skill body: this cannot grant a tool, widen an approval, or bypass the command denylist. It is a `user` message and never the system prompt, and it grants nothing because nothing in the path could. The block is appended to the seed, which the agent counts as already persisted, so it reaches the model and never the store. Written into the conversation it would be embedded by the next feed and recalled by the turn after that. The feed reuses the vector it already holds for unchanged text, so a fiftieth turn costs one embedding and not fifty. Also adds `Index::search_passages` and a dated `Conversation` header to zorp-recall, with a migration so an index built by #83 keeps its vectors, and `updated` on `SessionRow`, which is the only "when" the store has. Exercised: default features, `recall`, `memory`, and `memory,search` together. No CI job runs the last two combinations. Claude-Session: https://claude.ai/code/session_01KGPVQ8wUG7h36zashWYCp4
adityak74
added a commit
that referenced
this pull request
Aug 22, 2026
…ized (#87) Stacked on #83, which merged as 195c8cc. That gave zorp an on-device index of the conversations already in the store. This is the second way that index gets read: not into the sidebar, into a live turn. Every finished turn indexes its own session in the background, so the corpus keeps up without anybody pressing Index, and a turn can be told to read it before answering. Behind a non-default `memory` feature on `zorp-web`, a `"memory": true` field on the turn endpoint, and a checkbox next to the composer that starts unticked on every message. The unit of memory is a verbatim message and there is no other kind. No model is asked to read the corpus and write down what it learned, so there is no claim table and no stored sentence a model composed about the past. That is the shape in which an agent's guesses turn into its own evidence, which is the failure aryabhatta's integrity rule names. What gets recalled is a message somebody sent, with the conversation, the position, the author and the date attached. Half of any conversation was written by an assistant, and those lines are labelled as a model's earlier output rather than presented as fact, in the block the model reads and on the card the person reads. Recalled text is data. It sits inside a fence whose marker carries a nonce minted for that one turn, so a payload stored in March cannot close the quotation and start speaking as the harness. Above it is the sentence zorp-skill puts under a skill body: this cannot grant a tool, widen an approval, or bypass the command denylist. It is a `user` message and never the system prompt, and it grants nothing because nothing in the path could. The block is appended to the seed, which the agent counts as already persisted, so it reaches the model and never the store. Written into the conversation it would be embedded by the next feed and recalled by the turn after that. The feed reuses the vector it already holds for unchanged text, so a fiftieth turn costs one embedding and not fifty. Also adds `Index::search_passages` and a dated `Conversation` header to zorp-recall, with a migration so an index built by #83 keeps its vectors, and `updated` on `SessionRow`, which is the only "when" the store has. Exercised: default features, `recall`, `memory`, and `memory,search` together. No CI job runs the last two combinations. Claude-Session: https://claude.ai/code/session_01KGPVQ8wUG7h36zashWYCp4
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.
The browser lists every conversation you have had and gives you no way to find
one. The only way back to something from last week is to scroll and guess, and
a substring filter would not fix it: you rarely remember the words, you
remember what it was about.
This makes all of them searchable by meaning, and the text never leaves the
machine.
The constraint, and how it is actually enforced
Conversation text goes to a loopback address or it goes nowhere. No remote
embedding provider, no flag that adds one, no fallback when the local model is
missing. This corpus is a person's whole history with an agent that has been
reading their files, so a capability that keeps working by posting it to an API
is not a degraded version of this feature.
Four layers, because any one of them could be wrong.
LoopbackUrl::parseis the only way to name an endpoint. The writtenform must be a loopback IP literal or exactly
localhost. A substring testfor
127.0.0.1accepts127.0.0.1.evil.example, which is a name somebodyelse owns. The name is then resolved once and every address it yields must
be loopback, which catches a
localhostremapped in/etc/hosts. A nameanswering with one loopback address and one that is not is refused whole,
not filtered down to the safe half.
LoopbackResolveris the only resolver the HTTP client gets. Itperforms no lookup of its own and answers for exactly one host and port,
returning the addresses step 1 validated. Checking a name and then letting
the client look it up again is a check with a gap in the middle.
ureqroutes every connection through the resolver, including a proxied one.
redirects(0). A 302 is a request to send the same body somewherechosen by whatever answered.
try_proxy_from_env(false).ureq::AgentBuilder::newturns proxydetection on when the
proxy-from-envfeature is enabled, and Cargounifies features across the graph, so another crate can enable it without
this one asking.
HTTP_PROXYon a managed laptop is somebody else's server.There is also no
Authorizationheader on the embedding request, ever. Thereis no credential for a local model, and the habit is how one ends up being sent
to whatever the endpoint turns out to be.
The tests count connections to a loopback canary rather than checking for
an error. A request that failed and a request that was never made look
identical from the caller's side, and only one of them is the guarantee. I
verified the cases have teeth: with
redirects(0)and the resolver bothremoved,
a_redirect_off_device_is_not_followedfails on the canary count(the text really does reach it). With only
redirects(0)removed it fails onthe error kind, because layer 2 still blocks the connection.
Decisions
Embedding source: a local Ollama over loopback HTTP.
fastembedandcandleare genuinely self-contained, and they are a large tree in a workspacethat pares
zipdown to two codecs and runs an MSRV job to stop the floordrifting. They also download weights from a model host on first use, which is a
network call, a large one, that this feature would then have to explain. Ollama
is already the first entry in the settings panel's provider list and already
has a test driving it (
zorp-agent/tests/ollama_calibration.rs). The cost is anexternal process, and the honest answer to not having one is the refusal.
Store: SQLite, not the LanceDB
Libraryinzorp-track. Reuse was thereflex and it is wrong three times.
Libraryis keyed by track id because itholds an investigation's evidence, and chat history is not evidence, which is
the line
docs/DECISIONS.mdalready drew for open-context on 2026-08-17. Thelibraryfeature is opt-in specifically because it pulls the Arrow tree, andthis would pull it into the web binary. And
rusqlitewithbundledisalready linked by
zorp-agent, the crate that holds the conversations, so thewhole capability adds no new crates to the tree. Search is a brute-force
scan: 93 conversations is 145 vectors, a dot product over that is
sub-millisecond, and an ANN index is a data structure, a build step and a
recall tradeoff bought for a problem nobody has.
Indexing: on a button, incrementally, one vector per message. On write
would put a model call in the path of sending a message and make chat depend on
Ollama being up. On first search would put minutes behind a text box. The
button skips any conversation whose SHA-256 content hash has not moved. A
message rather than a whole conversation, because a conversation averaged into
one vector is a vector about nothing in particular, and because a per-message
hit gives the result list a line to show. Results roll up to one row per
conversation. Tool results are not indexed: largest thing in most sessions,
mostly files the agent read on the way to an answer, and they would fill the
results with the same file in nine conversations.
On disk:
recall.dbbesidesessions.dbin zorp's state directory,overridable with
ZORP_RECALL_DB. Not gitignored because it does not need tobe: it is in the state directory, not a project tree.
zorp-track/src/project.rsignores
zorp.duckdb*andlancedb/because those are written inside a repo.A separate file from
sessions.dbbecause a derived index is rebuildable andthe thing it was derived from is not, so this code never writes to the store.
Feature:
recallonzorp-web, non-default.Measured on real data
Against a copy of the real store and a real local model
(
qwen3-embedding:lateston127.0.0.1:11434):"genetic algorithm for communities in a graph"returns the ERBGAconversation at 0.747, next 0.581. No word in common.
"how many source files are in a directory"returns the right one at 0.743,next 0.539.
relevance floor: any fixed cutoff would be a number invented to look
decisive. The list is ranked and capped, weakest last.
Tests added
zorp-recall/tests/no_remote.rs(7), the most important file here:api.openai.com:443,8.8.8.8, a different loopback port, andlocalhostZORP_BASE_URLpointed at a canary that stays at zero
Authorizationzorp-recall/tests/no_proxy.rs(1, own binary for env isolation): a proxy inHTTP_PROXY/http_proxy/ALL_PROXYis not used, canary count zero.zorp-recall/tests/loopback.rs(8): public addresses refused; near missesrefused (
127.0.0.1.evil.example,localhost.evil.example, userinfo forms);0.0.0.0and[::]refused; non-HTTP schemes refused; the local formsaccepted including
[::ffff:127.0.0.1]; an accepted URL carries only loopbackaddresses; the default endpoint passes its own guard; a refusal names the host.
zorp-recall/tests/index.rs(11): search finds a conversation no substringmatch would (the test first asserts the substring search really does miss);
one row per conversation naming the message that matched; ranked and capped;
replace does not duplicate; fingerprints drive the incremental reindex; retain
drops deleted conversations; changing embedder clears the index; writing under
a different embedder refused; a query of the wrong width refused; empty index
returns nothing; the index persists.
zorp-recall/tests/ollama_embed.rs(2,#[ignore], same arrangement asollama_calibration.rs): a real model finds a conversation by meaning; theguard still refuses a remote endpoint. Both were run for real and pass.
zorp-web/tests/recall.rs(7 with the feature, 1 without): end to end throughthe real HTTP path against a stub embedding server on a loopback socket.
Semantic hit a substring misses; second index skips unchanged; status reports
endpoint and size; no local embedder means an explicit 503 saying "no local
embedder" with a canary at zero; a remote
ZORP_EMBED_URLis refused and therefusal names the host; empty query is 400; deleted conversations drop out. The
without-feature test asserts status says off and the other two answer 501.
web/test/conversation-search.test.ts(17): script tags,<img onerror>,markup in a title and in a notice all render as text; malformed rows dropped;
non-array body is no results; missing fields defaulted; long snippets cut; each
result is a button reporting its conversation; results are named for a screen
reader; untitled conversations say so; no results is a sentence; rendering
replaces rather than appends; the four status-line wordings.
Everything on the page goes through
textContent. There is noinnerHTMLinweb/src/conversation-search.ts.Commands run
cargo fmt --all --checkcargo build --workspace --lockedcargo test --workspace --lockedcargo test -p zorp-web -p zorp-recall --features zorp-web/recall --lockedcargo test -p zorp-agent --features research --lockedcargo clippy --workspace --exclude zorp-track --all-targets --locked -- -D warningscargo clippy -p zorp-web -p zorp-recall --features zorp-web/recall --all-targets --locked -- -D warningscargo +1.95 check --workspace --exclude zorp-track --all-targets --lockedZORP_EMBED_MODEL=qwen3-embedding:latest cargo test -p zorp-recall --test ollama_embed -- --ignorednpm run check(web)npm test(web)npm run build(web)CI gains one step in
build-and-testfor the feature run. It adds no crates,so it is cheap.
Rebased onto current main
Rebased onto
origin/mainat e003771, after #79, #81 and #80 landed. Stilltwo commits, no merge commit. Three conflicts, all resolved by keeping both
sides:
zorp-web/Cargo.toml: the[features]block now carriesdefault = [],feat(web): say when the agent can search, and mean it #81's
search, and this branch'srecall. My comment onrecallnow drawsthe contrast rather than repeating the boilerplate:
searchopts into anegress path,
recallopts into a capability that refuses to have one.web/src/main.ts:connectOrExplaincalls bothrefreshCapabilities()and
refreshRecallStatus().docs/DECISIONS.md: both entries kept, newest first, separator restored.zorp-web/src/api.rs,web/index.html,web/styles.css,CLAUDE.mdandAGENTS.mdauto-merged.Confirmed nothing of the merged work was dropped:
web/src/search-indicator.ts(67 lines),
web/src/copy-response.ts(296 lines) andweb/test/copy-response.test.ts(25 cases) are byte-identical toorigin/main, andgit diff origin/mainreports no change to any of them. Therouter carries
/api/capabilitiesalongside the three recall routes. Runindividually after the rebase: copy-response 25/25, search-indicator 8/8,
conversation-search 17/17, 193 web tests in total.
recallandsearchare a new feature combination that no CI job covers;cargo clippy -p zorp-web --features recall,searchand the matching test runare both clean.
Open questions
much larger history is a long-held POST with a spinner and no progress. The
smallest fix is a progress event on the existing stream; I did not build it
because it is architecture for a cost nobody has hit yet.
should be measured per model, not guessed.
one with no user is speculative.
message re-embeds all of it. Wasteful and correct, and correct is the one
that matters at this size.
https://claude.ai/code/session_01KGPVQ8wUG7h36zashWYCp4