Skip to content

feat!: revision-aware incremental indexing, share locations, streamable-http transport and a docs split - #41

Merged
Chetic merged 18 commits into
mainfrom
v3-improvements
Sep 14, 2026
Merged

Chetic merged 18 commits into
mainfrom
v3-improvements

Conversation

@Chetic

@Chetic Chetic commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Highlights

  • Indexing: untouched files are no longer re-indexed; deletions the scan could not verify are withheld; .doc conversion happens in a private work directory so indexing never writes inside an indexed tree (test/test_never_mutates.py enforces this).
  • Revision-aware indexing: files that are revisions of one document are grouped and only the newest is indexed; review-comment copies are skipped; superseded revisions are demoted in results. chunksilo --check-files explains why a file is or is not indexed.
  • Search: result URIs are built from the stored path without touching the filesystem; files from a mounted network share can be presented at the share's own location (smb:// URI plus UNC path, new shares: config); results are spread across documents (retrieval.max_chunks_per_doc); models load once under concurrency; per-stage timings are logged; internal error details are hidden from callers.
  • Server: the MCP log lives under storage.storage_dir; new unauthenticated streamable-http transport (loopback by default) with public_url / allowed_hosts / authorization_servers for running behind an authenticating reverse proxy; GET /health.
  • Docs: README split into docs/ (configuration, CLI, tools, MCP clients, reverse proxy, troubleshooting).
  • Packaging: release bundles get the right version stamped in; models are downloaded through the CLI.

Breaking changes

  • The indexing config was flattened. Removed keys (indexing.timeout.*, logging.*, retrieval.bm25_similarity_top_k, and others) now log a warning naming their replacement instead of being silently ignored. See docs/configuration.md#upgrading-from-2x.
  • mcp>=1.10.0 is now required (the transport-security settings the server uses arrived there).

Release as a major bump.

Verification

  • ruff check src/ clean, test/run_tests.sh: 501 passed, 2 skipped.
  • Confidentiality checklist for the public repo run against the branch: clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_011Yp9BfFC7MRYZ4WCKSobE6

Chetic and others added 16 commits September 13, 2026 18:38
An explicit-path load_config() now becomes the process-wide active
configuration that cfgload.get() answers from, so `--config X` no longer
silently takes timeouts, batching, parallelism and the .doc scratch
location from an auto-discovered file. The CLI activates the given path
before importing the index and search modules, and every duplicated
per-call-site fallback default (including the 8-vs-4 parallel_workers
disagreement) goes away.

Schema changes:
- indexing.timeout.* flattened to indexing.per_file_seconds (0 disables)
  and indexing.scan_item_seconds; the heartbeat and .doc conversion
  budgets are internal now
- indexing.enable_adaptive_batching / max_memory_mb folded into
  batch_size (an upper bound over the adaptive sizer);
  enable_parallel_loading folded into parallel_workers (1 = serial)
- indexing.logging.slow_file_threshold_seconds flattened to
  indexing.slow_file_threshold_seconds; log_slow_files was never read
- retrieval.bm25_similarity_top_k removed (the BM25 top-k was hardcoded)
- include/exclude pattern lists defined once, in cfgload
- an empty YAML value (chunk_size:) keeps the default instead of
  loading None, and the returned config never aliases the defaults
- a config file that still sets a removed option gets a warning naming
  its replacement

BREAKING CHANGE: the indexing.timeout, indexing.logging,
enable_parallel_loading, enable_adaptive_batching, max_memory_mb and
retrieval.bm25_similarity_top_k options are gone; the loader warns
about each one it finds and names the replacement.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Yp9BfFC7MRYZ4WCKSobE6
…models through the CLI

The manylinux bundle installed as the previous version: prepare-common.sh
copies pyproject.toml and src/ straight from the checkout, where main
still carries the last release's number (the bump lands only after a
release finishes), so the tarball name and VERSION file said one thing
while pip recorded another. The script now stamps VERSION into the
copied sources and verifies it, so a mismatch fails the build instead
of shipping.

The release scripts and CI also invoked the module-level
`python -m chunksilo.index` entry point, a second CLI that duplicated
`chunksilo --download-models` with its own --model-cache-dir flag. That
entry point and the build_index(model_cache_dir=...) override are gone;
the scripts download models through the real CLI with a throwaway config
naming the cache location, which the active-config loader honours end to
end. setup.sh points at `chunksilo --build-index` as well.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Yp9BfFC7MRYZ4WCKSobE6
…adings by the original path

Legacy .doc conversion wrote LibreOffice's output into one shared
scratch directory: two parallel workers converting files with the same
stem raced for one output path, so one file's content could be indexed
under the other file's path. LibreOffice was also handed the original
document, which let it drop a .~lock.*# file next to a source inside an
indexed directory, and it used the shared profile under $HOME.

Each conversion now runs in its own directory under storage on a copy of
the source, with a private LibreOffice profile, and the whole directory
is removed afterwards. Headings extracted from a converted .doc are
stored under the original .doc path, which is where the query side looks
them up; previously they were keyed by the temporary .docx path and never
found.

A new regression test builds an index twice over a populated tree
(txt/md/docx/legacy doc, subdirectories with spaces) from a neutral
working directory and asserts the tree is byte-for-byte unchanged, with
no logs or lock files added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Yp9BfFC7MRYZ4WCKSobE6
… could not verify

Untouched files were re-indexed on every run, by three separate routes.
A file that extracted no text never got a state row - load_files_parallel
dropped it and build_index only recorded non-empty results - while its
old chunks had already been deleted, so it looked new for ever; an
image-only .docx hit this every time. A directory whose availability
check was merely slow was dropped from the source list, and every file
under it then looked deleted, wiping the tree from the index and forcing
a full rebuild next run. A single file that could not be stat'd or
hashed was pruned the same way, and once pruned it lost the mtime fast
path, so the next scan had to hash it again - self-sustaining.

Now every attempted file gets an outcome: an empty load is a final
answer and is recorded with no doc ids; a failure is not recorded and is
retried. Each source tracks the health of its scan, deletions are
withheld for anything a scan could not see (with the count reported and
the reason logged), and each reprocessed file logs why.

The scan now runs in two phases - a stat-only enumeration, then hashing
- with reason-returning variants of the include/exclude matchers and an
optional per-event callback, so diagnostics can reuse the exact scan the
indexer performs. This also fixes a pre-existing race: directory pruning
ran on the consumer side of the walk while the os.walk producer thread
walked ahead, so excluded directories were often descended into anyway.
Pruning now happens inside the walker thread before descent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Yp9BfFC7MRYZ4WCKSobE6
Document trees often hold many ad-hoc revisions of one document
(revision-named directories like .../PB3/, filename tokens like
'Spec PB3.docx' or 'Spec_v2.pdf') plus copies made to collect review
comments, and every one of them was indexed.

- A new revisions module groups files that are revisions of one
  document: by a configurable doc-ID regex first, else by
  revision-stripped filename and path. Only the newest member (by file
  mtime - labels are never ordered) is indexed; review copies are never
  indexed. Skipped files are pruned by the existing deletion loop and
  resurface automatically when they become newest again. No state-schema
  change.
- The two-phase scan hashes only the keepers, so skipped files cost no
  MD5 work.
- indexing.versioning.index_superseded keeps old revisions indexed,
  stamped superseded so the query side can demote them.
- Chunks and BM25 file-name nodes carry doc_group / doc_id / superseded
  metadata for the query side.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Yp9BfFC7MRYZ4WCKSobE6
chunksilo --check-files [PATH] runs the indexer's exact phase-1 scan
(availability, directory pruning, include/exclude globs, revision
grouping, review copies) and reports a per-file verdict with the
deciding reason - read-only: no models, no hashing, no state-DB writes.
With PATH it explains one file, including its revision group. Exit codes
make it scriptable; --json emits the full report.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Yp9BfFC7MRYZ4WCKSobE6
…before anything else

mcp.log was written to the current working directory, which for a
service started from the wrong place can be inside an indexed tree, and
rotated by renaming into mcp_<timestamp>_<pid>.log files that accumulated
forever. It now lives in storage.storage_dir, is created mode 0600, and
rotates with a bounded backup count.

Because the log location comes from the configuration, run_server now
loads - and thereby activates - the configuration first, then configures
logging, then imports the search module. The CHUNKSILO_CONFIG
environment workaround that used to smuggle --config into the modules
that read the config at import time is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Yp9BfFC7MRYZ4WCKSobE6
…esystem

_resolve_file_uri called Path.exists() and Path.resolve() on every result
path - for each BM25 candidate and for each formatted chunk, so 10-15
paths per query. resolve() readlinks every path component, and on a
network mount that is a round trip per component, serialized through one
userspace daemon: a single query against a few dozen documents could
take the better part of a minute. It was also wrong twice over:
resolve() rewrote a path the user recognises into the mount's internal
form, which no application will open, and the URI was never
percent-encoded, so any path containing a space produced a broken URI.

The URI is now built from the stored path alone (a relative path is
joined to the first configured directory) with quote(). Result URIs are
consequently percent-encoded where they previously were not.

The URI tests now exercise the real builder and pin the contract: no
stat, readlink or resolve during URI building.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Yp9BfFC7MRYZ4WCKSobE6
Search results carried file:// URIs built from the indexer's own mount
paths, which a client on another machine can neither open nor act on.

A new `shares` configuration lists where each mounted tree really lives
(local prefix -> //server/share), and results for covered files now name
that location: an smb:// URI plus the Windows UNC path, in a new `unc`
field next to `uri`. The mapping is derived purely from configuration
with component-anchored, post-normpath matching (longest prefix wins, and
`..` cannot cross into another share), and like the rest of result
formatting it never touches the filesystem on the query path. Files not
covered by any mapping - including everything in a local single-machine
setup, which typically configures none - keep their file:// URIs and a
null `unc`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Yp9BfFC7MRYZ4WCKSobE6
…ings

The four lazy loaders (index, embedding model, reranker, BM25) were
unguarded module globals. A server that dispatches tool calls into a
thread pool with no session affinity lets N concurrent first calls each
cold-start their own copy - N times the work and memory, N-1 discarded -
and every one of them waits for the slowest. They are now
double-checked-locked, the reranker is published only after it loaded
successfully (a failed load is retried, not cached), and a new
search.warm_up() lets a server pay the cost once at startup instead of
inside a user's query.

Per-stage timings (load, vector, bm25, remote, filter, rerank, format)
are logged for every search, because "the query took 56 seconds" is not
actionable on its own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Yp9BfFC7MRYZ4WCKSobE6
run_search returned the raw exception text in its error field. That text
can name filesystem paths and storage locations, which is not the
caller's business - an MCP client gets a generic message and the
operator gets the detail, with traceback, in the server log.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Yp9BfFC7MRYZ4WCKSobE6
Search results were dominated by one document's chunks (or one
document's many revisions): the reranker truncated to rerank_top_k and
handed back whatever ranked highest, which for a long manual is five
chunks of the same file.

Results are now capped per document (retrieval.max_chunks_per_doc,
default 2, 0 disables) with rank-order backfill, so the top-k spans
documents instead of one document's every chunk; the reranker returns
the full ranked list so the diversifier has candidates to backfill from.
Chunks stamped superseded by the revision-aware indexer are demoted
below current documents at both the candidate cap and the final cut, and
BM25 file-name matches are deduped per document group with superseded
revisions dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Yp9BfFC7MRYZ4WCKSobE6
chunksilo-mcp can now serve MCP over streamable-http as well as stdio:
server.transport selects it (or --transport overrides the config), and
server.host / server.port give the bind address, loopback by default.
On the HTTP path the server warms up the index, the models and the
BM25 index before accepting the first request, so concurrent first
calls do not all wait on the slowest cold start.

The transport performs no authentication of its own. That is deliberate:
login, TLS termination and access policy belong to a reverse proxy in
front of the server, which is why the default bind is 127.0.0.1 and the
log line at startup says so.

The stdio server is unchanged apart from its MCP name, which is now
"chunksilo".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Yp9BfFC7MRYZ4WCKSobE6
…ch and server behaviour

The README grew into a 400-line manual. It is now a short overview -
pitch, installation, a config sketch, the search_docs result shape and a
documentation index - with the reference material in docs/: the full
configuration reference (including the flattened indexing options,
revision handling, share locations, the HTTP transport and an upgrade
table for 2.x configs), CLI usage with --check-files, the tool result
fields, MCP client setup for Claude Code, Claude Desktop, Cline, Roo
Code and opencode over stdio or HTTP, and troubleshooting notes for the
scan-health, revision and re-indexing behaviour.

AGENTS.md records the invariants the code now relies on: the config
activation contract, indexing never writing inside an indexed
directory, withheld deletions, one state row per attempted file, the
dry-run scan reusing the indexer's own scan, filesystem-free result
formatting, the locked lazy loaders, and the HTTP transport
authenticating nobody.

The release bundle now ships docs/, and the test docs no longer point at
a test/requirements.txt that does not exist.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Yp9BfFC7MRYZ4WCKSobE6
… proxy

Behind a reverse proxy the MCP transport rejected every request: the SDK
enables DNS-rebinding protection for a loopback bind and then accepts
only loopback Host headers (421 otherwise, 403 for a foreign Origin),
while a proxy forwards the public hostname.

- server.public_url names the URL the proxy serves the instance on; its
  host and origin are added to the transport's allow-list, and
  server.allowed_hosts / allowed_origins take any further names. Without
  them the SDK's loopback-only default is kept.
- server.authorization_servers publishes RFC 9728 protected-resource
  metadata at /.well-known/oauth-protected-resource (and the
  path-suffixed form) naming the authorization server MCP clients should
  obtain a token from. ChunkSilo only publishes the document; verifying
  tokens remains the proxy's job.
- GET /health answers once the server is warmed up and listening.
- A forwarded X-Forwarded-User / X-Forwarded-Email is logged with each
  search, for an audit trail per instance; it is never used for a
  decision.

docs/reverse-proxy.md walks through one instance per body of documents
behind oauth2-proxy, with membership of a KeyCloak group deciding who may
search it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Yp9BfFC7MRYZ4WCKSobE6
server.py imports mcp.server.transport_security, which the SDK added in
1.10.0, so chunksilo-mcp failed at import - even over stdio - in an
environment that updated the wheel but kept an older mcp: the previous
floor of 1.0.0 let pip leave it in place.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Yp9BfFC7MRYZ4WCKSobE6
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-14T16:54:28.123650Z 9809125 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9809125706

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/chunksilo/index.py
Comment on lines +2025 to +2032
for file_path, outcome in file_docs.items():
if outcome.failed:
failed_files.append(file_path)
continue
doc_ids = file_doc_ids.get(file_path, [])
if not doc_ids:
empty_files.append(file_path)
state_updates.append((outcome.file_info, doc_ids))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat loader failures as failed outcomes

When a loader reports a timeout or operational failure by returning []—as load_file does for a disappeared file, DOCX timeout, failed LibreOffice conversion, and generic reader timeout—outcome.failed remains false, so this block stores an empty state row. Subsequent builds then consider the file unchanged and never retry it, even after the transient problem is fixed or LibreOffice is installed; these failure paths must produce a failed outcome rather than being persisted as successfully empty.

AGENTS.md reference: AGENTS.md:L40-L40

Useful? React with 👍 / 👎.

Comment thread src/chunksilo/index.py Outdated
try:
for entry in os.walk(self.base_dir):
q.put(entry)
for root, dirs, files in os.walk(self.base_dir):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Mark os.walk traversal errors incomplete

When a recursive scan encounters an unreadable or disconnected descendant directory, os.walk ignores the OSError by default because no onerror callback is supplied, then reaches the sentinel with scan_status.complete still true. Previously indexed files in that unseen subtree consequently enter deleted_files and have their chunks and state removed; pass an error callback that marks the scan incomplete so deletions under this root are withheld.

AGENTS.md reference: AGENTS.md:L39-L39

Useful? React with 👍 / 👎.

Comment thread src/chunksilo/search.py
Comment on lines +170 to +174
directories = _get_configured_directories(config)
base = str(directories[0]) if directories else ""
absolute = os.path.join(base, raw) if base else raw

return os.path.normpath(absolute)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Absolutize legacy relative paths before building file URIs

For an older index containing relative metadata and the default relative directory ./data, this join normalizes to data/docs/file.pdf, so _file_uri emits file://data/docs/file.pdf; URI parsers interpret data as the host rather than as a local path, and absolute shares prefixes cannot match it. Convert the configured-directory join to an absolute path using string/CWD operations before formatting, without probing or resolving the filesystem.

AGENTS.md reference: AGENTS.md:L44-L44

Useful? React with 👍 / 👎.

Chetic and others added 2 commits September 14, 2026 19:15
…n could not list

Two ways the incremental indexer could quietly lose content:

- A loader that hit a timeout, a failed .doc conversion or a file that
  vanished returned an empty list, which build_index persisted as "holds
  no text". The file was then never tried again - a .doc indexed before
  LibreOffice was installed stayed unindexed for ever. Those paths now
  raise (FileProcessingTimeoutError / FileLoadError), so the file gets
  no state row and is retried next run, as the LoadOutcome contract says.

- os.walk skips a directory it cannot list and carries on, so a
  subdirectory that lost permissions or a stale mount finished the walk
  "cleanly" and everything indexed under it was pruned, then re-indexed
  once readable again. The walk now records such directories on
  ScanStatus.unreadable_dirs; unscanned_roots() reports them, so
  deletions under them are withheld like under a stalled root, and
  --check-files lists them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Yp9BfFC7MRYZ4WCKSobE6
Metadata written by old index versions can hold a relative path. Joined
to a relative configured directory such as ./data it stayed relative,
and file://data/x.pdf reads "data" as a host. os.path.abspath is string
arithmetic against the CWD, so the no-filesystem rule still holds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Yp9BfFC7MRYZ4WCKSobE6
@Chetic

Chetic commented Sep 14, 2026

Copy link
Copy Markdown
Owner Author

Addressed the three automated review findings in the two commits above: loader timeouts, failed .doc conversions and vanished files now raise so the file gets no state row and is retried; os.walk gets an onerror callback so files under a subdirectory the scan could not list are withheld from deletion (and reported by --check-files); legacy relative result paths are absolutised before the file:// URI is built. Full suite: 506 passed.

@Chetic
Chetic merged commit 29c16d1 into main Sep 14, 2026
4 checks passed
@Chetic
Chetic deleted the v3-improvements branch September 15, 2026 04:33
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