knowledge-rag 4.11.0__tar.gz → 5.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/PKG-INFO +1 -2
  2. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/README.md +0 -1
  3. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/bench/dashboard/README.md +3 -3
  4. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/config.example.yaml +5 -5
  5. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/__init__.py +1 -1
  6. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/config.py +48 -6
  7. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/data/config.example.yaml +5 -5
  8. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/file_transaction.py +27 -3
  9. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/fts5_index.py +3 -1
  10. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/ingestion.py +1 -1
  11. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/preflight.py +52 -19
  12. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/security.py +6 -2
  13. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/server.py +199 -60
  14. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/pyproject.toml +4 -2
  15. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/.gitignore +0 -0
  16. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/LICENSE +0 -0
  17. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/data/cybersecurity.yaml +0 -0
  18. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/data/developer.yaml +0 -0
  19. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/data/general.yaml +0 -0
  20. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/data/multilingual.yaml +0 -0
  21. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/data/research.yaml +0 -0
  22. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/filesystem.py +0 -0
  23. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/guarded.py +0 -0
  24. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/health.py +0 -0
  25. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/instance_lock.py +0 -0
  26. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/logging_config.py +0 -0
  27. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/metrics.py +0 -0
  28. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/pdf_support.py +0 -0
  29. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/publication.py +0 -0
  30. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/query_router.py +0 -0
  31. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/ratelimit.py +0 -0
  32. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/search_candidates.py +0 -0
  33. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/search_scope.py +0 -0
  34. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/npm/README.md +0 -0
  35. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/presets/cybersecurity.yaml +0 -0
  36. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/presets/developer.yaml +0 -0
  37. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/presets/general.yaml +0 -0
  38. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/presets/multilingual.yaml +0 -0
  39. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/presets/research.yaml +0 -0
  40. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/requirements.txt +0 -0
  41. {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/skills/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: knowledge-rag
3
- Version: 4.11.0
3
+ Version: 5.0.0
4
4
  Summary: Local RAG System for Claude Code — Hybrid search + Cross-encoder Reranking + 13 MCP Tools + 20 Format Parsers. Zero external servers.
5
5
  Project-URL: Homepage, https://github.com/lyonzin/knowledge-rag
6
6
  Project-URL: Repository, https://github.com/lyonzin/knowledge-rag
@@ -87,7 +87,6 @@ Structured data (schema.org SoftwareApplication) — indexed by search engines,
87
87
  ![License](https://img.shields.io/badge/license-MIT-yellow.svg)
88
88
  ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey.svg)
89
89
  ![GPU](https://img.shields.io/badge/GPU-NVIDIA%20CUDA-76B900.svg?logo=nvidia)
90
- [![CI](https://github.com/lyonzin/knowledge-rag/actions/workflows/ci.yml/badge.svg)](https://github.com/lyonzin/knowledge-rag/actions/workflows/ci.yml)
91
90
  [![CodeQL](https://github.com/lyonzin/knowledge-rag/actions/workflows/security.yml/badge.svg)](https://github.com/lyonzin/knowledge-rag/actions/workflows/security.yml)
92
91
  [![Quality Gate](https://github.com/lyonzin/knowledge-rag/actions/workflows/quality-gate.yml/badge.svg)](https://github.com/lyonzin/knowledge-rag/actions/workflows/quality-gate.yml)
93
92
  [![Glama Score](https://glama.ai/mcp/servers/lyonzin/knowledge-rag/badges/score.svg)](https://glama.ai/mcp/servers/lyonzin/knowledge-rag)
@@ -38,7 +38,6 @@ Structured data (schema.org SoftwareApplication) — indexed by search engines,
38
38
  ![License](https://img.shields.io/badge/license-MIT-yellow.svg)
39
39
  ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey.svg)
40
40
  ![GPU](https://img.shields.io/badge/GPU-NVIDIA%20CUDA-76B900.svg?logo=nvidia)
41
- [![CI](https://github.com/lyonzin/knowledge-rag/actions/workflows/ci.yml/badge.svg)](https://github.com/lyonzin/knowledge-rag/actions/workflows/ci.yml)
42
41
  [![CodeQL](https://github.com/lyonzin/knowledge-rag/actions/workflows/security.yml/badge.svg)](https://github.com/lyonzin/knowledge-rag/actions/workflows/security.yml)
43
42
  [![Quality Gate](https://github.com/lyonzin/knowledge-rag/actions/workflows/quality-gate.yml/badge.svg)](https://github.com/lyonzin/knowledge-rag/actions/workflows/quality-gate.yml)
44
43
  [![Glama Score](https://glama.ai/mcp/servers/lyonzin/knowledge-rag/badges/score.svg)](https://glama.ai/mcp/servers/lyonzin/knowledge-rag)
@@ -51,13 +51,13 @@ python -m http.server 8000 --directory /tmp/dashboard-preview
51
51
  | **FTS5** | `fts5 / lexical / fast[_-]?path` | 10 ms / 30 ms |
52
52
  | **Reindex** | `reindex / rebuild / swap` | 10 s / 60 s |
53
53
  | **Indexing** | `index / ingest / parse / chunk` | 100 ms / 500 ms |
54
- | **Memory** | Explicit `measurement_unit: bytes` overrides name matching | Per-workload RSS budget in bytes |
54
+ | **Memory** | Explicit `measurement_unit: bytes` overrides name matching | Per-workload allocation budget in bytes |
55
55
  | **Concurrent** | `concurrent / thread / parallel` | 100 ms / 500 ms |
56
56
  | **Other** | — | 100 ms / 1 s |
57
57
 
58
58
  New benchmark files under `bench/` are automatically categorized as long as the test name contains one of these keywords. The first matching pattern wins. Explicit byte measurements always use the memory category. A cache-hit latency benchmark remains a timing measurement rather than a RAM measurement. Timing thresholds are display heuristics, not service-level guarantees.
59
59
 
60
- Memory rows read `rss_peak_delta_bytes`, `rss_retained_delta_bytes`, `rss_budget_bytes` and `rss_sampling_interval_ms` from the benchmark's `extra_info`. Each workload runs five times, sampling process RSS every 5 ms and applying an absolute budget to every run. Older artifacts without byte samples display `not measured` / `unavailable`; elapsed garbage-collection time is never relabeled as memory. Shorter allocation peaks can fall between samples, and RSS includes native allocations and allocator retention.
60
+ Memory rows read `tracemalloc_peak_bytes` and `memory_budget_bytes` from the benchmark's `extra_info`. Each workload runs five times under `tracemalloc` and applies an absolute budget to every run. The peak counts every Python allocation the workload makes, so it does not depend on pages the allocator already holds; native allocations outside Python's allocator are not counted. Older artifacts without traced bytes display `not measured` / `unavailable`; elapsed garbage-collection time is never relabeled as memory.
61
61
 
62
62
  ## Data schema (`data.json`)
63
63
 
@@ -82,6 +82,6 @@ Memory rows read `rss_peak_delta_bytes`, `rss_retained_delta_bytes`, `rss_budget
82
82
  }
83
83
  ```
84
84
 
85
- For memory rows, `measurement_unit` is `bytes`; `median_ns`, `stddev_ns`, `ops` and `iqr_ratio` are null. `measurement_value` contains sampled peak RSS growth or null when the legacy artifact has no byte sample. The RSS budget and sampling interval are also retained. Sorting groups measurements by unit instead of treating bytes as nanoseconds.
85
+ For memory rows, `measurement_unit` is `bytes`; `median_ns`, `stddev_ns`, `ops` and `iqr_ratio` are null. `measurement_value` contains the traced allocation peak or null when the legacy artifact has no traced bytes. `budget_bytes` is also kept. Sorting groups measurements by unit instead of treating bytes as nanoseconds.
86
86
 
87
87
  `dashboard.js` enriches each row with `category`, `category_label`, `category_color`, `status`, and `stddev_pct` at render time. `workload_version` identifies changes to the benchmark scenario; the CI gate reports different versions as incomparable and keeps enforcing its 10% threshold for unchanged, comparable scenarios.
@@ -221,11 +221,11 @@ models:
221
221
 
222
222
  # Query / passage prefixes — prepended to text BEFORE embedding.
223
223
  # Required by some model families (intfloat/e5, thenlper/gte, ...);
224
- # leave empty for bge / mxbai. When a profile ships prefixes, this
225
- # override wins ONLY if the key is present here (empty string "" IS
226
- # an explicit override).
227
- query_prefix: ""
228
- passage_prefix: ""
224
+ # not used by bge / mxbai. Keep them commented out with a profile: it
225
+ # sets its own prefixes, and any value here, even "", overrides them.
226
+ # Uncomment only for a custom model that needs them (e5 shown).
227
+ # query_prefix: "query: "
228
+ # passage_prefix: "passage: "
229
229
 
230
230
  # GPU acceleration mode (v4.8.0+):
231
231
  # "auto" → probe CUDA at first embedding; use if ready, fall back to CPU (default)
@@ -1,6 +1,6 @@
1
1
  """Knowledge RAG MCP Server - Local Retrieval-Augmented Generation System"""
2
2
 
3
- __version__ = "4.11.0"
3
+ __version__ = "5.0.0"
4
4
  __author__ = "Ailton Rocha (Lyon.)"
5
5
 
6
6
  from .config import Config # noqa: E402
@@ -1,5 +1,6 @@
1
1
  """Configuration for Knowledge RAG System v4.0.0 — YAML-configurable"""
2
2
 
3
+ import datetime
3
4
  import os
4
5
  import sys
5
6
  from dataclasses import dataclass, field
@@ -559,6 +560,11 @@ def _yaml_embedding_has(key: str) -> bool:
559
560
  return key in emb
560
561
 
561
562
 
563
+ # YAML scalars that keyword_routes / category_mappings convert to text (GH #261).
564
+ # YAML reads an unquoted 2024-01-01 as a date; str() gives the folder name back.
565
+ _TEXT_SCALAR_TYPES = (str, int, float, bool, datetime.date)
566
+
567
+
562
568
  # ============================================================================
563
569
  # CONFIG DATACLASS
564
570
  # ============================================================================
@@ -939,7 +945,11 @@ class Config:
939
945
  self._apply_embedding_profile(profile)
940
946
 
941
947
  def _apply_embedding_profile(self, profile: dict) -> None:
942
- """Copy profile model/dim into config; prefixes only if user did not declare them."""
948
+ """Copy profile model/dim into config; prefixes only if user did not declare them.
949
+
950
+ A declared prefix that differs from a non-empty profile prefix still wins,
951
+ with a warning, so an empty one left from an old template is visible (GH #261).
952
+ """
943
953
  if _yaml_embedding_has("model"):
944
954
  print(
945
955
  f"[WARN] Both models.embedding.model and profile="
@@ -953,6 +963,13 @@ class Config:
953
963
  self.query_prefix = profile["query_prefix"]
954
964
  if not _yaml_embedding_has("passage_prefix"):
955
965
  self.passage_prefix = profile["passage_prefix"]
966
+ for key in ("query_prefix", "passage_prefix"):
967
+ if profile[key] and getattr(self, key) != profile[key]:
968
+ print(
969
+ f"[WARN] models.embedding.{key}={getattr(self, key)!r} overrides profile="
970
+ f"'{self.embedding_profile}' prefix {profile[key]!r}; remove the key to use the profile's",
971
+ file=sys.stderr,
972
+ )
956
973
 
957
974
  def _validate_embedding_types(self) -> None:
958
975
  """Type-check prefixes + embedding_dim + reranker settings (runs AFTER profile)."""
@@ -1036,22 +1053,47 @@ class Config:
1036
1053
  self.supported_formats = list(_DEFAULT_SUPPORTED_FORMATS)
1037
1054
 
1038
1055
  def _validate_lists_and_maps(self) -> None:
1039
- """Type-check exclude_patterns + keyword_routes + query_expansions in order."""
1056
+ """Type-check exclude_patterns + keyword_routes + category_mappings + query_expansions in order."""
1040
1057
  self._validate_exclude_and_routes()
1041
1058
  self._validate_query_expansions()
1042
1059
 
1043
1060
  def _validate_exclude_and_routes(self) -> None:
1044
- """Ensure exclude_patterns is a str-only list and keyword_routes values are lists."""
1061
+ """Keep exclude_patterns str-only; convert keyword_routes and category_mappings scalars to text.
1062
+
1063
+ YAML parses a bare ``4624`` as int, which breaks the str methods that
1064
+ indexing and query routing call on these tables (GH #261). YAML has
1065
+ already converted the scalar, so ``0123`` or ``yes`` must be quoted to
1066
+ keep their spelling. Null, list and mapping entries are skipped with a
1067
+ warning rather than turned into names such as ``"['a', 'b']"``. New
1068
+ dicts are built so the module-level defaults are never mutated.
1069
+ """
1045
1070
  if not isinstance(self.exclude_patterns, list):
1046
1071
  print(f"[WARN] exclude_patterns={self.exclude_patterns!r} invalid, using []", file=sys.stderr)
1047
1072
  self.exclude_patterns = []
1048
1073
  else:
1049
1074
  self.exclude_patterns = [p for p in self.exclude_patterns if isinstance(p, str)]
1050
1075
 
1051
- for cat, keywords in list(self.keyword_routes.items()):
1052
- if not isinstance(keywords, list):
1076
+ routes: Dict[str, List[str]] = {}
1077
+ for cat, keywords in self.keyword_routes.items():
1078
+ if not isinstance(cat, _TEXT_SCALAR_TYPES):
1079
+ print(f"[WARN] keyword_routes: ignoring key {cat!r}", file=sys.stderr)
1080
+ elif not isinstance(keywords, list):
1053
1081
  print(f"[WARN] keyword_routes.{cat} is not a list, removing", file=sys.stderr)
1054
- del self.keyword_routes[cat]
1082
+ else:
1083
+ routes[str(cat)] = [str(k) for k in keywords if isinstance(k, _TEXT_SCALAR_TYPES)]
1084
+ for ignored in (k for k in keywords if not isinstance(k, _TEXT_SCALAR_TYPES)):
1085
+ print(f"[WARN] keyword_routes.{cat}: ignoring {ignored!r}", file=sys.stderr)
1086
+ self.keyword_routes = routes
1087
+ mappings: Dict[str, str] = {}
1088
+ for path, category in self.category_mappings.items():
1089
+ if not isinstance(path, _TEXT_SCALAR_TYPES):
1090
+ print(f"[WARN] category_mappings: ignoring key {path!r}", file=sys.stderr)
1091
+ elif not isinstance(category, _TEXT_SCALAR_TYPES):
1092
+ print(f"[WARN] category_mappings.{path}: ignoring {category!r}", file=sys.stderr)
1093
+ else:
1094
+ # _detect_category matches against the lower-cased relative path.
1095
+ mappings[str(path).lower()] = str(category)
1096
+ self.category_mappings = mappings
1055
1097
 
1056
1098
  def _validate_query_expansions(self) -> None:
1057
1099
  """Type-check + merge query_expansions with query_expansion_groups."""
@@ -221,11 +221,11 @@ models:
221
221
 
222
222
  # Query / passage prefixes — prepended to text BEFORE embedding.
223
223
  # Required by some model families (intfloat/e5, thenlper/gte, ...);
224
- # leave empty for bge / mxbai. When a profile ships prefixes, this
225
- # override wins ONLY if the key is present here (empty string "" IS
226
- # an explicit override).
227
- query_prefix: ""
228
- passage_prefix: ""
224
+ # not used by bge / mxbai. Keep them commented out with a profile: it
225
+ # sets its own prefixes, and any value here, even "", overrides them.
226
+ # Uncomment only for a custom model that needs them (e5 shown).
227
+ # query_prefix: "query: "
228
+ # passage_prefix: "passage: "
229
229
 
230
230
  # GPU acceleration mode (v4.8.0+):
231
231
  # "auto" → probe CUDA at first embedding; use if ready, fall back to CPU (default)
@@ -8,9 +8,33 @@ import tempfile
8
8
  from collections.abc import Iterator
9
9
  from contextlib import contextmanager
10
10
  from pathlib import Path
11
+ from time import sleep
11
12
 
12
13
  logger = logging.getLogger(__name__)
13
14
 
15
+ # ERROR_ACCESS_DENIED / ERROR_SHARING_VIOLATION: a scanner, indexer or reader holds
16
+ # the path without FILE_SHARE_DELETE, and Windows cannot replace over it until it lets go.
17
+ _WINDOWS_HELD_ERRORS = frozenset({5, 32})
18
+ _REPLACE_RETRY_DELAYS = (0.05, 0.1, 0.2, 0.4, 0.8)
19
+
20
+
21
+ def replace_with_retry(source: str | os.PathLike[str], destination: str | os.PathLike[str]) -> None:
22
+ """``os.replace`` that waits out a transient Windows handle on either path (GH #249).
23
+
24
+ Only PermissionError carrying winerror 5 or 32 is retried, for about 1.5 s; then
25
+ it is re-raised, so a real permission problem still surfaces. Elsewhere this is
26
+ a plain ``os.replace``.
27
+ """
28
+ for delay in _REPLACE_RETRY_DELAYS:
29
+ try:
30
+ os.replace(source, destination)
31
+ return
32
+ except PermissionError as exc:
33
+ if getattr(exc, "winerror", None) not in _WINDOWS_HELD_ERRORS:
34
+ raise
35
+ sleep(delay)
36
+ os.replace(source, destination)
37
+
14
38
 
15
39
  class StagedTextFile:
16
40
  """Prepare beside the destination so rename works on every supported OS."""
@@ -47,19 +71,19 @@ class StagedTextFile:
47
71
  ) as stream:
48
72
  backup = Path(stream.name)
49
73
  try:
50
- os.replace(self.destination, backup)
74
+ replace_with_retry(self.destination, backup)
51
75
  except BaseException:
52
76
  backup.unlink(missing_ok=True)
53
77
  raise
54
78
  self.backup = backup
55
- os.replace(self.path, self.destination)
79
+ replace_with_retry(self.path, self.destination)
56
80
  self.published = True
57
81
 
58
82
  def close(self) -> None:
59
83
  """Restore old bytes after failure; never delete a backup whose restore failed."""
60
84
  if not self.committed:
61
85
  if self.backup is not None:
62
- os.replace(self.backup, self.destination)
86
+ replace_with_retry(self.backup, self.destination)
63
87
  elif self.published:
64
88
  self.destination.unlink(missing_ok=True)
65
89
  for temporary in (self.path, self.backup):
@@ -27,6 +27,8 @@ from itertools import islice
27
27
  from pathlib import Path
28
28
  from typing import Any, Callable, Iterable, List, Optional, Sequence, Tuple
29
29
 
30
+ from .file_transaction import replace_with_retry
31
+
30
32
  ChunkRow = Tuple[str, str, str, str]
31
33
  ChunkIterFactory = Callable[[], Iterable[ChunkRow]]
32
34
  ProgressCallback = Callable[[int, int], None]
@@ -137,7 +139,7 @@ class Fts5MigrationState:
137
139
  json.dump(payload, tmp)
138
140
  tmp.flush()
139
141
  os.fsync(tmp.fileno())
140
- os.replace(tmp_name, self._path)
142
+ replace_with_retry(tmp_name, self._path)
141
143
  except Exception:
142
144
  try:
143
145
  os.unlink(tmp_name)
@@ -406,7 +406,7 @@ class DocumentParser:
406
406
  ]
407
407
 
408
408
  for fname in files:
409
- if fname.startswith(".rag-pending-"):
409
+ if fname.startswith((".rag-pending-", "~$")): # staging file or Office owner file
410
410
  continue
411
411
  filepath = Path(root) / fname
412
412
  # supported_formats holds suffixes (".go") and exact filenames ("Dockerfile")
@@ -10,8 +10,10 @@ fixes below were validated on their environment before merging.
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import contextlib
13
14
  import os
14
15
  import re
16
+ import shutil
15
17
  import subprocess
16
18
  import sys
17
19
  import time
@@ -23,6 +25,8 @@ from .config import BASE_DIR, _get, config
23
25
  PROBE_ATTEMPTS = 3
24
26
  PROBE_RETRY_DELAY_SECONDS = 5
25
27
  PROBE_TIMEOUT_SECONDS = 30
28
+ AUTO_REPAIR_BACKUPS_KEPT = 3 # GH #267: unbounded auto-repair backups once filled a disk (197 copies, 76 GB)
29
+ _AUTO_REPAIR_DIR_NAME = re.compile(r"auto-repair-[0-9]{8}-[0-9]{6}") # what _backup_active_index creates
26
30
 
27
31
  # Cloud-sync folders break ChromaDB (HNSW mmap) and SQLite WAL/SHM:
28
32
  # the provider opens handles on live files to upload them, racing with
@@ -134,32 +138,50 @@ def _backup_active_index(reason: str) -> Path:
134
138
  metadata_file = config.data_dir / "index_metadata.json"
135
139
  metadata_backup = backup_dir / f"index_metadata.{reason}.json"
136
140
  metadata_moved = False
137
- if metadata_file.exists():
138
- os.rename(str(metadata_file), str(metadata_backup))
139
- metadata_moved = True
140
-
141
- if config.chroma_dir.exists():
142
- try:
141
+ try:
142
+ if metadata_file.exists():
143
+ os.rename(str(metadata_file), str(metadata_backup))
144
+ metadata_moved = True
145
+ if config.chroma_dir.exists():
143
146
  os.rename(str(config.chroma_dir), str(backup_dir / f"chroma_db.{reason}"))
144
- except OSError:
145
- if metadata_moved:
146
- # Rollback the metadata move so the two files stay consistent.
147
- # If the rollback itself fails (unlikely — dst dir is fresh)
148
- # we still re-raise the original OSError so the caller can
149
- # exit cleanly instead of leaving the system in a wedged state.
150
- try:
151
- os.rename(str(metadata_backup), str(metadata_file))
152
- except OSError:
153
- pass
154
- raise
147
+ except OSError:
148
+ if metadata_moved:
149
+ # Rollback the metadata move so the two files stay consistent.
150
+ # If the rollback itself fails (unlikely — dst dir is fresh)
151
+ # we still re-raise the original OSError so the caller can
152
+ # exit cleanly instead of leaving the system in a wedged state.
153
+ with contextlib.suppress(OSError):
154
+ os.rename(str(metadata_backup), str(metadata_file))
155
+ raise
156
+ finally:
157
+ with contextlib.suppress(OSError):
158
+ backup_dir.rmdir() # GH #267: an empty folder would take a kept slot; rmdir spares any holding data
155
159
 
156
160
  return backup_dir
157
161
 
158
162
 
163
+ def _prune_auto_repair_backups(new_backup: Path) -> None:
164
+ """Keep only the newest AUTO_REPAIR_BACKUPS_KEPT auto-repair backups, ``new_backup`` included."""
165
+ with os.scandir(new_backup.parent) as entries:
166
+ # Symlinks are skipped here; rmtree refuses Windows junctions, so no link is ever followed.
167
+ other_dirs = {entry.name for entry in entries if entry.is_dir(follow_symlinks=False)} - {new_backup.name}
168
+ older = sorted((name for name in other_dirs if _AUTO_REPAIR_DIR_NAME.fullmatch(name)), reverse=True)
169
+ removed = 0
170
+ # new_backup takes one kept slot even when a clock change made its stamp sort below the others.
171
+ for name in older[AUTO_REPAIR_BACKUPS_KEPT - 1 :]:
172
+ try:
173
+ shutil.rmtree(new_backup.parent / name)
174
+ removed += 1
175
+ except OSError as exc: # e.g. a file still held open on Windows
176
+ _log(f"[RECOVERY] could not delete old backup {new_backup.parent / name} ({exc}); will retry next repair")
177
+ _log(f"[RECOVERY] removed {removed} old auto-repair backup(s)")
178
+
179
+
159
180
  def _probe_chroma(timeout_seconds: int = PROBE_TIMEOUT_SECONDS) -> subprocess.CompletedProcess[str]:
160
181
  """Check Chroma in a child process so native crashes do not kill MCP startup."""
161
182
  code = r"""
162
183
  import chromadb
184
+ from chromadb.errors import NotFoundError
163
185
 
164
186
  from mcp_server.config import config
165
187
 
@@ -168,7 +190,13 @@ if not config.chroma_dir.exists():
168
190
  raise SystemExit(0)
169
191
 
170
192
  client = chromadb.PersistentClient(path=str(config.chroma_dir))
171
- collection = client.get_or_create_collection(name=config.collection_name)
193
+ try:
194
+ # GH #248/#252: read only. Creating it here persisted Chroma's default
195
+ # embedding function, which the server then refused to open.
196
+ collection = client.get_collection(name=config.collection_name, embedding_function=None)
197
+ except NotFoundError:
198
+ print("missing")
199
+ raise SystemExit(0)
172
200
  print(collection.count())
173
201
  """
174
202
  env = os.environ.copy()
@@ -235,7 +263,12 @@ def run_preflight(timeout_seconds: int = PROBE_TIMEOUT_SECONDS) -> bool:
235
263
  f"to avoid partial corruption. Retry after freeing file handles."
236
264
  )
237
265
  raise SystemExit(75) from exc
238
- _log(f"[RECOVERY] moved active index to {backup_dir}")
266
+ try:
267
+ if backup_dir.exists(): # GH #267: the empty folder is dropped when there was nothing to move
268
+ _log(f"[RECOVERY] moved active index to {backup_dir}")
269
+ _prune_auto_repair_backups(backup_dir)
270
+ except OSError as exc: # best effort: old backups never fail the repair
271
+ _log(f"[RECOVERY] could not prune old backups in {backup_dir.parent} ({exc})")
239
272
  return True
240
273
 
241
274
 
@@ -272,6 +272,10 @@ def neutralize_injection_sentinels(text: str) -> str:
272
272
  return text
273
273
 
274
274
 
275
+ #: Longest source identifier the provenance marker keeps; longer ones are cut.
276
+ _MAX_SOURCE_CHARS: Final[int] = 512
277
+
278
+
275
279
  def _escape_attribute(value: str) -> str:
276
280
  """Make a source identifier safe to embed in an XML-ish attribute.
277
281
 
@@ -284,11 +288,11 @@ def _escape_attribute(value: str) -> str:
284
288
 
285
289
  Returns:
286
290
  str: Value with quotes, angle brackets and control characters removed,
287
- truncated to 512 characters.
291
+ truncated to ``_MAX_SOURCE_CHARS`` (512) characters.
288
292
  """
289
293
  cleaned = "".join(ch for ch in value if ch.isprintable())
290
294
  cleaned = cleaned.replace('"', "").replace("'", "").replace("<", "").replace(">", "")
291
- return cleaned[:512]
295
+ return cleaned[:_MAX_SOURCE_CHARS]
292
296
 
293
297
 
294
298
  def wrap_external_content(content: str, source: str, digest: str | None = None) -> str:
@@ -64,7 +64,7 @@ from watchdog.observers import Observer
64
64
  # Local imports
65
65
  from . import __version__
66
66
  from .config import config
67
- from .file_transaction import staged_text_file
67
+ from .file_transaction import replace_with_retry, staged_text_file
68
68
  from .filesystem import io_path
69
69
  from .fts5_index import Fts5LexicalIndex, Fts5MigrationError, Fts5NotReadyError
70
70
  from .ingestion import Document, DocumentParser
@@ -85,8 +85,12 @@ from .ratelimit import rate_limited
85
85
  from .search_candidates import select_rerank_candidates
86
86
  from .search_scope import FolderScope, ScopeKey
87
87
  from .security import (
88
+ _MAX_SOURCE_CHARS,
88
89
  BearerAuthMiddleware,
89
90
  PathEscapeError,
91
+ _escape_attribute,
92
+ content_sha256,
93
+ detect_external_marker,
90
94
  is_path_within,
91
95
  sanitize_external_content,
92
96
  validate_path_within,
@@ -1468,7 +1472,7 @@ class DocumentWatcher(FileSystemEventHandler):
1468
1472
  Suffixes are lowercased to match DocumentParser.parse_directory().
1469
1473
  """
1470
1474
  p = Path(path)
1471
- if p.name.startswith(".rag-pending-"):
1475
+ if p.name.startswith((".rag-pending-", "~$")): # staging file or Office owner file
1472
1476
  return False
1473
1477
  return p.suffix.lower() in config.supported_formats or p.name in config.supported_formats
1474
1478
 
@@ -1541,6 +1545,30 @@ class DocumentWatcher(FileSystemEventHandler):
1541
1545
  # distinguishes from ``None`` (no existing doc yet, index as fresh).
1542
1546
  _SKIP_DOC = object()
1543
1547
 
1548
+ # Characters read to find a saved page's provenance marker. It is the first line,
1549
+ # and its tag, source (at most 512 characters) and SHA-256 take at most 614.
1550
+ _MARKER_HEAD_CHARS = 1024
1551
+
1552
+
1553
+ def _is_saved_from(path: Path, url: str) -> bool:
1554
+ """Whether the provenance marker at the top of ``path`` proves it holds ``url``'s page.
1555
+
1556
+ A file without the marker, or one that cannot be read, proves nothing. The
1557
+ marker keeps the URL as ``_escape_attribute`` leaves it (quotes, angle brackets
1558
+ and non-printable characters removed), so two URLs that differ only in those
1559
+ characters count as the same page. A marker URL cut at ``_MAX_SOURCE_CHARS``
1560
+ proves nothing either, so each re-add of a URL that long takes the next free
1561
+ name, until the full-digest name is reached and then refreshed.
1562
+ """
1563
+ try:
1564
+ with path.open(encoding="utf-8", errors="replace") as page:
1565
+ marker = detect_external_marker(page.read(_MARKER_HEAD_CHARS))
1566
+ except OSError as exc:
1567
+ # Held by another program or an offline cloud placeholder: owner unproven.
1568
+ print(f"[WARN] add_from_url: cannot read {path}, saving under a hashed name: {exc}", file=sys.stderr)
1569
+ return False
1570
+ return marker is not None and len(marker[0]) < _MAX_SOURCE_CHARS and marker[0] == _escape_attribute(url)
1571
+
1544
1572
 
1545
1573
  class KnowledgeOrchestrator:
1546
1574
  """Main orchestrator for knowledge retrieval with semantic search + keyword routing"""
@@ -1642,17 +1670,41 @@ class KnowledgeOrchestrator:
1642
1670
  def _safe_get_collection(self):
1643
1671
  """Open the collection without discarding data on initialization errors."""
1644
1672
  try:
1645
- return self.chroma_client.get_or_create_collection(
1646
- name=config.collection_name,
1647
- embedding_function=self.embed_fn,
1648
- metadata={"description": "Knowledge base for RAG"},
1649
- )
1673
+ return self._open_collection()
1650
1674
  except Exception as exc:
1651
- raise RuntimeError(
1652
- f"Could not open ChromaDB collection {config.collection_name!r} "
1653
- f"at {config.chroma_dir}: {exc}. Existing index preserved. "
1654
- "Check database access and embedding configuration before retrying."
1655
- ) from exc
1675
+ if not self._drop_empty_default_ef_collection():
1676
+ raise RuntimeError(
1677
+ f"Could not open ChromaDB collection {config.collection_name!r} "
1678
+ f"at {config.chroma_dir}: {exc}. Existing index preserved. "
1679
+ "Check database access and embedding configuration before retrying."
1680
+ ) from exc
1681
+ return self._open_collection()
1682
+
1683
+ def _open_collection(self) -> Any:
1684
+ """Get or create the production collection bound to the FastEmbed function."""
1685
+ return self.chroma_client.get_or_create_collection(
1686
+ name=config.collection_name,
1687
+ embedding_function=self.embed_fn,
1688
+ metadata={"description": "Knowledge base for RAG"},
1689
+ )
1690
+
1691
+ def _drop_empty_default_ef_collection(self) -> bool:
1692
+ """Drop the collection only when it is empty and bound to Chroma's default EF.
1693
+
1694
+ GH #248/#252: the preflight probe used to create it without an embedding
1695
+ function, so Chroma persisted ``default`` and refused FastEmbed on every
1696
+ later open. An empty one holds nothing; one with data is never touched.
1697
+ """
1698
+ try:
1699
+ stale = self.chroma_client.get_collection(name=config.collection_name, embedding_function=None)
1700
+ ef_name = (stale.configuration_json.get("embedding_function") or {}).get("name")
1701
+ if ef_name != "default" or stale.count() != 0:
1702
+ return False
1703
+ self.chroma_client.delete_collection(name=config.collection_name)
1704
+ except Exception:
1705
+ return False
1706
+ print("[RECOVERY] Recreating empty collection bound to Chroma's default embedding (GH #248)", file=sys.stderr)
1707
+ return True
1656
1708
 
1657
1709
  def _check_dimension_mismatch(self) -> bool:
1658
1710
  """Check if stored embeddings have different dimension than current config.
@@ -1721,8 +1773,10 @@ class KnowledgeOrchestrator:
1721
1773
  try:
1722
1774
  count = self.collection.count()
1723
1775
  if count == 0:
1724
- # Empty collection — nothing to build. Leave flag False so
1725
- # a later call retries once documents become available.
1776
+ # Empty collection — nothing to build, and the old generation must
1777
+ # not outlive its chunks (#259). Leave flag False so a later
1778
+ # call retries once documents become available.
1779
+ self.bm25_index.clear()
1726
1780
  return
1727
1781
 
1728
1782
  # Batch to avoid SQLite "too many SQL variables" (chromadb 1.x
@@ -1851,8 +1905,17 @@ class KnowledgeOrchestrator:
1851
1905
 
1852
1906
  Done BEFORE indexing so moved files are not blocked by stale content
1853
1907
  hashes. Mutates ``stats`` (chunks_removed, deleted) and evicts from
1854
- both ``_indexed_docs`` and ``_source_to_docid``.
1908
+ both ``_indexed_docs`` and ``_source_to_docid``. An unavailable or empty
1909
+ ``documents_dir`` (unplugged drive, unmounted share, folder recreated at
1910
+ startup) prunes nothing; remove_document removes on purpose (#259).
1855
1911
  """
1912
+ if not io_path(config.documents_dir).is_dir() or (not documents and self._indexed_docs):
1913
+ print(
1914
+ f"[WARN] documents_dir unavailable or empty, skipping orphan pruning: {config.documents_dir}",
1915
+ file=sys.stderr,
1916
+ )
1917
+ stats["errors"] += 1
1918
+ return
1856
1919
  current_paths = {str(Path(getattr(doc, "source", doc)).resolve()) for doc in documents}
1857
1920
  for doc_id, info in list(self._indexed_docs.items()):
1858
1921
  source = Path(info.get("source", ""))
@@ -1945,11 +2008,20 @@ class KnowledgeOrchestrator:
1945
2008
  if not is_path_within(config.documents_dir, doc):
1946
2009
  raise PathEscapeError(f"File escaped documents directory: {doc}")
1947
2010
  doc = self.parser.parse_file(doc)
1948
- if doc is None:
1949
- raise ValueError("Document has no indexable content")
1950
2011
  after_parse = io_path(source).stat()
1951
2012
  if (source_stat.st_mtime_ns, source_stat.st_size) != (after_parse.st_mtime_ns, after_parse.st_size):
1952
2013
  raise RuntimeError("Source changed during parsing; retry indexing this document")
2014
+ # Empty or text-less files are skipped, never an error that aborts a rebuild (#259).
2015
+ if doc is None:
2016
+ if repair_ids is not None:
2017
+ self._remove_repair_chunks(source, [value for value in repair_ids if value != existing])
2018
+ if existing:
2019
+ self._evict_stale_doc(existing, stats)
2020
+ else:
2021
+ stats["skipped"] += 1
2022
+ if repair_ids is not None:
2023
+ tracking["repaired_sources"].add(str(source.resolve()))
2024
+ return
1953
2025
  existing_doc_id = self._resolve_existing_or_skip(doc, force, path_to_docid, stats, tracking)
1954
2026
  if existing_doc_id is _SKIP_DOC:
1955
2027
  return
@@ -2042,14 +2114,15 @@ class KnowledgeOrchestrator:
2042
2114
  return stored_mtime == current_mtime and stored_size == current_size
2043
2115
 
2044
2116
  def _evict_stale_doc(self, existing_doc_id: str, stats: Dict[str, Any]) -> None:
2045
- """Remove chunks + metadata for a doc that needs reindexing. Bumps updated."""
2117
+ """Remove chunks + metadata of a doc whose file has no indexable content left. Bumps deleted."""
2046
2118
  removed = self._remove_document_chunks(existing_doc_id)
2047
2119
  stats["chunks_removed"] += removed
2048
2120
  src = self._indexed_docs[existing_doc_id].get("source", "")
2049
2121
  if src:
2050
2122
  self._source_to_docid.pop(str(Path(src).resolve()), None)
2051
2123
  del self._indexed_docs[existing_doc_id]
2052
- stats["updated"] += 1
2124
+ stats["deleted"] += 1
2125
+ print(f"[WARN] Removed {removed} chunks of {src}: the file has no indexable content", file=sys.stderr)
2053
2126
 
2054
2127
  def _register_indexed_doc(self, doc, chunks_added: int, source_stat=None) -> None:
2055
2128
  """Record the source version actually parsed, before potentially slow inference."""
@@ -2387,6 +2460,17 @@ class KnowledgeOrchestrator:
2387
2460
  if self._reindex_progress.get("active"):
2388
2461
  return {"status": "already_running", "progress": dict(self._reindex_progress)}
2389
2462
 
2463
+ # GH #249: index_all refuses at once while a watcher pass or a document
2464
+ # write holds the index lock, so "started" would hide the refusal.
2465
+ # nuclear_rebuild waits for the lock instead.
2466
+ # ponytail: a probe, not admission. A pass that takes the lock between this
2467
+ # check and the worker's index_all is still refused, as before, and shows up
2468
+ # as last_result.skipped_reason; move admission into the worker if it matters.
2469
+ if mode != "nuclear_rebuild":
2470
+ if not self._index_lock.acquire(blocking=False):
2471
+ return {"status": "already_running", "progress": {**self._reindex_progress, "operation": None}}
2472
+ self._index_lock.release()
2473
+
2390
2474
  self._reindex_progress = self._fresh_reindex_progress(mode, resume_state)
2391
2475
 
2392
2476
  # GH #163 (v4.8.3): propagate force through smart_reindex so
@@ -2440,7 +2524,7 @@ class KnowledgeOrchestrator:
2440
2524
  resume_state: Optional[Dict[str, Any]] = None,
2441
2525
  force: bool = False,
2442
2526
  ) -> Dict[str, Any]:
2443
- """Smart reindex: incremental detection + BM25 rebuild + orphan cleanup.
2527
+ """Smart reindex: index_all (unchanged files skipped unless ``force``) + BM25 rebuild + orphan cleanup.
2444
2528
 
2445
2529
  When ``resume_state`` is provided (v4.8.0 Fase 4), docs listed in
2446
2530
  it are skipped so the interrupted previous run picks up where it
@@ -2471,7 +2555,7 @@ class KnowledgeOrchestrator:
2471
2555
  return stats
2472
2556
 
2473
2557
  print("[REINDEX] Rebuilding BM25 index...", file=sys.stderr)
2474
- self.bm25_index.clear()
2558
+ # No clear(): queries keep the published generation until the rebuild replaces it (#259).
2475
2559
  self._bm25_initialized = False
2476
2560
  self._ensure_bm25_index()
2477
2561
 
@@ -3704,7 +3788,8 @@ class KnowledgeOrchestrator:
3704
3788
  window: Number of adjacent chunks to fetch on each side (default: 1)
3705
3789
 
3706
3790
  Returns:
3707
- Results with expanded content field
3791
+ Results with expanded content field; ``match_offset`` marks where the
3792
+ matched chunk starts inside it, so snippets can skip the context before it.
3708
3793
  """
3709
3794
  if not results:
3710
3795
  return results
@@ -3757,6 +3842,7 @@ class KnowledgeOrchestrator:
3757
3842
  expanded = "\n\n".join(parts_before + [results[result_idx]["content"]] + parts_after)
3758
3843
  results[result_idx]["content"] = expanded
3759
3844
  results[result_idx]["context_expanded"] = True
3845
+ results[result_idx]["match_offset"] = sum(len(part) + len("\n\n") for part in parts_before)
3760
3846
 
3761
3847
  return results
3762
3848
 
@@ -4064,14 +4150,36 @@ class KnowledgeOrchestrator:
4064
4150
  lines = [line.strip() for line in text.splitlines() if line.strip()]
4065
4151
  clean_text = f"# {title}\n\nSource: {url}\n\n" + "\n\n".join(lines)
4066
4152
 
4067
- safe_title = re.sub(r"[^\w\s-]", "", title).strip().replace(" ", "-").lower()[:60]
4068
- filename = f"{safe_title}.md"
4069
- filepath = f"{category}/{filename}"
4153
+ safe_title = re.sub(r"[^\w\s-]", "", title).strip().replace(" ", "-").lower()[:60] or "page"
4070
4154
 
4071
4155
  # The body was fetched from an untrusted URL; the sanitizer inserts a
4072
4156
  # provenance fence and defuses model control tokens before the text
4073
4157
  # ever reaches the parser or the LLM that will consume the RAG output.
4074
- return self.add_document_from_content(clean_text, filepath, category, external_source=url)
4158
+ # The lock spans name check and write, so parallel adds cannot both claim a free name.
4159
+ with self._index_lock:
4160
+ filepath = self._url_document_path(url, category, safe_title)
4161
+ return self.add_document_from_content(clean_text, filepath, category, external_source=url)
4162
+
4163
+ @staticmethod
4164
+ def _url_document_path(url: str, category: str, safe_title: str) -> str:
4165
+ """Pick the first name that no other source owns for ``url``'s page (GH #262).
4166
+
4167
+ Titles are cut to 60 characters, so different pages can map to one name.
4168
+ Tried in order: the title-based name, then that name plus the first 8 and
4169
+ 16 hex characters of the URL's SHA-256. The first that is free, or that
4170
+ ``_is_saved_from`` shows already holds this URL's page, wins. The name with
4171
+ the full digest is the last resort and is used without a check.
4172
+ """
4173
+ digest = content_sha256(url)
4174
+ for stem in (safe_title, f"{safe_title}-{digest[:8]}", f"{safe_title}-{digest[:16]}"):
4175
+ filepath = f"{category}/{stem}.md"
4176
+ try:
4177
+ existing = validate_path_within(config.documents_dir, filepath)
4178
+ except PathEscapeError:
4179
+ return filepath # add_document_from_content rejects it and reports why
4180
+ if not existing.is_file() or _is_saved_from(existing, url):
4181
+ return filepath
4182
+ return f"{category}/{safe_title}-{digest}.md"
4075
4183
 
4076
4184
  @collection_reader
4077
4185
  def search_similar(self, filepath: str, max_results: int = 5) -> List[Dict[str, Any]]:
@@ -4097,16 +4205,21 @@ class KnowledgeOrchestrator:
4097
4205
  if not results["ids"] or embeddings is None or len(embeddings) == 0:
4098
4206
  return []
4099
4207
  query_embedding = embeddings[0]
4100
- except Exception:
4208
+ except Exception as exc:
4209
+ print(f"[WARN] search_similar query failed for {filepath!r}: {exc}", file=sys.stderr)
4101
4210
  return []
4102
4211
 
4103
4212
  try:
4213
+ # Exclude the reference in the query: filtering afterwards let a long
4214
+ # document's own chunks take every candidate slot.
4104
4215
  similar = self.collection.query(
4105
4216
  query_embeddings=[query_embedding],
4106
4217
  n_results=max_results + 20,
4218
+ where={"doc_id": {"$ne": doc_id}},
4107
4219
  include=["documents", "metadatas", "distances"],
4108
4220
  )
4109
- except Exception:
4221
+ except Exception as exc:
4222
+ print(f"[WARN] search_similar query failed for {filepath!r}: {exc}", file=sys.stderr)
4110
4223
  return []
4111
4224
 
4112
4225
  if not similar["ids"] or not similar["ids"][0]:
@@ -4118,8 +4231,6 @@ class KnowledgeOrchestrator:
4118
4231
  meta = (similar["metadatas"][0][i] or {}) if similar["metadatas"] else {}
4119
4232
  source = meta.get("source", "")
4120
4233
 
4121
- if meta.get("doc_id") == doc_id:
4122
- continue
4123
4234
  if source in seen_sources:
4124
4235
  continue
4125
4236
  seen_sources.add(source)
@@ -4367,7 +4478,7 @@ class KnowledgeOrchestrator:
4367
4478
  temporary.unlink(missing_ok=True)
4368
4479
  raise
4369
4480
  try:
4370
- os.replace(temporary, path)
4481
+ replace_with_retry(temporary, path)
4371
4482
  finally:
4372
4483
  temporary.unlink(missing_ok=True)
4373
4484
 
@@ -4497,7 +4608,7 @@ class KnowledgeOrchestrator:
4497
4608
  temporary.unlink(missing_ok=True)
4498
4609
  raise
4499
4610
  try:
4500
- os.replace(temporary, self._metadata_file)
4611
+ replace_with_retry(temporary, self._metadata_file)
4501
4612
  finally:
4502
4613
  temporary.unlink(missing_ok=True)
4503
4614
 
@@ -4556,7 +4667,7 @@ class KnowledgeOrchestrator:
4556
4667
  json.dumps(payload, indent=2, ensure_ascii=False),
4557
4668
  encoding="utf-8",
4558
4669
  )
4559
- os.replace(tmp_path, self._checkpoint_file)
4670
+ replace_with_retry(tmp_path, self._checkpoint_file)
4560
4671
  # Reflect in progress dict for status polling.
4561
4672
  self._reindex_progress["checkpoint_saved_at"] = payload["checkpoint_at"]
4562
4673
 
@@ -4688,7 +4799,7 @@ def _make_snippet(content: str, max_chars: int = 500) -> str:
4688
4799
  def search_knowledge(
4689
4800
  query: str,
4690
4801
  max_results: int = 5,
4691
- category: str = None,
4802
+ category: Optional[str] = None,
4692
4803
  hybrid_alpha: float = 0.3,
4693
4804
  min_score: float = 0.0,
4694
4805
  snippet_mode: bool = True,
@@ -4712,9 +4823,10 @@ def search_knowledge(
4712
4823
  min_score: Minimum normalized relevance score (0.0–1.0) to include a result. Results scoring
4713
4824
  below this threshold are discarded. Default 0.0 returns all results. Use 0.2–0.4 to cut
4714
4825
  low-relevance noise.
4715
- snippet_mode: When true (default), truncates content to ~500 characters at a natural break
4716
- point and adds a content_length field with the original size. Use get_document() to
4717
- fetch full content when needed. Set to false to return full chunk content.
4826
+ snippet_mode: When true (default), truncates content to ~500 characters from the start of the
4827
+ matching chunk, at a natural break point, and adds a content_length field with the full
4828
+ size. Use get_document() to fetch full content when needed. Set to false to return the full
4829
+ content, which can include adjacent chunks; match_offset, when present, is where the match starts.
4718
4830
  search_method: Dispatch selector (v4.8.2+). One of ``"auto"`` (router picks FTS5 fast-path
4719
4831
  for lexical queries when enabled, hybrid otherwise), ``"hybrid"`` (force hybrid path —
4720
4832
  kill switch for suspected router misclassification), or ``"fts5"`` (force FTS5 fast-path
@@ -4789,7 +4901,11 @@ def search_knowledge(
4789
4901
 
4790
4902
  if snippet_mode:
4791
4903
  results = [
4792
- {**row, "content": _make_snippet(row["content"]), "content_length": len(row.get("content", ""))}
4904
+ {
4905
+ **row,
4906
+ "content": _make_snippet(row["content"][row.get("match_offset", 0) :]),
4907
+ "content_length": len(row.get("content", "")),
4908
+ }
4793
4909
  for row in results
4794
4910
  ]
4795
4911
 
@@ -4820,15 +4936,15 @@ def get_document(filepath: str) -> str:
4820
4936
  Args:
4821
4937
  filepath: Relative path to the document within the documents directory
4822
4938
  (e.g., "security/technique.md"). Must be an indexed file — use
4823
- list_documents() to browse available paths, or search_knowledge()
4824
- to find the filepath by topic first.
4939
+ search_knowledge() to find the filepath by topic first, or browse
4940
+ list_documents(), which returns one page at a time (see its offset).
4825
4941
 
4826
4942
  Returns:
4827
4943
  JSON string with full document content and metadata (filepath, category, size).
4828
4944
 
4829
4945
  Usage: Use when you need the complete text of a known file — search_knowledge()
4830
4946
  returns chunks, not full docs. Use search_knowledge() first to find the filepath
4831
- if unknown. Use list_documents() to browse all available files by category.
4947
+ if unknown. Use list_documents() to browse files by category, one page at a time.
4832
4948
  """
4833
4949
  orchestrator = get_orchestrator()
4834
4950
  doc = orchestrator.get_document(filepath)
@@ -4914,8 +5030,12 @@ def reindex_documents(
4914
5030
  ) -> str:
4915
5031
  """Index or reindex all documents in the knowledge base (runs in background).
4916
5032
 
4917
- ``force`` — smart reindex (detect changed files + rebuild BM25). Use after
4918
- filesystem edits outside add_document/update_document.
5033
+ No flags — incremental pass: index new or changed files, skip unchanged
5034
+ ones, prune deleted sources. Use after filesystem edits outside
5035
+ add_document/update_document.
5036
+ ``force`` — smart reindex: re-embed every file, unchanged ones included,
5037
+ and rebuild BM25. Slow on a large corpus; use after changing the
5038
+ query/passage prefixes or chunking settings.
4919
5039
  ``full_rebuild`` — nuclear rebuild (delete + re-embed). Use only after
4920
5040
  embedding-model change or index corruption. Mutually exclusive with resume.
4921
5041
  ``resume`` — pick up an interrupted smart reindex from
@@ -4983,12 +5103,15 @@ def list_categories() -> str:
4983
5103
  )
4984
5104
 
4985
5105
 
5106
+ _LIST_DOCUMENTS_MAX_LIMIT = 200 # ~75 KB of JSON: stays under common MCP client output limits
5107
+
5108
+
4986
5109
  @mcp.tool()
4987
5110
  @rate_limited
4988
5111
  @instrument("list_documents")
4989
- def list_documents(category: str = None) -> str:
5112
+ def list_documents(category: Optional[str] = None, limit: int = 100, offset: int = 0) -> str:
4990
5113
  """
4991
- List all indexed documents, optionally filtered by category.
5114
+ List indexed documents one page at a time, optionally filtered by category.
4992
5115
 
4993
5116
  Read-only. No side effects.
4994
5117
 
@@ -4996,19 +5119,35 @@ def list_documents(category: str = None) -> str:
4996
5119
  category: Optional category filter. Must be a valid category name — call
4997
5120
  list_categories() to see available options (e.g., security, ctf, logscale,
4998
5121
  development, general, redteam, blueteam).
5122
+ limit: Maximum number of documents to return (default: 100, max: 200).
5123
+ offset: Number of matching documents to skip (default: 0). For the next page, call
5124
+ again with offset + count until offset reaches total.
4999
5125
 
5000
5126
  Returns:
5001
- JSON string with list of document filepaths, categories, and metadata for each indexed file.
5127
+ JSON string with total (documents matching the filter), offset, limit, count (documents
5128
+ in this page) and the filepaths, categories, and metadata of this page, ordered by filepath.
5002
5129
 
5003
- Usage: Use to browse what's in the index or verify a specific file is indexed. Use
5004
- list_categories() first to see valid category names. Use search_knowledge() when you
5005
- want to find documents by topic rather than browsing the full list. Use get_document()
5006
- to read a specific file once you have its filepath.
5130
+ Usage: Use to browse what's in the index, one page at a time; to see every document, call
5131
+ again with offset + count until offset reaches total. Use list_categories() first to see valid category names. Use
5132
+ search_knowledge() when you want to find documents by topic rather than browsing the full
5133
+ list. Use get_document() to read a specific file once you have its filepath.
5007
5134
  """
5135
+ offset = max(0, offset)
5136
+ limit = max(1, min(limit, _LIST_DOCUMENTS_MAX_LIMIT))
5008
5137
  orchestrator = get_orchestrator()
5009
- docs = orchestrator.list_documents(category=category)
5138
+ # Source order keeps pages stable: re-indexing an edited file moves it to the end of the index.
5139
+ docs = sorted(orchestrator.list_documents(category=category), key=lambda doc: doc["source"])
5140
+ page = docs[offset : offset + limit]
5010
5141
  return json.dumps(
5011
- {"status": "success", "filter": category or "all", "count": len(docs), "documents": docs},
5142
+ {
5143
+ "status": "success",
5144
+ "filter": category or "all",
5145
+ "total": len(docs),
5146
+ "offset": offset,
5147
+ "limit": limit,
5148
+ "count": len(page),
5149
+ "documents": page,
5150
+ },
5012
5151
  indent=2,
5013
5152
  ensure_ascii=False,
5014
5153
  )
@@ -5092,7 +5231,7 @@ def update_document(filepath: str, content: str) -> str:
5092
5231
 
5093
5232
  Args:
5094
5233
  filepath: Full or relative path to the document file. Must be an already-indexed
5095
- file — use list_documents() to find valid paths.
5234
+ file — list_documents() lists indexed paths, one page at a time.
5096
5235
  content: New full-text content to replace the existing content entirely
5097
5236
 
5098
5237
  Returns:
@@ -5127,8 +5266,8 @@ def remove_document(filepath: str, delete_file: bool = False) -> str:
5127
5266
  the file from disk (irreversible, cannot be undone).
5128
5267
 
5129
5268
  Args:
5130
- filepath: Path to the document file. Must be an indexed document — use
5131
- list_documents() to find valid paths.
5269
+ filepath: Path to the document file. Must be an indexed document —
5270
+ list_documents() lists indexed paths, one page at a time.
5132
5271
  delete_file: If True, permanently deletes the file from disk in addition to
5133
5272
  removing from the index (default: False).
5134
5273
 
@@ -5155,7 +5294,7 @@ def remove_document(filepath: str, delete_file: bool = False) -> str:
5155
5294
  @mcp.tool()
5156
5295
  @rate_limited
5157
5296
  @instrument("add_from_url")
5158
- def add_from_url(url: str, category: str = "general", title: str = None) -> str:
5297
+ def add_from_url(url: str, category: str = "general", title: Optional[str] = None) -> str:
5159
5298
  """
5160
5299
  Fetch content from a URL, convert to markdown, and add to the knowledge base.
5161
5300
 
@@ -5197,8 +5336,8 @@ def search_similar(filepath: str, max_results: int = 5) -> str:
5197
5336
  Read-only. No side effects. Uses the document's embedding for similarity comparison.
5198
5337
 
5199
5338
  Args:
5200
- filepath: Path to the reference document (must already be indexed — use
5201
- list_documents() to verify). E.g., "security/technique.md"
5339
+ filepath: Path to the reference document (must already be indexed — list_documents()
5340
+ lists indexed paths, one page at a time). E.g., "security/technique.md"
5202
5341
  max_results: Number of similar documents to return (default: 5, max: 20)
5203
5342
 
5204
5343
  Returns:
@@ -5206,8 +5345,8 @@ def search_similar(filepath: str, max_results: int = 5) -> str:
5206
5345
 
5207
5346
  Usage: Use when you have a specific document and want to discover thematically related
5208
5347
  ones. Use search_knowledge() instead when you have a text query rather than a reference
5209
- document. The reference document must be indexed — call list_documents() to confirm
5210
- it exists before calling this tool.
5348
+ document. The reference document must be indexed; list_documents() lists indexed paths
5349
+ one page at a time.
5211
5350
  """
5212
5351
  if not filepath:
5213
5352
  return json.dumps({"status": "error", "message": "Filepath required"})
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "knowledge-rag"
7
- version = "4.11.0"
7
+ version = "5.0.0"
8
8
  description = "Local RAG System for Claude Code — Hybrid search + Cross-encoder Reranking + 13 MCP Tools + 20 Format Parsers. Zero external servers."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -141,7 +141,9 @@ source = ["mcp_server"]
141
141
 
142
142
  [tool.coverage.report]
143
143
  show_missing = true
144
- fail_under = 35
144
+ # pytest-cov enforces this in every test matrix cell; each cell measured
145
+ # 84.2-84.4% on 2026-11-05, so the floor catches a real drop, not noise.
146
+ fail_under = 80
145
147
 
146
148
  # ── mypy: strict gradual rollout (Pillar 7) ─────────────────────────────────
147
149
  # Strict mode is enabled GLOBALLY but with a per-module exclusion list while
File without changes
File without changes