* feat(ai-vault-search): expose the seams a scheduler needs to stay honest
Three questions a lifecycle owner has to be able to ask, none of which had an
answer: how many unfinished writes this open tombstoned, what the index
believes it holds, and whether the session list's cursor already covers a file.
The last one is not a nicety. The reader only opens a transcript the list still
needs, so any file the list scanned before the index existed would be reused
from cache and never reach the index at all. Naming the rule where it lives
keeps one spelling of it instead of two.
* feat(ai-vault-search): the bounds an unasked background index has to respect
An injected clock, a retention window, a per-cycle allowance in files and
bytes, a bounded re-read queue, and the load-aware pacing from the original
branch.
The allowance deliberately does not carry unspent room forward: accumulating it
would let a long idle stretch buy one unbounded cycle, which is the stall the
budget exists to prevent. Work that does not fit is queued, not dropped, and a
transcript larger than a whole cycle's bytes is admitted alone rather than
being refused forever.
* feat(ai-vault-search): read a candidate set into the index, and say what happened
One pass over a candidate list, plus the four things a caller has to be told
about afterwards: what was discovered, which roots could not be read, which
sources are provably gone, and where the whole run stands.
Two absence rules are load-bearing. The file walker swallows a readdir failure
and returns, so an unreadable root and an uninstalled agent both arrive as "no
files"; only a root that yielded nothing is re-probed, and only ENOENT counts
as absent. A source is retired on the same evidence and no weaker: an EACCES,
an EIO or a stalled distro keeps its rows.
Progress lives in the index's own files table, so a pass skips what it already
covers and an interrupted run resumes instead of starting over.
* feat(ai-vault-search): a whole-machine sweep and a recency-window cycle
The sweep enumerates every root without a limit and is the only pass that can
retire a source deleted while nothing was running. The cycle re-stats the
newest N per agent, which is the sidebar's own rule rather than a second one,
folds in the store's stale set and anything a caller invalidated, and reads
what changed inside the cycle's allowance.
A replaced file gets a whole re-read in the cycle that notices it. Waiting for
the index to decline an append and mark itself stale would cost a second cycle
and, because the reader resumes from the session list's cursor, would decline
again every cycle after that.
* feat(ai-vault-search): SessionSearchIndexer, with its freshness claim tested
The object that owns freshness for the index: a full sweep on start, a timer
that reconciles the recent window, retention that purges when it narrows and
re-sweeps when it widens, and a pause that stops the writers rather than only
the timer.
A library, not a service. No Electron, no app lifecycle, no settings read, no
IPC, and nothing constructs it. The clock is injected because a guarantee
stated in wall time is a claim until a test can advance the clock and watch it
hold: a transcript in the recent window that grows, is rename-replaced, or is
deleted is reflected within one interval, each with its own test.
* test(ai-vault-search): index a conversation held in Orca's own chat
Reviewer F4 and the plan's fourth open decision. A conversation held in the
panel writes the same file in the same place as one held in the terminal, so
it must be searchable through the same path with nothing else running. The
test constructs the indexer over an isolated root, writes and then appends
native-chat-shaped rows, advances the clock one interval, and reads the rows
back through the published views.
* fix(ai-vault-search): ask the store before reading its cursor
The pass read the index's own file row to decide how to read a candidate, and
only then asked whether the store would accept it. A store that is paused or
already closed answers no to everything, so those cursor reads were against a
handle it had given up.
* feat(ai-vault-search): take the reader's whole-read seam and PR 2's pause rules
`requestWholeTranscriptRead` replaces the local invalidation: the reader owns
the resume point, so asking it is the honest way to say the index needs a span
the session list has already moved past.
Two callers, and the second is the one no decline can reach. A forced path is
one the store handed back from `takeStale` or a caller invalidated, and it has
to arrive as a `replace` or the consumer declines the same append forever. But
when the list's cursor already sits at a file's current stat the parse opens
nothing at all, so no consumer is asked and there is nothing to record. That is
every transcript on the machine the first time the index is switched on inside
a running app, so it gets a test on both the sweep and the cycle path.
Pausing now keeps the store's re-read set, so `filesPending` and
`droppedPending` add both bounded queues together: a caller cannot act on one
of them alone. The schema creates the index directory, so the indexer no longer
does.
* refactor(ai-vault-search): one ceiling for both re-read queues
The indexer's scheduled-work queue carried a bound of its own beside the
store's STALE_PATH_LIMIT, so `droppedPending` summed two numbers that meant two
different things. It now shares the store's ceiling, set by the indexer, which
is the only thing holding both queues.
They stay separate queues. The store records that the index has a hole in a
file; this records work scheduled and not yet done. Merging them by pushing
budget leftovers through `markStale` would turn every deferred append into a
whole re-read.
* fix(ai-vault-search): stop the indexer from going quiet and calling it current
Seven review findings, all in the seam between "stopped working" and "finished".
One commit because they meet in the same three files.
A pause part way through a backfill abandoned it. The flag was cleared on entry,
the abort was swallowed as a normal stop, and resume only re-swept when the pause
outlasted an interval. A sweep is now due until one completes, and work drained
out of a queue and then not read goes back: a stale or invalidated path is a hole
in the index, not finished work.
The retention cutoff was set once at construction while purges took a fresh one,
so with a one-day window a sweep three days later deleted a row the very next
accept check re-indexed. It is refreshed from the clock before any accept
decision in a pass.
An invalidated path the index had never held was dropped unread, because it was
resolved only through rows the index already had. It now resolves the agent from
the same root table discovery reads, and what still cannot be resolved stays
queued and counted rather than vanishing.
Status told four small lies: a file count that tallied attempts and grew past the
total, `current` while work was queued or before any sweep had finished, `current`
after close, and a declined read counted as indexed because the parse returned
without throwing. It now counts what the store holds, requires all three
conditions for `current`, has a `closed` phase, and counts a file only when the
index's own cursor moved.
The cursor drop ran outside the per-path parse lane, so an overlapping sidebar
parse could store its entry in between and turn a forced whole read back into a
cache reuse. The decision moves inside the lane as a read requirement, which is
the only place that is atomic against it.
A sweep retired every indexed file it did not discover. An unmounted volume
ENOENTs its whole tree at once, so that deleted a user's searchable history for a
detached drive. A root that cannot be read, or that lists nothing where it listed
transcripts before, is degraded, and a degraded root's files are never retired
however loudly the filesystem says they are gone.
The pass loop moves to `session-search-work-loop.ts`: one task at a time, one
pending tick, one abort. It is the part with no opinion about transcripts, and
the indexer was over the line limit with it inline.
* fix(ai-vault-search): count files owed a read once, and pick the ceiling on purpose
`filesPending` summed the store's re-read set and the indexer's queue, and a
path sits in both the moment a read is declined during a pause and a caller
then invalidates the same file. One transcript read as two, with nothing to
distinguish that from two transcripts. It is a union by path now. The drop
counts stay a sum, because a drop is an event rather than a membership and
nothing retains the paths to deduplicate afterwards.
The previous commit raised this queue's cap from 2,000 to the store's 20,000 as
a side effect of trying to make one number out of two, which the double-count
shows it never was. Both queues retain a candidate per entry, so that quietly
doubled the worst-case memory of a background feature. Back to 2,000, with the
reason written down: the store's set is filled by the reader at machine speed
during a pause and needs headroom proportional to the transcripts on the disk,
while this one is filled by a cycle's budget rollover, bounded by one recent
window at a few hundred, and by `invalidate()`, where 2,000 outstanding requests
is already a malfunctioning caller.
* fix(ai-vault-search): apply the round-1 guards on the cycle path too
Two of the round-1 fixes were written on the sweep and the cycle walked around
them twenty seconds later.
The degraded-root fence now lives inside the retirement function itself rather
than at one call site, so both passes get it from one place and the cycle
cannot delete what the sweep just protected. The cycle also reads the sweep's
root counts, so it can see a tree that went to zero at all; it does not write
them back, because a recent-window discovery is not a census.
The N-to-zero alarm was single-shot: the degraded sweep's own zero became the
baseline, so the next sweep compared zero with zero and retired the tree it had
just spared. A root keeps its last healthy count until one lists it non-empty
again.
Taking a file no longer means the cursor moved. A forced whole re-read of an
unchanged file writes an identical cursor, so an invalidated file that turned
out not to have changed was never settled: owed forever, re-read whole every
interval, status pinned. It means the index now covers the file at this stat,
which is the same question the skip at the top of the pass asks, and it is the
same function.
An aborted cycle handed the store's re-read set to a queue a tenth its size,
which silently discarded the difference. What came from the store goes back to
the store, under its own bound and its own retention rule.
Whether a sweep finished is now an argument rather than a call site's position,
so an aborted one cannot latch `current` by being reported a line too early.
* fix(ai-vault-search): fence real directories, and let the alarm release
The degraded-root fence did nothing at all for OpenClaw, the one agent whose
roots are alternates for a single install. Discovery reports those as one
discovery whose rootDir is every path joined by the platform's path delimiter,
and that string is not a directory: the probe readdir'd it and got ENOENT, the
containment check never matched a file under it, and a scan issue recorded
against a real root never compared equal to it. So the agent most likely to
live on a mounted volume was the one an unmount deleted, and the degraded root
it reported was not a path anyone could act on.
Health now runs on the constituent directories, taken from the same source
table discovery reads rather than by splitting the joined string back apart,
which would be its own bug: a directory may legally contain the delimiter.
Files are attributed to the root they actually live under, so one alternate can
be unreadable while the other keeps indexing and retiring normally.
The N-to-zero alarm also never released. Carrying only counts above zero meant
a root the user legitimately emptied stayed degraded for the life of the
process, its rows never retired and the phase pinned. The rule is now explicit:
a root that cannot be listed keeps its last healthy count and stays degraded
indefinitely, while one that lists successfully and empty on two consecutive
full sweeps is believed. One sweep is not enough, because that is also what a
freshly unmounted volume looks like, and only a sweep counts: a recent-window
cycle can see a root at zero but is not a census.
The allowance still charges a forced read at the size discovery saw. That is an
under-count when a file grows mid-cycle, and it is deliberate: the budget paces
a cycle rather than accounting for it, and the error is bounded by what one
cycle's writers appended.
* fix(ai-vault-search): prove a root once held transcripts from the index, not memory
The fence was inert on the first sweep of every process. The evidence that a
root had ever held anything lived only in memory, so after a restart it was
empty, and a missing directory is what a detached volume and an agent that was
never installed both look like. Index a transcript, close, detach the volume,
open the same database: every row retired on that one sweep, with no degraded
root reported. The evidence now comes from the store, which is the thing that
actually outlives the process, through a range scan on the path key. A root
that is missing while the index holds files under it is degraded; only one the
index holds nothing under is absent.
Consecutive also has to mean consecutive. An unreadable sweep left the tally
alone rather than breaking it, so empty, unreadable, empty added up to a
deletion nobody performed. Anything that is not a successful empty listing now
resets the run.
Rows under no configured root were immortal: nothing refreshed them, nothing
retired them, nothing reported them, and searches still returned them. That
happens when a profile moves or a root is reconfigured. One rule, written at
the function: such a file is retired exactly like any other if its path answers
ENOENT, because that is proof, and otherwise the rows stay and `orphanedFiles`
reports them. An index holding content the current configuration cannot reach
is a configuration problem to surface, not a licence to delete history.
An aborted sweep no longer publishes findings it never gathered. It stops
probing on abort, so its empty degraded list would have cleared a live alarm,
and its partial view must not count toward emptying a root either. It now skips
the health pass entirely and carries the previous state forward.
* fix(ai-vault-search): an empty mountpoint is not an emptied root either
Round 4 moved the missing-root branch onto the store and left the other one on
memory. An unmount on Linux, WSL or sshfs does not remove the mountpoint: it
leaves it present and empty, so a detached volume takes the listable-but-empty
branch, and that branch armed its two-sweep grace from a count that is zero on
the first sweep of every process. Index three transcripts, close, detach: all
three retired on that one sweep, with no alarm and a phase of `current`. Both
branches now ask the store, which is the only thing that outlives the process.
Nothing re-armed a sweep when a root came back. A root absent at start is
correctly ignored, but after it returns a cycle only reads the newest N per
agent, so one file was indexed and the rest stayed unreachable for the life of
the process. A cycle that sees a root listing again where a pass judged it
absent or degraded now asks for a sweep. Only after one sweep has completed:
before that, a root with no recorded count has simply never been censused, and
treating that as a recovery would turn every early cycle into a full sweep.
`hasIndexedFilesUnder` was already bounded at a path segment; nothing pinned
it, which is why the bare-prefix mutation lived. It has a test now, on both
separators, including that a root is not held under itself.
* refactor(ai-vault-search): prove a deletion by walking to the root, not by remembering
Retirement had grown a root-health state machine: a per-root healthy count, a
two-consecutive-empty-sweeps tally, a census flag, a store query for whether the
index had ever held files under a root, and a fence every call site had to
remember to apply. Four review rounds found the same bug in four shapes, because
each shape was a new way for the machine to conclude "empty" from something that
was not.
The rule is structural now. A row retires only when a directory between the file
and its configured root lists successfully and the next component toward the
file is absent from that listing; a directory that ENOENTs is walked up, and any
other failure is unverifiable at once. The walk stops at the configured root, so
everything above it -- a home on an unmounted volume, a detached drive, a
dropped SSH mount -- is out of scope by construction rather than by memory, and
the rule reads the same on the first pass of a process as on the thousandth. The
invariants are written at the top of the module and each is a test.
One bit per root survives: a root that held transcripts on the previous pass and
holds none on this one gets a pass of grace, so a directory swapped out for a
moment cannot retire a tree. What that does not cover is stated in the module
and pinned by two tests.
degradedRoots becomes a per-pass signal with no memory: roots discovery recorded
an issue against, roots the walk could not read through, and roots that yielded
nothing and refuse to list at all.
* fix(ai-vault-search): close the loop, budget the backfill, and let a pause mean it
Five lifecycle defects, all of them cases where a call did more or less than it
says.
close() disarmed the timer and aborted the task in flight but left the queue
running, so a clear() queued a moment earlier would go on to delete the
database, open a new one and register a consumer against it, behind an indexer
whose caller had finished with it. Closing the work loop makes every queued task
a no-op.
The backfill was the one pass that read transcript bytes without a budget: a
first run over a large disk owned the process until it finished. It now spends
an allowance of its own and hands back the rest of its plan, which the passes
that follow drain without re-discovering. The allowance is separate from the
cycle's and much larger, because a first run has a backlog and steady state does
not: 128 MB a pass drains 20 GB in about 53 minutes where the cycle budget would
take about 14 hours.
A pause now stops purges and compaction too: narrowing the history window while
paused records that a purge is owed and runs it on the first pass allowed to
write. resume() no longer sweeps on its own, however long the pause was; every
read declined while paused is already in the store's re-read set, which the next
cycle drains. start() while paused arms on resume instead of queueing a pass
that returns immediately and resolves as though one had run.
A root that recovers still buys a full sweep, but at most one per recovery: it
has to be listed healthy on the pass after the one that re-armed before it can
buy another, so a root flapping every interval costs one sweep rather than one a
flap.
Smaller: clear() resets the swept flag, so an emptied index is not reported as
current before the sweep that refills it; a sweep watches only what it could not
settle rather than every path it discovered; and the progress pair is measured
against one population, so a ratio cannot exceed 100 percent.
The round-6 lifecycle matrix lives in the repository now: 11 operations against
3 unreachable-root shapes against both ways discovery reports a root, 66 cells
on four invariants.
* test(ai-vault-search): drop the tests the old retirement rule owned
Two of them asserted the same behaviour as the emptied-root tests that replaced
them, and both were named for a rule that no longer exists: retirement waiting
for a second sweep to agree, and an unmounted volume being recognised by its
root listing empty. Comments that pointed at review rounds rather than at the
behaviour go with them, and the merged-root test is named for what it covers now
that there is no root-health module for it to be about.
* fix(ai-vault-search): stop a sweep from erasing the request that arrived during it
Four round-8 findings, all in round-7 code.
A full-sweep request raised while a sweep was running was erased by the sweep it
arrived during. The flag stayed set across the await and was cleared on the way
out, so widening the history window or calling reconcile({ full: true }) part
way through a backfill left status reading `current` with the widened-in
transcripts never read. The pass takes the flag on entry now; an unfinished
sweep is what puts it back.
clear() followed by close() left the database on disk. The removal was queued on
the work loop, close() makes queued tasks no-ops, and clear()'s promise resolved
anyway -- a privacy action that reports success without doing anything. The
store, its consumer registration and its file on disk now have one owner and one
lifetime, and closing performs a removal that is still owed.
The backfill drain bounded bytes but not wall time. The pacer backs off 15
seconds a batch on a loaded host, so a pass could hold the loop for a quarter of
an hour without going near its byte budget, and since the drain runs inside the
reconcile cycle that is the recent-N-per-interval promise gone. Every read pass
now stops at one interval and hands the rest back.
A row whose path names an entry inside a container rather than a file of its own
was proven only against the container, so an entry deleted inside it could never
be retired. Such a row is now proven by the container's own enumeration, under
the same bar a directory listing has to meet: exhaustive, successful, and not
empty. Nothing in this PR can hold such a row yet -- the index pass refuses a
source whose messages the channel cannot reach, which is every OpenCode SQLite
session -- so this is the guard for the day that changes, and a test pins the
precondition.
Also: status() reports `idle` before start() rather than describing work no
timer was going to do, and reconcile() before start() is refused rather than
writing the index once and leaving it to go stale.
* test(ai-vault-search): date the widened-in transcript on the clock retention reads
The transcript meant to sit outside a 30-day window was dated against wall time
while the window is measured against the test clock, which runs a year behind
it, so the file was inside the window and the test proved nothing: it passed
with the fix reverted.
* fix(ai-vault-search): meet the rewritten store where PR 2 left it
The rebase onto the one-transaction-per-file store: re-add the cursor
predicate PR 2 dropped, read `sessions`/`messages` now the visibility views
are gone, and delete `recoveredRows` -- there is no tombstone table left for
a crashed writer to leave rows in, so the counter could only ever read zero.
* refactor(ai-vault-search): make the indexer immutable, with one queue and one bound
A configuration change is now "close it, construct a new one", so the object
has one store, one registration and one lifetime. `pause`, `resume`, `clear`,
`setHistoryDays` and `invalidate` are gone, and with them every flag that only
existed to keep a second lifetime in step: `paused`, `pausedAt`, `purgeDue`,
the deferred-purge path, the resume-sweep rules and the start-while-paused
case. Throwing the index away is close, `removeSessionSearchDatabase`, and a
new instance; widening retention is a new instance whose opening sweep admits
the older files, and narrowing is the purge that opens every full sweep.
One queue, not three. The indexer's pending queue and the sweep remainder are
deleted; the store's re-read set is the one bounded queue, already the place a
declined read lands, and `filesPending` is its size rather than a union across
queues that could count one transcript twice.
One pacer, not three. The files-and-bytes allowance and the load-average
back-off are deleted; a pass reads until its wall-clock deadline and hands the
rest back. A pass that runs out of time defers only files it would actually
have read, so a truncated pass cannot buy a whole re-read for a file the index
already covers.
The sweep cadence subsumes root recovery: a full sweep runs on start and every
`fullSweepEveryCycles` after it, so a root that comes back is picked up by the
next one instead of by a flap-bounded re-arm rule.
* test(ai-vault-search): prove close disarms the timer, not only the store
Removing `loop.close()` from `close()` failed no test: the unregister already
stopped a later scan reaching the index, so the surviving timer and the task
queued behind it were invisible. The close test now asserts the timer is gone
and that advancing the clock past it reports nothing, which is what a pass
running against a shut store would have done.
* refactor(ai-vault-search): drop the options and fields nothing reads
`retirementChecksPerCycle` had no caller in the stack, so the reconciler keeps
its own constant; the error reporter is only needed while the store and the
loop are being built, so it stops being a field.
* refactor(ai-vault-search): let indexedSources walk the table it is asked for
The per-path arm existed for `invalidate()`, which had to resolve a path the
index might never have held. Only the sweep reads this now, and it reads all
of it.
* fix(ai-vault-search): never call a half-written file current
PR 2 now reports a file a chunked read left half written with a null cursor
under the whole file's mtime and size, so the freshness check has to start at
`requiresWholeRead`: comparing only the stat calls a prefix current and leaves
it in the index for good. Two consequences, one test each. The skip check no
longer skips such a file, and the took-it check no longer reports it indexed,
so it stays owed until a read finishes it. The pass reads it whole rather than
appending, which repairs it in one pass instead of waiting for the consumer to
decline an append it was never going to take.
* fix(ai-vault-search): four ways a pass reached the wrong conclusion
Round 10, each reproduced on 6a1bcfdf49 first and each repro kept as a test.
A sweep watched only what it could not settle, which is nothing on a healthy
machine, so the cycle after a sweep had no deletion candidates and the cycle
after that no longer remembered the file. A transcript deleted in that interval
survived until the next periodic sweep, five minutes later. The sweep now seeds
the watch set with its own recency window, taken from its own discoveries
through the same class discovery selects with, so there is no second spelling
of the rule and no second walk of the trees.
A transcript the reader cannot open was recorded stale by the consumer on every
attempt and re-read every cycle for ever. Three failures at one unchanged stat
now hold a file out until that stat moves, which is the only thing that can
mean it changed. `failures`, a tally of attempts that climbed without bound,
becomes `unreadableFiles`, a gauge of files being held; a non-zero value is
degradation, because waiting will not close that gap.
`close()` part way through a pass left the pass reading a shut handle and
reported three database errors to the owner who asked for the close, and
`status()` afterwards opened it again to answer zero files. The pass stops at
the cancellation the close raises, and a closed indexer reports what it last
knew. `indexedSources` throws rather than answering with an empty list: its
caller is a sweep deciding what nothing rediscovered, and an empty answer is
the one conclusion an unreadable handle must not reach. A sweep that threw puts
its own flag back, so something is still armed to try again.
`droppedPending` was a lifetime tally under a doc that promised it meant the
queue was incomplete until the next sweep. A completed sweep now clears it, and
`bytesIndexed` resets per pass rather than per sweep, so it stops sawtoothing
every fifteen cycles.
Two indexers on one database both registered with the reader and wrote every
transcript twice. The second construction throws.
* test(ai-vault-search): prove the three conclusions a broken pass must not reach
Three round-10 mutations survived the first pass of tests, all of them the same
shape: a pass that failed still reached a verdict, and nothing checked.
The drop reset moves into the sweep itself, where a test holding the store can
overflow the queue and watch a completed sweep clear it; from the indexer the
call was unreachable without twenty thousand files.
A sweep whose held-file read throws now has a test that the failure travels
rather than being folded into an empty list, and a sweep that threw part way
has one that the flag saying a sweep is owed comes back.
* feat(ai-vault-search): put what a file still owes on the file's own row
Three additive columns on `files`, and the consumer writes them. `state` is
'current', 'due' or 'failed'; `fail_count` and `failed_mtime_ms` are what stop
an unreadable transcript being retried on every pass for ever. Schema version 4,
so a stale index rebuilds.
Every refusal now leaves its record on the row rather than in a map beside it. A
declined append is 'due': the index is behind on a span no append reaches, so
the next pass reads the file whole. A read that started and did not commit is
'failed', counted, and stamped with the stat it failed at, because a transcript
the reader cannot open fails identically every time and only a change to that
stat can mean the file itself changed. A path the file table does not name needs
no record at all: the next pass reads it because the index holds nothing for it.
Deleted with the in-memory set they served: `markStale`, `takeStale`,
`pendingFileCount`, `droppedPendingFileCount`, `forgetDroppedPending`,
`setAcceptingWrites`, `acceptsCandidate`, `STALE_PATH_LIMIT`. Added: `files()`,
`setFileState()`, `stateCounts()`, `retentionCutoff`. The retention gate moves
into `beginWrite`, because the consumer observes every read the session list
makes and not only the ones the index asked for.
The indexer rewrite that consumes this surface is the commit after; this one is
kept to the store, the schema and the consumer so PR 2's own final commits can
rebase over it.
* fix(ai-vault-search): count a failure for a file the index never held
The common unreadable transcript is one no read ever got through: a file behind
the wrong mode bits fails on its first attempt, so there is no row to count the
failure on and it would be read again on every pass for the life of the process.
The failure now inserts its own row, holding a zero cursor and no session, which
is what "the index holds nothing for this file" already looked like.
* refactor(ai-vault-search): make the store the indexer's only memory
Design v3. Every question a pass asks between passes is a row in `files`: what
is owed a read, what has failed and how often, what the index holds and
therefore what may have been deleted, what to report. Two things outlive a pass
and are not rows -- the timer, and one bit per root for the retirement walk's
grace -- and both are named in the class doc.
One loop, four steps. Discover: the only filesystem walk, every root on a sweep
and the newest N per agent on a cycle. Decide: the candidate's stat against its
row, as one pure function with its own test. Retire: the rows discovery did not
return, inside the scope it covered, through the unchanged walk. Report: a
`GROUP BY state` over the same rows.
`session-search-backfill.ts` and `session-search-reconciler.ts` become one
`session-search-pass.ts`, because the two differed only in discovery scope.
Deleted with them: the watch set redefined three times, the hold-out map, the
`sweptClean` latch, `session-search-indexing-status.ts` and every counter with a
rule about when to reset, `session-search-unreadable-files.ts`, and PR 3's
addition to `session-search-file-cursor.ts`, which the decide step replaced.
Two things the design did not anticipate, both found by its own tests. A cycle
lists the newest N per agent, so a backlog outside that window is invisible to
it and a first run would have crawled: a pass that runs out of time now asks for
a sweep, which is self-limiting because the first pass that finishes its reads
hands the interval back. And a cycle's retirement candidates are the newest
rows under the roots it listed, capped, so a deletion inside the recency window
is proven on the next cycle whenever it happened rather than waiting for a
sweep.
* test(ai-vault-search): make the retirement cap test prove its ordering
Removing the newest-first sort from a cycle's retirement scope failed nothing:
the fixture wrote its transcripts oldest first and the sweep indexed them
newest first, so the table's own row order already put the oldest last and an
unsorted slice happened to reach the same answer. The oldest file is now
indexed on its own first, which makes it the earliest row as well as the oldest
file, and the two orders disagree.
* fix(ai-vault-search): take schema version 5 for the three files columns
PR 2's final pass took version 4 when it dropped conversation_fts, and the
rebase merged both bumps into one number. The columns take 5. Two smaller
things the same rebase left behind: the stub store in the identity test still
declared the two methods the consumer no longer calls, and STALE_PATH_LIMIT
outlived the set it bounded.
This sits one commit above the columns rather than inside them, because the
rebase onto abeccc5905 was the one history rewrite that was authorised.
Whoever cherry-picks the store commit needs both.
* refactor(ai-vault-search): drop the file count nothing reads, and say what memory is left
`store.indexedFileCount` has no caller: the status reports the state counts and
PR 4 reads sessions.
The class doc claimed two things outlive a pass. Six do, and each is now named
with the reason it cannot be a row: the grace bit, the two timer fields, the
three the last pass observed for `status()` to answer between passes, and one
cached query result read only after a close. A slogan that undercounts is worse
than a list, because the next round has to rediscover what it left out.
* fix(ai-vault-search): release the database path when the open throws
The claim on a database path was staked before the store opened it, and a
construction that throws has no close() to release it. One failed open -- a
directory where the file should be, a corrupt header, a permission -- left the
path owned by an object that does not exist, and every later construction was
refused for the life of the process, including the one that would have fixed
whatever broke the open.
* fix(ai-vault-search): record a source no read can ever index
An OpenCode SQLite session decodes where the message channel cannot reach it,
so no read of one will ever commit a row, and the consumer declined it without
writing anything. That left the file table silent about a source discovery
returns on every pass: the decide step saw a path the index held nothing for
and asked for a read, and asking for one over a warm cache drops the session
list's own resume point. Every OpenCode session was fully decoded on every
pass, and the sidebar's cached fold was thrown away with it -- the cache
STA-1278 and STA-1417 added.
The decline now writes the row the store already has a shape for: a read that
went through and decoded no session, cursor at the file's size, no session row.
The decide step skips it until its stat moves, and the retirement walk retires
it like any other row. Nothing in the decide step knows what OpenCode is.
* fix(ai-vault-search): bound the retirement walk by directories, not by rows
A directory that cannot be listed answers `unverifiable` for every row under
it, on every pass, for as long as the permission stays wrong. Counting rows
against the cap let five hundred such rows spend the whole budget on one
readdir's worth of verdicts: a row for a file the user really deleted, sorted
behind them, was never reached on any pass. Six full sweeps and it was still
held; at a hundred rows the same file retires on the first.
The cap now counts the directories a walk asks for, which is what actually
costs a read, and it is spent only by a row that starts somewhere the walk has
not been. Rows sharing a directory are one read and then map lookups, so a
block of them can no longer crowd out anything.
The comment claiming the leftovers "keep being watched" went with it. There is
no watch set: a row the walk did not reach is simply still undiscovered on the
next pass, which is what makes finishing over several passes safe.
* fix(ai-vault-search): normalize indexer ownership paths
Orca
中文 · 日本語 · 한국어 · Español · Français · Português
The AI Orchestrator for 100x builders.
Run Codex, ClaudeCode, OpenCode or Pi side-by-side — each in its own worktree, tracked in one place.
Download Orca
Features
Also in the box:
- Quick open — Search across worktrees, files, agents, commands, and repo context without leaving your flow.
- Account switcher & usage tracking — See Claude and Codex usage and rate-limit resets, and hot-swap accounts without re-logging in.
- Rich repo previews — Preview Markdown, images, PDFs, and repo docs in the workspace.
- Computer Use — Let agents operate desktop apps and visible UI when a workflow needs real interaction.
- Notifications and unread state — Know when an agent finishes or needs attention, then mark threads unread to come back later.
- And many, many more — we ship daily, so this list is perpetually behind. The changelog is the real feature list.
Supported Agents
Works with any CLI agent — if it runs in a terminal, it runs in Orca.
Claude Code
Codex
Grok
Cursor
GitHub Copilot
OpenCode
MiMo Code
Amp
OpenClaude
Antigravity
Pi
oh-my-pi
Hermes Agent
Devin
Goose
Auggie
Autohand Code
Charm
Cline
Codebuff
Command Code
Continue
Droid
Kilocode
Kimi
Kiro
Mistral Vibe
Qwen Code
Rovo Dev
+ any CLI agent
Install
Desktop — macOS, Windows, Linux
- Download from onOrca.dev
- Or grab a build directly: macOS Apple Silicon · macOS Intel · Windows (.exe) · Linux AppImage · All builds
- Running
orca serveon a headless Linux server? See the headless Linux server guide.
Or via a package manager:
# macOS (Homebrew)
brew install --cask stablyai/orca/orca
# Arch Linux (AUR) — or stably-orca-git to build from source
yay -S stably-orca-bin
Mobile Companion — iOS, Android
Pair with your desktop app to monitor and steer your agents from your phone.
- iOS: Download on the App Store or join TestFlight
- Android: Download APK 0.0.48 · Install guide
Community & Support
-
Discord: Join the community on Discord.
-
Twitter / X: Follow @orca_build for updates and announcements.
-
WeChat: Scan to join the Orca community WeChat group 8. Group 8 may be full; if so, scan the Group 9 QR code instead.
-
Feedback & Ideas: We ship fast. Missing something? Request a new feature.
-
Privacy: See the privacy & telemetry docs for what anonymous usage data Orca collects and how to opt out.
-
Show Support: Star this repo to follow along with our daily ships.
Developing
Want to contribute or run locally? See our CONTRIBUTING.md guide.
The relay that pairs the mobile app with a desktop host is also in this repository under
cloud/, with a separate pnpm workspace and setup guide.
Signed Builds
Windows code signing sponored/provided by SignPath.io, certificate by SignPath Foundation.
License
Orca is free and open source under the MIT License.












