ken-jo/local-indexer
Local Indexer 문서 파일 자동 인덱싱 및 문서 검색 익스텐션. 폴더 감시(chokidar), PDF/Markdown 파싱, 한국어 형태소 분석(kiwi), FTS5 기반 검색. 인덱스 위치: 익스텐션 데이터 디렉터리의 index/
Changelog
[0.7.0] - Unreleased
Requires Ave 0.14.0 and host protocol 3 (requires.hostProtocol: "3").
Apps that serve protocol 2 (0.12.x, 0.13.x) keep 0.6.9.
Changed — host protocol 3
- The manifest declares
hostProtocol: "3"andminAppVersion: "0.14.0", the Python runtime asmanagedBy: "ave", and the path-bearing tool arguments under theave/pathFieldsannotation. - The host home comes from
PluginRuntimeContext.aveHome; the default home, the corporate CA path and the Kiwi user dictionary move from~/.lvisto~/.avewith the host. - The panel reads the host's
--ave-*theme tokens. - The display name is "Ave Local Indexer". Tools, settings, events and stored data are unchanged.
[0.6.9] - 2026-10-09
Requires LVIS 0.12.0 and host protocol 2 (requires.hostProtocol: "2").
LVIS 0.11.x and earlier keep 0.6.8.
Changed — provider egress through per-request leases
- The embedding and caption broker resolves a host key lease for each provider
request, sends it through
hostFetch, buffers the response and releases the lease. The Python worker receives only its broker token. A revoked or cancelled lease ends the request; it does not fall through to another transport. The OpenAI SDK's built-in retries are off in broker mode, so only the worker's own retry policy applies. - Startup and warm-up claim provider presence only when the host issued an embedding lease.
- The manifest declares its sandbox: read access to the runtime and CA bundle
directories, write access to the user-chosen
indexStorageRootandworkspacedirectories (both new string settings; empty keeps the default location).
Added — alerts
- Two summary-only alert kinds, off until you turn them on in the extension's
Alerts settings:
index-scan-completedandindex-folders-changed. They carry no local paths. The nightly-rescan suggestion is declared inalerts.suggestions.
Removed
- The OS notifications for
index.scan.completedandindex.folders.changed(notificationEvents), which LVIS 0.12.0 no longer accepts. The bus events are unchanged.
[0.6.8] - 2026-10-09
Changed — dependency bumps ported from lvis-plugin-local-indexer
- Python worker: openai 2.54.0 → 3.14.1. openai 3 uses HTTPX2 and no
longer installs
httpx, so the egress-broker Unix-socket transport inworker/embedding_client.pymoved tohttpx2(now a direct dependency, 2.13.1).test/egress_broker_uds_transport.test.pydrives the real SDK over the socket. - Python worker: lancedb 0.37.1 → 0.38.0, pillow 12.1.1 → 12.3.0,
pyarrow 25.0.0 → 25.0.1, uvicorn 0.52.4 → 0.53.0. The lock is
regenerated with
uv pip compile. - Dev: vitest and @vitest/coverage-v8 4 → 5.0.1.
[0.6.7] - 2026-09-24
Changed — built in the official marketplace repository
- The source moved into
ken-jo/agent-marketplaceunderplugins/<name>/source/, and releases are built there: the zip is a release asset of that repository, pinned by sha256 in the plugin's descriptor. @lvis/plugin-sdkis gone. Types andflattenAgentPluginsManifestcome from the host's own contract, vendored at lvis-app v0.10.0 (@lvis/plugin-contract), and the tsup preset from@lvis/plugin-build.installPolicyandpublisherare removed fromplugin.json: the git marketplace installs and locks nothing through a package's own manifest.requires.minAppVersionis0.10.0, the contract this version compiles against.
[0.6.6] - 2026-09-04
Fixed
- The Python worker outlived a host that died abnormally (SIGTERM, crash,
kill): it was reparented to the init process and kept serving forever, one orphan per past host run. The host now spawns the worker with stdin as a liveness pipe — it carries no bytes and the OS closes it only when the host process is gone, in every death mode — and the worker watches that handle: on EOF it stops the server the way/shutdowndoes (WAL checkpoint, storage close) and exits after a short grace period even if the server is wedged. Whether the pipe is present is read from the handle itself (the socketpair end Node hands the child on macOS/Linux or the named pipe on Windows arms the watchdog; a character device such as/dev/nulldoes not, since it reaches EOF immediately), so a host that still spawns with stdin ignored is unaffected.
[0.6.5] - 2026-09-03
Changed
- The document tools belong to this plugin alone. The
worker-clientcapability and thegetWorkerClient()the plugin object handed the host are both gone: the plugin runs out of process, so the host only ever leased a proxy that carried no such function, and its own knowledge tools were skipped on every boot while the capability declaration made the skip read as a plugin defect. Nothing on the host side consumed the handle, so removing it costs no behaviour — the same worker client still backs everyindex_*handler from inside the plugin. index_get_documenttakesincludeStructure, which returns the page numbers that actually carry indexed chunks. That inventory is what the retired hostdocument_structuretool returned, and it is not a restatement ofdoc.pageCount: pages the parser dropped never appear in it, so a caller picking apagesrange no longer spends a round trip on a page the index does not hold. It stays an option on the existing tool rather than a seventh tool, because the model-visible surface is a deliberate six.
Fixed
- The egress broker bound a Unix-domain socket on every platform, so on
Windows the plugin never became healthy:
server.listen({ path })there is a named pipe, whose address has to live under\\.\pipe\, and libuv refuses a filesystem path withEACCES— leaving no socket file behind, which is how the failure was told apart from an NTFS permission problem. How the broker binds is now one decision made from the platform: loopback TCP on an ephemeral port for Windows, the Unix socket in the host-registered directory everywhere else, where a confined plugin child cannot bind loopback at all. On the TCP transport the worker is handed the real bound URL and no socket path, which is the address its provider SDKs already dial when no socket is pinned; the per-session token is checked before any routing on both transports, so the loopback port is no more reachable than the socket.pluginSocketDiris required only on the platforms that bind a socket in it. (#272)
[0.6.3] - 2026-08-29
Fixed
- The image captioner labelled any file with an unknown extension as
image/pngand forwarded it whole. It now accepts only.png,.jpg/.jpegand.webp—.gifis dropped, because the providers take only non-animated GIF and the magic bytes cannot tell the two apart — refuses a file over 20 MB (Azure OpenAI's per-image vision limit, applied to both providers) onst_sizebefore reading it and again on a bounded read, so a file still growing under a watched folder cannot slip past the stat, and refuses a body whose magic bytes do not match its extension. A caption model is validated against the naming rule of the provider that will receive it (OpenAI model id, or Azure deployment name) before any SDK client is built. The BLIPlocalprovider, unreachable since the local indexer was removed, is deleted along with itstransformers/Pillow imports. (#111) set_image_captioningread an emptymodelas "keep current", so a provider switch pushed with the new provider's default ("") carried the previous provider's model — and endpoint, and api-version — across. Those three fields are now tri-state on the wire:null/omitted keeps,""resets to the provider default, a value sets. The host already sends every field on every push;WorkerClient.setImageCaptioningnow types them asstring | nullwith no defaults so a caller decides. On the host side an Azure value that is derived from the host key handle and could not be derived (the handle is refused transiently) is sent asnull, keep — only an empty value the user configured is sent as"". The removedlocalprovider is gone from the enum on both sides. (#112)
Changed
- CI runs the Python worker suite (
pytest test/, Python 3.12, locked dependencies via uv) alongside the shared TypeScript job; until now only the retrieval gate ran.pillowis declared directly — openpyxl only reads workbook images when it is importable, so the XLSX image path had never run. A test that removed the SQLite parent directory withrmdirassumed no WAL sidecars and failed on every platform; it removes the tree. (#113)
[0.5.36] - 2026-08-24
Fixed
- The image-caption radio labels in the settings panel showed mojibake instead
of Korean. They were written that way in
dac772aand have been wrong in every release since.
Changed
- Run the shared
plugin-ci.ymlfromlvis-project/.githubinstead of the copy of it that lived in this repo. The copy had drifted: unpinned action refs (@v7,@v2.2.0), a GitHub-hosted runner instead of the CI runner group, and no job timeout.
[0.5.35] - 2026-08-24
Changed
- Pin the shared reusable workflows to
6200e124, a commit that is still reachable. The previous pins were orphaned by a history rewrite inlvis-project/.github, so every Actions run — including the0.5.34publish — died in 0s with workflow was not found. The workflow files are byte-identical at the old and new SHAs; only the name of the commit changed. - Move the
@lvis/plugin-sdkdevDependency pin tov12.1.0, the first release carrying the loopback grantplugin.jsonrelies on. This supersedes the unpublished0.5.34, whose contents it otherwise matches.
[0.5.34] - 2026-08-24
Fixed — the embeddingEndpoint setting can reach a local server
- The setting exists for an OpenAI-compatible endpoint the user runs themselves
(LM Studio, LiteLLM, an internal proxy), and the plugin already accepted
http://loopback forembeddingProvider="openai". The host did not: every request went out throughhostApi.hostFetch, which was https-only over an allow-list that could not spell a loopback host, so the endpoint was denied before it was reached and every document ended in a 504. networkAccess.allowedDomainsnow declareslocalhost,127.0.0.1and::1. That declaration is the grant: the host permits cleartext http to those hosts only because they are named here, and only after proving the name resolves to loopback and nothing else. It opens neither the LAN nor any other host, and the host key is still withheld from a local endpoint — only the plugin's own key may be forwarded there.- Raise
requires.minAppVersionto0.6.3, the first app release whose manifest validator accepts the loopback literals.
[0.5.33] - 2026-08-19
Fixed — await the host keychain read for provider API keys
hostApi.getSecretresolves asynchronously, so reading its result directly handed thetypeof s === "string"guards a pending Promise: both the plugin-own slots (embeddingApiKey/imageCaptionApiKey) and the brokered host slots (llm.apiKey.openai/llm.apiKey.azure-foundry) read as absent.- Scans silently degraded to BM25-only and the settings panel reported no API key for a key the user had saved.
- The egress channel thunk resolves its key host-side per request, so it now returns a promise; the broker's request handler already awaited its work.
[0.5.31] - 2026-08-03
Changed — require atomic host-managed Python candidate activation
- Publish the Local Indexer bundle for LVIS app
0.5.11, where Marketplace candidates receive their host-managed Python executable before activation. - Raise
requires.minAppVersionto0.5.11so older hosts cannot install this release and fail during the hidden candidate start.
[0.5.26] - 2026-07-23
Added — plugin-owned first-run onboarding
- Declare bounded English/Korean first-task copy with explicit priority
20; acceptance only prefills the visible composer through the host contract. - Raise
requires.minAppVersionto0.5.7and align the SDK pin tov11.1.0for the declarative onboarding schema.
[0.5.23] - 2026-07-11
Changed — align to @lvis/plugin-sdk v8.0.0
- Bump SDK pin
github:lvis-project/lvis-plugin-sdk#v7.0.0→#v8.0.0; refreshbun.lock. - v8 removed the
runtime/electronandruntime/networkshim modules (they moved into the host'shostApi). This plugin has no shim call sites, so this is a pure pin bump — no source changes, nominAppVersionfloor change.
[0.5.18] - 2026-06-29
Fixed
- P1 — ledger parent-dir created with 0o700.
writeRescanLedgerProposedpreviously calledmkdir(dirname, {recursive:true})without amode, leaving the directory world-readable when created fresh (umask ~0o755). Now passes{recursive:true, mode:0o700}to match the storage-namespace rule (dirs 0o700, files 0o600). The ledger file was already written 0o600. - P2 —
countRegisteredFoldersbase-path consistency.folders.jsonentries were resolved via bareresolve(f.trim())(againstprocess.cwd()), whilescanFoldersare resolved againstcontext.hostRoot. A relative folder present in both sources would not deduplicate in theSet, inflating the count. Now both sources are resolved againstcontext.hostRootso the dedup is correct. - P3 —
rate_limitedno longer locks the propose-once ledger. PreviouslyisStagingSuccesstreatedrate_limitedthe same asduplicate, recordingproposedAtand permanently suppressing future proposals. A rate-limited overlay may never have surfaced to the user; locking the ledger for it loses the proposal forever.rate_limitednow falls through to the transient-denial path (alongsidecapability_denied,invalid_source,loop_unavailable) so the next wakeup retries. Onlyacceptedandduplicate(overlay actually staged/deduped) lock the ledger.
[0.5.17] - 2026-06-29
Fixed
- minAppVersion corrected to
0.4.2. 0.5.16 declaredminAppVersion: 0.5.0as a forward reference, but the host capabilities this plugin depends on (hasRoutineBySource, the routinesourcemarker, and session-scoped on-demand plugin activation) shipped in lvis-app 0.4.2 (released 2026-06-29). The 0.5.0 floor would have made the plugin refuse to install on the released app.0.4.2is the first released app version containing those capabilities (capability-before-publish ordering).
[0.5.16] - 2026-06-29
Added
- Daily Help suggestion to set up a nightly rescan routine. On launch, if
there are registered folders, no existing rescan routine
(
hasRoutineBySource), and the suggestion has not been proposed before, the plugin proposes (viatriggerConversation) a once-daily 22:00 routine that runsindex_scan(incremental: mtime + content-hash short-circuit + orphan sweep). Accepting it creates anllm-sessionroutine (leaves session history- a per-run summary). Propose-once: never re-proposed once a routine exists or
the suggestion was shown (ledger at
~/.lvis/plugins/local-indexer/). The routine's prePrompt requests on-demand activation (request_plugin) if the plugin is disabled at fire time. Requires the host capabilities in app 0.4.2. Addshost:overlay+lifecycle-observercapabilities; re-pins the SDK to v5.19.0 (hasRoutineBySource).
- a per-run summary). Propose-once: never re-proposed once a routine exists or
the suggestion was shown (ledger at
[0.5.15] - 2026-06-28
Security
- Host-mediated egress broker — remove the provider key from the Python
worker (P3 ⑬). The worker is untrusted for credentials (it parses
attacker-controlled documents and runs a large transitive dependency tree
in-process). It previously held the real provider key in memory across four
holders and could open arbitrary outbound sockets. The new
EgressBroker(src/egressBroker.ts, owned byLvisLocalIndexerPlugin) closes both:- The plugin starts a loopback broker BEFORE spawning the worker and passes
the worker only the broker base URL (
--egress-broker-url) plus a per-session token (pushed over the control channel). The worker's four credential holders now carry only the token, never the provider key. - The worker addresses
<base>/egress/embeddingand<base>/egress/captionand can supply only{ path-remainder, body }. The broker resolves the upstream host host-side per channel (trusted defaults or the SSRF-validated custom endpoint), so a compromised worker cannot redirect the credential to an attacker host. - The broker reuses the P0 trust chokepoint verbatim
(
resolveEmbeddingKeyForEndpoint/resolveCaptionKeyForEndpoint): a custom untrusted endpoint still receives the plugin-own key, never the host key. - Token auth via
x-lvis-egress-token+crypto.timingSafeEqual(missing / wrong ⇒ 403, blocking other local processes). The broker uses an allow-list to drop all worker-supplied headers (preventing gzip-confusion attacks and arbitrary header injection) then injects the real provider auth host-side, auth-scheme aware per channel (OpenAIBearer, Azure OpenAIapi-key). The real key never appears in a response body returned to the worker.node:https.requestdoes not auto-follow 3xx redirects, so a rogue upstream cannot steal the injected key via Location. - A host-side allow-list gate (
isTrustedEndpointUrlor the validated custom endpoint) refuses any non-allow-listed upstream (403). Corporate-CA trust moved to the broker's upstream client (it terminates provider TLS now), and the worker'sOPENAI_API_KEYdev opt-in is gated OFF when a broker is configured (it must not bypass the broker). - Transport-switchable from day one (loopback TCP for the MVP, UDS-ready) so the ASRT/netns end state is an additive flip, not a rewrite.
- The plugin starts a loopback broker BEFORE spawning the worker and passes
the worker only the broker base URL (
Changed
- Route renamed
/indexer/set_embedding_api_key→/indexer/set_egress_token(body fieldapi_key→token) for honesty — the worker↔host contract moves together in this single-repo change. The pushed value is the broker token in broker mode (the embedding key on the legacy no-broker path).
[0.5.14] - 2026-06-28
Changed
- Rebuilt the retrieval eval gate to MEASURE real embedding/reranker/OCR
quality (Stage-0). The old gate injected a fake
DeterministicEmbedder(256-dim char-trigram) over a 25-doc, ceiling-saturated corpus, so its Recall@10 = 0.975 / nDCG@10 = 0.9584 measured ONLY the lexical path (Kiwi + FTS5 + RRF) — it said nothing about embedding quality. Citing it as an embedding-quality number was the over-claim caught in P1 #187. The gate is now split into two:- Real-embedding env hook in
eval/run_eval.py:build_embedder()uses the productionOpenAIEmbeddingClient(the worker's realembedding_client.py) whenEVAL_EMBEDDING_ENDPOINT(+EVAL_EMBEDDING_API_KEY, optionalEVAL_EMBEDDING_MODEL/EVAL_EMBEDDING_DIMENSIONS) is set, and falls back to the offlineDeterministicEmbedderwhen it is not (so the default/offline PR gate runs with no key).run()takes anembedderDI seam; the result records which embedder ran. - De-ceiling corpus: grown 25 → 118 docs (117 Korean-heavy
.mdacross 12 vocabulary clusters + 1 OCR-target.txtfixture) and 20 → 43 graded queries (incl. paraphrastic queries whose gold doc is not the trivial top lexical hit). The lexical baseline is now recall@10 = 0.7558 / nDCG@10 = 0.7117 — intentionally NOT saturated, leaving headroom to MEASURE an embedding/reranker lift on the nightly job.
- Real-embedding env hook in
- Relabeled the deterministic gate (
test/retrieval_eval.test.py) as the lexical-path regression guard ONLY — it now injectsDeterministicEmbedderexplicitly (hermetic regardless of env) and asserts so, and its docstring +eval/README.mdstate it does NOT measure embedding/reranker/OCR quality.
Added
- Nightly real-embedding CI job (
.github/workflows/retrieval-eval-real.yml) — runsrun_eval.pywith the real embedding client on a schedule (03:00 KST) and on demand, records real Recall@10 / nDCG@10 as an artifact + job summary, and skips gracefully when noEVAL_EMBEDDING_API_KEY/OPENAI_API_KEYsecret is present. A maintainer must add that secret (Settings → Secrets) to enable nightly real-embedding measurement. - OCR-target text fixture
eval/corpus/ko-108-scanned-expense-receipt-ocr.txt- query
q-ko-36so the OCR recall path can be measured once OCR lands.
- query
test/eval_real_embedding_hook.test.py— covers the env hook (deterministic fallback without a key; real client correctly configured when the endpoint is set, verified config-only with no network call).eval/README.mdrunbook: the two-gate split, how to run the real-embedding eval locally, how to add corpus docs/queries, and the nightly-secret setup.
[0.5.13] - 2026-06-28
Fixed
-
DOCX page=1 collapse — boundary-loss bug fixed.
_parse_docxpreviously extracted all text from every page of a.docxfile but assigned it all topage=1, losing page-boundary information. All content was indexed (no text was dropped) but every chunk reported the wrong page number, making page-level search results misleading for multi-page documents.The parser now uses python-docx ≥ 1.2.0 to detect page boundaries in two complementary passes across body children (paragraphs + tables in document order):
- Rendered breaks (
w:lastRenderedPageBreak): detected viaRun.iter_inner_content()yieldingRenderedPageBreakobjects — written by Microsoft Word on save. - Hard breaks:
paragraph.paragraph_format.page_break_beforeand<w:br w:type="page"/>runs — produced by LibreOffice, Google Docs export, andpython-docx'sadd_page_break().
When zero breaks of either type are detected (single-page or freshly generated DOCX without explicit markers), the document is treated as a single page and the condition is logged (
indexer.docx.no_page_breaks) rather than silently discarded.python-docx is a pure-Python wheel with no native extensions. The
default.docxtemplate resource inside the wheel is not a native binary; installation succeeds under standard corporate EDR policies. - Rendered breaks (
Added
python-docx >= 1.2.0topython-requirements.in.test/docx_parser.test.py— 13 new tests covering hard breaks,page_break_beforeformat, no-break fallback + log, table content on correct page, no content loss, and end-to-endindex_filepage-count assertion.
[0.5.12] - 2026-06-28
Added
- Boot reconcile pass — heal SQLite↔LanceDB vector drift. On startup the
host now calls
POST /indexer/reconcileto detect and heal the residual crash window that the content-hash-after-Lance ordering narrows but cannot fully close:index_filecommitscontent_state='ok'+ theembeddings_metarows BEFORE the Lance upsert, so a SIGKILL / OOM / power-loss between the sqlite commit and the Lance upsert leavesembeddings_metarows whosechunk_idhas NO Lance vector (and the doc silently has no vectors until something touches the file).LocalIndexer.reconcile()compares theembeddings_metachunk-id set against the Lance chunk-id set, both directions: meta-without-vector → the owning doc is marked for re-embed (content_hashforced NULL +content_state='embed_failed') and returned to the host; orphan vector (a Lance chunk-id with noembeddings_metarow) → deleted from Lance.- Detect is key-free (detect + normalise only, no embedding); heal (re-embedding the drifted docs via a bounded reindex) runs lazily in the background warmup AFTER the warmup embedding-key push, and is deferred when no key is available yet (docs stay BM25-only until a future cycle). The re-embed is bounded to 50 drifted docs per cycle to cap the cost on large drift; the remainder heal on the next startup.
- Honest
embed_faileddocs carry noembeddings_metarows, so they are never falsely flagged.embeddings_metais the drift sensor and is kept.
_LanceVectorStore.list_chunk_ids()— column-pruned enumeration of the chunk-ids that actually have a vector in Lance.WorkerClient.reconcile()+ the/indexer/reconcileworker route.
[0.5.11] - 2026-06-28
Added
-
Configurable file-size guardrail (
maxFileSize, default 50 MB). Files exceeding the limit are recorded asstatus='failed'in the index and surfaced to the user as an explicit failure — not silently skipped. The limit, the actual file size, and the configured cap are all included in the error string so the failure is immediately actionable. Configurable viaplugin.jsonconfigSchema (maxFileSize, integer bytes;0disables the check). Passed to the worker as--max-file-size. -
Configurable chunk-count guardrail (
maxChunksPerDoc, default 2000). Documents that generate more chunks than the cap are truncated to the cap. The truncation is recorded explicitly inIndexResult.errors[]so it surfaces to the user rather than being a silent drop. Truncated documents are still indexed (the leading chunks remain fully searchable) and returnstatus='indexed'— an outright failure is inappropriate here because partial indexing is better than no indexing for large documents. Configurable viaplugin.jsonconfigSchema (maxChunksPerDoc, integer ≥ 1). Passed to the worker as--max-chunks-per-doc.Enforcement points:
LocalIndexer.index_file()— file size check before any parsing (os.path.getsizecalled once, result inindexer.file.oversizelog event).LocalIndexer.index_file()— chunk count cap applied after_chunk(), before tokenisation, embedding, and persistence.
No scan-concurrency cap — the host scan loop (
folderIndexer.ts:370) is already serial; there is no concurrency to cap.
[0.5.10] - 2026-06-28
Changed
- Eager background indexing — dropped the vestigial worker queue and the
idle-deferral wiring. Watcher file events now index through the exact same
direct path as a folder scan (
indexIfChanged→/indexer/reindex) instead of being enqueued and deferred to host idle time. Indexing runs eagerly the moment a change is detected, off the user's interactive path: the worker is a separate process and the host dispatches scans/watcher events fire-and-forget (void handleFileEvent), so the user never waits on indexing. /indexer/reindexis now the sole ingestion route. It also counts failures in thefailedmetric (previously only the removed/process_onedid).
Removed
- Worker routes
POST /indexer/enqueue,POST /indexer/process_one,GET /indexer/state, andPOST /embed/batch_flush, plusAppState.queue/_seqand theEnqueueRequest/ProcessOneRequest/EmbedBatchFlushRequestmodels. The host drainedenqueue→process_onein lockstep, so the worker-side priority queue (P0–P4) and the no-op batch-flush were dead weight. WorkerClient.enqueue/processOne/getIndexerState/batchFlushand their result types.FolderAutoIndexer'sIdleSchedulerinterface,idleSchedulerfield,setIdleSchedulersetter, and the host plugin'ssetIdleSchedulermethod — the file-event queue branch is gone, so events flow straight throughindexIfChanged.- Companion host change (
lvis-app): the indexer's idle-scheduler wiring (idleWorkerAdapter+setIdleSchedulercall). The sharedIdleSchedulerServicestate machine is preserved for its non-indexer consumers (preference-refresh, post-turn, conversation-loop).
[0.5.9] - 2026-06-28
Added
- Doc-level content-hash short-circuit — skip re-embedding unchanged docs.
The host re-indexes a file on any mtime change, but the most common triggers
(an editor save with no edits, a metadata-only write, a cloud-sync touch)
leave the file's visible content byte-identical. Previously every such touch
re-paid the full embedding API cost.
documents.content_hashnow stores a sha256 over the document's normalized chunk text. On re-index, after chunking- tokenizing but before the embed call,
index_filerecomputes the hash and, if it matches the stored value and the prior run embedded cleanly (content_state == 'ok'), it reuses the existing chunks/vectors: the embed call and the SQLite/lance re-persist are both skipped and onlymtime_msis advanced. The result carries a newunchanged=Trueflag.
- embed_failed retry guard. Docs in
content_state == 'embed_failed'(text indexed, vectors missing) are never short-circuited — an unchanged-content re-index re-attempts embedding so a transient endpoint outage self-heals on the next scan. - reindex teardown moved.
reindexno longer deletes the document's rows up front; it delegates toindex_file, which resolves the existingdoc_id, runs the short-circuit, and tears down the prior chunks_fts + lance vectors only when the content actually changed. This is what lets an unchanged doc avoid being torn down and rebuilt, and keepsdoc_idstable across re-indexes. - Migration.
documents.content_hash TEXTis added idempotently (PRAGMA table_infoguard +ALTER ... ADD COLUMN, mirroring thecontent_statemigration). Legacy rows backfill to NULL, which is treated as "changed" so the first re-index after upgrade recomputes and stores the hash. No rows are dropped. - Crash-window safety.
content_hashis written to thedocumentsrow only after the lance vector upsert succeeds (not in_persist_sqlitebefore it). A SIGKILL/OOM between the sqlite commit and the lance upsert therefore leavescontent_hash=NULL, which the short-circuit treats as "changed" — forcing a full reindex that self-heals the missing vectors. A row withcontent_hashset but no vectors in lance is impossible through any production code path. Proven bytest_crash_window_content_hash_set_only_after_lance_successusing an ordering-probe vector store (mutation-verified: patching_persist_sqliteto write the hash eagerly makes the test fail). - Cost-saving proof. A no-op (touch-only) re-index makes zero embed
calls — proven by
test_touch_unchanged_content_short_circuits, which spies on a_CountingEmbeddingClientand assertscorpus_callsdoes not advance. The retrieval eval gate (test/retrieval_eval.test.py, Recall@10 / nDCG@10 over the KO/EN golden set) still passes unchanged, confirming the change leaves the retrieval ranking intact — but the eval gate injects a fake embedder, so it proves algorithm integrity, not the embed-skip plumbing; the counting-embedder unit test is the source of the cost-saving claim.
- tokenizing but before the embed call,
Backlog (not in this PR)
- Whitespace-only-edit short-circuit: hash normalizes whitespace, so a whitespace-only change skips re-embedding while stored embeddings keep old whitespace representation (benign but observable).
- Stale
line_count/page_counton short-circuit: both are not updated on a no-op reindex (cosmetic). - Embedding-model change is not an invalidation key: switching
embedding_modeldoes not clearcontent_hash, so the first post-switch scan short-circuits docs that would benefit from re-embedding. Addressed by a future P3 model-switch flow.
[0.5.8] - 2026-06-28
Changed (BREAKING)
-
LLM tool surface consolidated 12 → 6 (hard rename, no aliases). The model now sees six tools instead of twelve, cutting the per-turn tool-schema token cost of this plugin by ~50% (six fewer tool definitions advertised on every request). The behavior of every absorbed operation is preserved verbatim — the original handler bodies are reused unchanged; the consolidated tools only add a discriminator that routes to them.
Old → new mapping:
Old tool New call chat_preview({question})index_search({query, mode: "bm25"})(returns the same{question, documentCount, results, preview}preview shape)index_search({query, mode, topK})index_search({query, mode, topK})— unchanged formode: vector | hybridindex_scan()index_scan()(no args = scan all registered folders)index_refresh_folder({folder})index_scan({folder})document_index_scan({paths})index_scan({paths})index_folders()index_folders({action: "list"})(no args still lists)index_add_folder({folder})index_folders({action: "add", folder})index_add_folders({folders})index_folders({action: "add", folders})index_remove_folder({folder})index_folders({action: "remove", folder})Unchanged:
index_documents,index_get_document,index_scan_status.Migration required for saved routines / keywords. This is a hard cut (owner decision A): there are no compatibility aliases. Any saved routine in
~/.lvis/routine/routines.jsonor external keyword binding that references an old tool id (chat_preview,document_index_scan,index_refresh_folder,index_add_folder,index_add_folders,index_remove_folder) will hard-fail with "tool not found" and must be rewritten to the new call shown above.index_search({mode: "bm25"})now returns the preview shape (was the structuredhits[]list); usemode: "hybrid"(default) ormode: "vector"forhits[].
[0.5.7] - 2026-06-28
Security
- Removed the env LLM-key channel. The host no longer injects the embedding
API key into the worker process environment (
LVIS_INDEXER_API_KEY). That channel exposed the key via/proc/<pid>/environ, child-process inheritance, and crash dumps. The key now reaches the worker only through the runtimePOST /indexer/set_embedding_api_keypush and lives solely in worker-process memory. The worker boots with an empty key; the host pushes the credential on worker warmup, per index_scan, and on rotation (9-pref lifetime residency).
Fixed
- Push now feeds all three key consumers, not just the indexer. Previously
/indexer/set_embedding_api_keyupdated onlyLocalIndexer, whileSearchServiceandAppStatewere seeded from the boot env. With the env channel removed, that would have silently degraded query-time vector search (which runs outside any index_scan window) to BM25-only. The push now also updates the newSearchService.set_embedding_api_keyandAppState.openai_api_key, so vector search survives env removal. Verified bytest_push_feeds_all_three_and_revives_vector_search: assertssearch_vectorreturns[]before the push and real hits after it. The retrieval eval gate (Recall@10 0.975 / nDCG@10 0.9584, unchanged) confirms the env-removal left the retrieval algorithm and ranking unchanged; it does not verify key-feed plumbing becauserun_eval.pyinjectsDeterministicEmbedderdirectly as_embedding_client, bypassing the key resolution path entirely.
Added
SearchService.set_embedding_api_key/clear_embedding_keyandLocalIndexer.clear_embedding_key(overwrite-then-Nonememory scrub).POST /shutdownnow scrubs the key from the indexer, the search service, and the AppState copy — timing is shutdown/rotation, not scan end, so the key stays resident while search can run.
Preserved
- The item-4 trust chokepoint: the pushed bearer still flows through
resolveEmbeddingKeyForEndpoint, so an untrusted customembeddingEndpointwithholds the host LLM key and routes the plugin-own key instead. - The
OPENAI_API_KEYfallback inembedding_client.py(gated byLVIS_ALLOW_ENV_API_KEY=1) is preserved. This is a standalone-worker dev affordance only — the host's whitelist-only spawn env (localIndexerPlugin.ts) excludesOPENAI_API_KEY, so this path is unreachable through the host. Only the host-injectedLVIS_INDEXER_API_KEYchannel was removed.
[0.5.6] - 2026-06-28
Added
- Retrieval eval gate (
eval/+test/retrieval_eval.test.py+.github/workflows/retrieval-eval.yml) — a golden-set regression gate that runs the real hybrid search path (RRF + FTS5 + Kiwi) over a 25-doc KO/EN corpus with 20 graded queries and asserts Recall@10 / nDCG@10 against a recorded baseline (regression guard + absolute floor) plus a p95 latency ceiling. This is pure additive test infrastructure — no production code changed — and is the source-of-truth that later retrieval changes are validated against. eval/embedder.pyships aDeterministicEmbedder(hashed char-trigram bag-of-words → 256-dim L2-normalized vectors) so the gate runs hermetically with no embedding API key and no network; results are byte-identical across runs. It lives only undereval/and is never imported by the worker (it is not a production fallback).eval/vector_store.py(InMemoryCosineStore) mirrors the production score polarity (1 / (1 + cosine_distance));eval/metrics.pyprovides purerecall_at_k/ndcg_at_k;eval/run_eval.pydrives the indexer + search via dependency injection and writeseval/last_run.json.- The gate imports
kiwipiepydirectly (notpytest.importorskip) so a missing Korean tokenizer hard-fails the build instead of silently skipping the Korean-heavy corpus and reporting a falsely-green gate.
[0.5.5] - 2026-06-28
Added
documents.content_statecolumn ('ok' | 'empty' | 'embed_failed') to distinguish the three successful-registration outcomes that were previously conflated understatus = 'indexed': a clean doc (text + vectors), a parsed doc with no extractable text (empty), and a doc whose text was indexed for BM25/FTS5 but whose embeddings failed (embed_failed, semantic search unavailable).statuskeeps itspending | indexed | failedvocabulary unchanged so the host'sstatus === 'indexed'hard-filter still lists every registered doc — empty and degraded docs are no longer silently dropped, but they also do not pollute thestatusenum.content_stateis surfaced throughlist_documents/get_document, the worker client, and theindex_documents/index_get_documenthost responses so the LLM/UI can flag degraded or empty documents.
Changed
LocalIndexer._init_schemanow runs an idempotentALTER TABLE documents ADD COLUMN content_state TEXT NOT NULL DEFAULT 'ok'migration guarded byPRAGMA table_info, so pre-existing~/.lvisindex databases gain the column on next boot with every legacy row backfilled to'ok'and zero row loss.
[0.5.4] - 2026-06-28
Security
- Never forward the host's shared LLM key to an untrusted custom endpoint.
When
apiKeySource="host", a user-suppliedembeddingEndpoint/imageCaptionEndpointpointing at an arbitrarybase_url(LM Studio, an internal proxy, or an attacker-controlled URL smuggled in via a malicious config import) could previously exfiltrate the host's shared chat key across the boot, search query-embedding, legacy (no-SDK-handle), and image-caption paths. The pre-existing guard only covered one path (OpenAI provider + SDK handle + scan) and the caption endpoint had no guard at all. - Added a single host-side trust chokepoint (
isTrustedEndpointUrl): the host key is only ever sent toapi.openai.com(exact),*.openai.azure.com, or*.cognitiveservices.azure.com(https, dot-boundary suffix — look-alikes such asevil-openai.azure.com.attacker.comare rejected), aligned withplugin.jsonnetworkAccess.allowedDomains. For any other (untrusted) custom endpoint the host key is withheld and only the plugin's own key slot is used; if no plugin-own key exists,index_scanrefuses rather than silently mis-routing. An empty endpoint (provider default) and the trusted domains are unaffected — the host key is used exactly as before. - The worker is intentionally unchanged: it has no key provenance (it cannot tell a host-issued bearer from a plugin-own one), so the trust decision must live host-side where the endpoint and key source are both known.
[0.5.3] - 2026-06-28
Removed
- Dead worker-side
cloud_index_adapter.py(Mock/DisabledCloudIndexAdapterCloudIndexFactory) and itsAppState.cloudwiring +LVIS_CLOUD_INDEX_MODEenv switch. The adapter was always constructed indisabledmode and never read on any search/index path — pure dead code. The"cloud"RRF weight was also dropped fromDEFAULT_RRF_WEIGHTS(no source ever emitted it).
Changed
- Renamed the TypeScript search-source label
cloud→hybridacrosstypes.ts/workerClient.ts/hostPlugin.ts. This was the live fused-hybrid result mapping (every/search/hybridhit is the RRF output), mislabelledcloud; the externally exposedindex_searchsource vocabulary (bm25|vector|hybrid) is unchanged. This is unrelated to theImageCaptionMode = "cloud"caption-provider concept, which is untouched. - Moved 7 renderer-only tools off the LLM tool list (
tools[]19 → 12):index_scan_cancel,index_clear_warnings,index_pick_folder,index_preview_folder,index_get_settings,index_set_image_embedding,index_folders_purge_orphans. They remain inuiActions[](renderer can still invoke them) and keep theirtoolSchemasdescriptions.index_scan_statusstays LLM-visible — the model legitimately polls scan progress.
Fixed
- Vector search score polarity. LanceDB
_distance(smaller = closer) was used directly as the score, inverting rank order on the distance branch. It is now normalised to1 / (1 + distance)(higher = better, matching BM25) via the new_normalize_distance_to_scorehelper, and the LanceDB query now declares.metric("cosine")explicitly. Query-time only — no reindex required. Hybrid ranking is unchanged in practice because RRF fuses on rank order, not raw score.
[0.5.2] - 2026-06-27
Removed
- Dead
pageindexcloud-SDK integration in the worker. The pinned PyPIpageindex==0.2.8was the cloud SDK (PageIndexClient(api_key),api.pageindex.ai), incompatible with the localPageIndexClient(workspace).index()API the worker code targeted, so the client was alwaysNoneand the tree/structure/page-content-via-pageindex branches were inert in production. Removed the optional client, the_build_pageindex_treepass, the deadtreeRRF channel, and the latentapi.pageindex.aiegress. Search/index output is unchanged (the structure/page-content endpoints already read from SQLite).
Changed
- Renamed the worker modules off the pre-transition
pageindex_*name to the domain:local_indexer_worker.py/local_indexer.py/local_indexer_search.py(classPageIndexIndexer→LocalIndexer). Internal only — no host↔worker contract strings (routes, tool names, event types) changed. openaiis now a direct dependency (python-requirements.in); it was previously only pulled in transitively viapageindexbut is imported directly by the embedding/captioning clients. Lock regenerated viauv pip compile(openai==2.31.0retained,pageindexand its dead transitive deps dropped).
Unreleased (#893)
Changed
- Migrated to SDK v5.13
hostApi.resolveApiKey({purpose:"embedding", vendor:"openai", signal}) - Worker bearer via
getBearer()callback (no plaintext stored in worker memory)
Security
- SSRF fix: refuse user-supplied
embeddingEndpointwhen host LLM key is used - Sanitized open-source identifiers (publisher, UNC examples, test endpoints, README)
[0.4.4] - 2026-05-17
Chore — SDK pin v5.7.0 → v5.10.0
2026-05-12 plugin theme unification announcement (deadline 2026-05-26) 의
잔존 drift 정리. 본 plugin 은 이미 primeTheme(bridge, { target: scope })
를 mount 안에서 정상 사용 중이라 코드 변경 없이 dep pin + version bump 만.
package.json@lvis/plugin-sdkpin →github:lvis-project/lvis-plugin-sdk#v5.10.0- 검증: typecheck + test + build 통과 (209 tests pass)
- 검증: legacy theme symbol grep (
applyThemeFromHostEvent/bootstrapHostTheme/setupThemeSync/applyHostThemePayload/applyThemePayload) → 0건 (단,src/ui/lvis-tokens.css.js:28의 주석 안applyThemeFromHostEvent는 host 의 동작 설명 — 본 plugin 호출 아님)
Reference: lvis-plugin-sdk CHANGELOG [5.10.0].
[0.4.1] - 2026-05-13
Fixed
- Reclaim stale Local Indexer workers only when
.worker-session.json, the observed port owner PID, and the worker command identity all match the expected worker. - Fail closed when the port owner PID cannot be verified, or when a matching command line has no matching session record.
- Clean up spawned workers on startup/session persistence failures and redact shutdown tokens from worker stdout/stderr callbacks.
[0.3.0] - 2026-05-09
Added
toolSchemas[*].categorydeclarations for Q12 permission policy (read=7, write=7, meta=2)read:chat_preview,index_search,index_get_document,index_folders,index_preview_folder,index_scan_status,index_documentswrite:index_scan,index_add_folder,index_add_folders,index_remove_folder,index_refresh_folder,index_folders_purge_orphans,document_index_scanmeta:index_pick_folder,index_scan_cancel
pathFieldson all path-bearing write tools (Q12 spec §3 Layer 0/1):index_add_folder(folder),index_remove_folder(folder),index_refresh_folder(folder),index_add_folders(folders),document_index_scan(paths)