Skip to content

feat(recall): search your own conversations, on this machine only - #83

Merged
adityak74 merged 2 commits into
mainfrom
feat/on-device-conversation-search
Aug 22, 2026
Merged

feat(recall): search your own conversations, on this machine only#83
adityak74 merged 2 commits into
mainfrom
feat/on-device-conversation-search

Conversation

@adityak74

@adityak74 adityak74 commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

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.

  1. LoopbackUrl::parse is the only way to name an endpoint. The written
    form must be a loopback IP literal or exactly localhost. A substring test
    for 127.0.0.1 accepts 127.0.0.1.evil.example, which is a name somebody
    else owns. The name is then resolved once and every address it yields must
    be loopback, which catches a localhost remapped in /etc/hosts. A name
    answering with one loopback address and one that is not is refused whole,
    not filtered down to the safe half.
  2. LoopbackResolver is the only resolver the HTTP client gets. It
    performs 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. ureq
    routes every connection through the resolver, including a proxied one.
  3. redirects(0). A 302 is a request to send the same body somewhere
    chosen by whatever answered.
  4. try_proxy_from_env(false). ureq::AgentBuilder::new turns proxy
    detection on when the proxy-from-env feature is enabled, and Cargo
    unifies features across the graph, so another crate can enable it without
    this one asking. HTTP_PROXY on a managed laptop is somebody else's server.

There is also no Authorization header on the embedding request, ever. There
is 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 both
removed, a_redirect_off_device_is_not_followed fails on the canary count
(the text really does reach it). With only redirects(0) removed it fails on
the error kind, because layer 2 still blocks the connection.

Decisions

Embedding source: a local Ollama over loopback HTTP. fastembed and
candle are genuinely self-contained, and they are a large tree in a workspace
that pares zip down to two codecs and runs an MSRV job to stop the floor
drifting. 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 an
external process, and the honest answer to not having one is the refusal.

Store: SQLite, not the LanceDB Library in zorp-track. Reuse was the
reflex and it is wrong three times. Library is keyed by track id because it
holds an investigation's evidence, and chat history is not evidence, which is
the line docs/DECISIONS.md already drew for open-context on 2026-08-17. The
library feature is opt-in specifically because it pulls the Arrow tree, and
this would pull it into the web binary. And rusqlite with bundled is
already linked by zorp-agent, the crate that holds the conversations, so the
whole 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.db beside sessions.db in zorp's state directory,
overridable with ZORP_RECALL_DB. Not gitignored because it does not need to
be: it is in the state directory, not a project tree. zorp-track/src/project.rs
ignores zorp.duckdb* and lancedb/ because those are written inside a repo.
A separate file from sessions.db because a derived index is rebuildable and
the thing it was derived from is not, so this code never writes to the store.

Feature: recall on zorp-web, non-default.

Measured on real data

Against a copy of the real store and a real local model
(qwen3-embedding:latest on 127.0.0.1:11434):

  • 93 conversations, 145 chunks, 15.5 seconds for a cold index.
  • "genetic algorithm for communities in a graph" returns the ERBGA
    conversation 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.
  • Unrelated conversations sit at 0.47 to 0.58, which is why there is no
    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:

  • a redirect off device is not followed, canary count zero
  • a redirect to a name is not followed
  • the resolver answers only for the validated addresses, and refuses
    api.openai.com:443, 8.8.8.8, a different loopback port, and localhost
  • an off-device URL is refused before any request
  • an unreachable local embedder does not fall back, with ZORP_BASE_URL
    pointed at a canary that stays at zero
  • a malformed answer is an error, not an empty vector
  • the request goes to the named local endpoint and carries no Authorization

zorp-recall/tests/no_proxy.rs (1, own binary for env isolation): a proxy in
HTTP_PROXY / http_proxy / ALL_PROXY is not used, canary count zero.

zorp-recall/tests/loopback.rs (8): public addresses refused; near misses
refused (127.0.0.1.evil.example, localhost.evil.example, userinfo forms);
0.0.0.0 and [::] refused; non-HTTP schemes refused; the local forms
accepted including [::ffff:127.0.0.1]; an accepted URL carries only loopback
addresses; the default endpoint passes its own guard; a refusal names the host.

zorp-recall/tests/index.rs (11): search finds a conversation no substring
match 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 as
ollama_calibration.rs): a real model finds a conversation by meaning; the
guard 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 through
the 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_URL is refused and the
refusal 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 no innerHTML in
web/src/conversation-search.ts.

Commands run

Command Result
cargo fmt --all --check clean
cargo build --workspace --locked ok
cargo test --workspace --locked 64 binaries, all ok, 0 failed
cargo test -p zorp-web -p zorp-recall --features zorp-web/recall --locked all ok, 0 failed
cargo test -p zorp-agent --features research --locked 14 binaries ok, 0 failed
cargo clippy --workspace --exclude zorp-track --all-targets --locked -- -D warnings clean
cargo clippy -p zorp-web -p zorp-recall --features zorp-web/recall --all-targets --locked -- -D warnings clean
cargo +1.95 check --workspace --exclude zorp-track --all-targets --locked ok (MSRV)
ZORP_EMBED_MODEL=qwen3-embedding:latest cargo test -p zorp-recall --test ollama_embed -- --ignored 2 passed
npm run check (web) clean
npm test (web) 166 passed, 0 failed
npm run build (web) ok, 86.5kb

CI gains one step in build-and-test for the feature run. It adds no crates,
so it is cheap.

Rebased onto current main

Rebased onto origin/main at e003771, after #79, #81 and #80 landed. Still
two commits, no merge commit. Three conflicts, all resolved by keeping both
sides:

  • zorp-web/Cargo.toml: the [features] block now carries default = [],
    feat(web): say when the agent can search, and mean it #81's search, and this branch's recall. My comment on recall now draws
    the contrast rather than repeating the boilerplate: search opts into an
    egress path, recall opts into a capability that refuses to have one.
  • web/src/main.ts: connectOrExplain calls both refreshCapabilities()
    and refreshRecallStatus().
  • docs/DECISIONS.md: both entries kept, newest first, separator restored.

zorp-web/src/api.rs, web/index.html, web/styles.css, CLAUDE.md and
AGENTS.md auto-merged.

Confirmed nothing of the merged work was dropped: web/src/search-indicator.ts
(67 lines), web/src/copy-response.ts (296 lines) and
web/test/copy-response.test.ts (25 cases) are byte-identical to
origin/main, and git diff origin/main reports no change to any of them. The
router carries /api/capabilities alongside the three recall routes. Run
individually after the rebase: copy-response 25/25, search-indicator 8/8,
conversation-search 17/17, 193 web tests in total.

recall and search are a new feature combination that no CI job covers;
cargo clippy -p zorp-web --features recall,search and the matching test run
are both clean.

Open questions

  • The index request is synchronous. Fifteen seconds on this corpus, but a
    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.
  • No relevance floor, for the reason above. If a floor is ever wanted it
    should be measured per model, not guessed.
  • Only Ollama. The trait is there for a second local runtime, but a second
    one with no user is speculative.
  • Re-embedding is whole-conversation. A conversation that grew by one
    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

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
adityak74 force-pushed the feat/on-device-conversation-search branch from 469de52 to 708b909 Compare August 22, 2026 05:39
@adityak74
adityak74 merged commit 195c8cc into main Aug 22, 2026
7 checks passed
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
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