knowledge-rag 4.9.0__tar.gz → 4.9.1__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 (30) hide show
  1. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/PKG-INFO +1 -1
  2. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/mcp_server/__init__.py +1 -1
  3. knowledge_rag-4.9.1/mcp_server/preflight.py +145 -0
  4. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/mcp_server/server.py +9 -3
  5. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/pyproject.toml +1 -1
  6. knowledge_rag-4.9.0/mcp_server/preflight.py +0 -74
  7. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/.gitignore +0 -0
  8. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/LICENSE +0 -0
  9. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/README.md +0 -0
  10. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/bench/dashboard/README.md +0 -0
  11. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/config.example.yaml +0 -0
  12. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/mcp_server/config.py +0 -0
  13. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/mcp_server/fts5_index.py +0 -0
  14. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/mcp_server/guarded.py +0 -0
  15. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/mcp_server/health.py +0 -0
  16. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/mcp_server/ingestion.py +0 -0
  17. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/mcp_server/instance_lock.py +0 -0
  18. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/mcp_server/logging_config.py +0 -0
  19. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/mcp_server/metrics.py +0 -0
  20. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/mcp_server/query_router.py +0 -0
  21. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/mcp_server/ratelimit.py +0 -0
  22. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/mcp_server/security.py +0 -0
  23. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/npm/README.md +0 -0
  24. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/presets/cybersecurity.yaml +0 -0
  25. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/presets/developer.yaml +0 -0
  26. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/presets/general.yaml +0 -0
  27. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/presets/multilingual.yaml +0 -0
  28. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/presets/research.yaml +0 -0
  29. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/requirements.txt +0 -0
  30. {knowledge_rag-4.9.0 → knowledge_rag-4.9.1}/skills/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: knowledge-rag
3
- Version: 4.9.0
3
+ Version: 4.9.1
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
@@ -8,7 +8,7 @@ import sys # noqa: I001
8
8
  _original_stdout = sys.stdout
9
9
  sys.stdout = sys.stderr
10
10
 
11
- __version__ = "4.9.0"
11
+ __version__ = "4.9.1"
12
12
  __author__ = "Ailton Rocha (Lyon.)"
13
13
 
14
14
  from .config import Config # noqa: E402
@@ -0,0 +1,145 @@
1
+ """Startup preflight checks for persistent ChromaDB state.
2
+
3
+ GH #216 (v4.9.1): probe retries + inconclusive-on-timeout + atomic rename.
4
+ The previous single-attempt quarantine turned any transient failure
5
+ (file locks after client restart, slow disk, cold GPU init) into a
6
+ full-corpus reindex loop on Windows stdio MCP clients. The reporter's
7
+ forensic analysis on issue #216 documented the failure mode and the
8
+ fixes below were validated on their environment before merging.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ from datetime import datetime
18
+ from pathlib import Path
19
+
20
+ from .config import BASE_DIR, config
21
+
22
+ PROBE_ATTEMPTS = 3
23
+ PROBE_RETRY_DELAY_SECONDS = 5
24
+ PROBE_TIMEOUT_SECONDS = 30
25
+
26
+
27
+ def _log(msg: str) -> None:
28
+ """Emit preflight message to stderr and append to data/preflight.log.
29
+
30
+ Persisting to a file matters because stdio MCP clients hide the server's
31
+ stderr in their own logs — without the file, users could only ever see
32
+ the truncated exit code, not the actual Chroma stack trace.
33
+ """
34
+ line = f"[{datetime.now().isoformat(timespec='seconds')}] {msg}"
35
+ print(line, file=sys.stderr)
36
+ try:
37
+ log_file = config.data_dir / "preflight.log"
38
+ log_file.parent.mkdir(parents=True, exist_ok=True)
39
+ with log_file.open("a", encoding="utf-8") as f:
40
+ f.write(line + "\n")
41
+ except OSError:
42
+ pass
43
+
44
+
45
+ def _backup_active_index(reason: str) -> Path:
46
+ """Atomically move active ChromaDB state aside so the server can rebuild.
47
+
48
+ Uses os.rename (atomic on same volume) instead of shutil.move — the
49
+ fallback copy+rmtree path in shutil.move leaks a partial copy in
50
+ backups/ AND a partially deleted live chroma_db/ when another process
51
+ holds a segment file open (WinError 32).
52
+ """
53
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
54
+ backup_dir = config.data_dir / "backups" / f"auto-repair-{stamp}"
55
+ backup_dir.mkdir(parents=True, exist_ok=False)
56
+
57
+ if config.chroma_dir.exists():
58
+ os.rename(str(config.chroma_dir), str(backup_dir / f"chroma_db.{reason}"))
59
+
60
+ metadata_file = config.data_dir / "index_metadata.json"
61
+ if metadata_file.exists():
62
+ os.rename(str(metadata_file), str(backup_dir / f"index_metadata.{reason}.json"))
63
+
64
+ return backup_dir
65
+
66
+
67
+ def _probe_chroma(timeout_seconds: int = PROBE_TIMEOUT_SECONDS) -> subprocess.CompletedProcess[str]:
68
+ """Check Chroma in a child process so native crashes do not kill MCP startup."""
69
+ code = r"""
70
+ import chromadb
71
+
72
+ from mcp_server.config import config
73
+
74
+ if not config.chroma_dir.exists():
75
+ print("missing")
76
+ raise SystemExit(0)
77
+
78
+ client = chromadb.PersistentClient(path=str(config.chroma_dir))
79
+ collection = client.get_or_create_collection(name=config.collection_name)
80
+ print(collection.count())
81
+ """
82
+ env = os.environ.copy()
83
+ env.setdefault("KNOWLEDGE_RAG_DIR", str(BASE_DIR))
84
+ return subprocess.run(
85
+ [sys.executable, "-c", code],
86
+ cwd=str(BASE_DIR),
87
+ env=env,
88
+ text=True,
89
+ stdout=subprocess.PIPE,
90
+ stderr=subprocess.PIPE,
91
+ timeout=timeout_seconds,
92
+ check=False,
93
+ )
94
+
95
+
96
+ def run_preflight(timeout_seconds: int = PROBE_TIMEOUT_SECONDS) -> bool:
97
+ """Return True when active Chroma state was moved aside for repair.
98
+
99
+ Runs the probe up to PROBE_ATTEMPTS times so transient failures
100
+ (file locks lingering from a previous PID, slow disk, cold GPU init)
101
+ do not trigger a destructive quarantine. TimeoutExpired is treated
102
+ as inconclusive — the index is kept and the user can debug from
103
+ preflight.log without losing the corpus. Only PROBE_ATTEMPTS
104
+ consecutive non-zero exits quarantine.
105
+
106
+ If os.rename fails while quarantining (e.g. another process still
107
+ holds a segment file open on Windows) the index is left untouched
108
+ and the process exits with EX_TEMPFAIL (75) instead of crashing
109
+ halfway with a partial move.
110
+ """
111
+ last_result: subprocess.CompletedProcess[str] | None = None
112
+ for attempt in range(1, PROBE_ATTEMPTS + 1):
113
+ try:
114
+ result = _probe_chroma(timeout_seconds=timeout_seconds)
115
+ except subprocess.TimeoutExpired:
116
+ _log(
117
+ f"[PREFLIGHT] attempt {attempt}/{PROBE_ATTEMPTS}: probe timed out "
118
+ f"after {timeout_seconds}s — inconclusive, keeping index"
119
+ )
120
+ return False
121
+ if result.returncode == 0:
122
+ if attempt > 1:
123
+ _log(f"[PREFLIGHT] attempt {attempt}: OK (recovered from transient failure)")
124
+ return False
125
+ stderr_tail = (result.stderr or "").strip()[-1500:]
126
+ _log(
127
+ f"[PREFLIGHT] attempt {attempt}/{PROBE_ATTEMPTS}: probe failed with code {result.returncode}\n{stderr_tail}"
128
+ )
129
+ last_result = result
130
+ if attempt < PROBE_ATTEMPTS:
131
+ time.sleep(PROBE_RETRY_DELAY_SECONDS)
132
+
133
+ assert last_result is not None
134
+ reason = "segfault" if last_result.returncode in (-11, 139) else "failed"
135
+ _log(f"[RECOVERY] preflight failed {PROBE_ATTEMPTS}x (last exit code {last_result.returncode}); moving index aside")
136
+ try:
137
+ backup_dir = _backup_active_index(reason)
138
+ except OSError as exc:
139
+ _log(
140
+ f"[PREFLIGHT] could not move index aside ({exc}); leaving it untouched "
141
+ f"to avoid partial corruption. Retry after freeing file handles."
142
+ )
143
+ raise SystemExit(75) from exc
144
+ _log(f"[RECOVERY] moved active index to {backup_dir}")
145
+ return True
@@ -4793,9 +4793,15 @@ def main():
4793
4793
  print("[FALLBACK] Attempting regular index instead...")
4794
4794
  stats = orchestrator.index_all(force=True)
4795
4795
  elif orchestrator.collection.count() == 0:
4796
- print("[INFO] No documents indexed. Running initial indexing...")
4797
- stats = orchestrator.index_all()
4798
- print(f"[INFO] Indexed {stats['indexed']} documents with {stats['chunks_added']} chunks")
4796
+ # GH #216: initial indexing runs in a background thread so it
4797
+ # does not block the MCP `initialize` handshake. Blocking here
4798
+ # for a large corpus (~150k chunks takes minutes) caused stdio
4799
+ # clients to timeout, kill the process, and restart into an
4800
+ # empty index — an endless full-reindex loop. Users poll
4801
+ # get_reindex_status() to track progress.
4802
+ print("[INFO] No documents indexed. Starting initial indexing in background...")
4803
+ orchestrator.start_reindex_background("incremental")
4804
+ print("[INFO] Initial indexing runs in background. Track progress with get_reindex_status.")
4799
4805
 
4800
4806
  # Start file watcher for auto-reindex on document changes
4801
4807
  if os.environ.get("KNOWLEDGE_RAG_WATCHER_DISABLED", "").strip() == "1":
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "knowledge-rag"
7
- version = "4.9.0"
7
+ version = "4.9.1"
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"}
@@ -1,74 +0,0 @@
1
- """Startup preflight checks for persistent ChromaDB state."""
2
-
3
- from __future__ import annotations
4
-
5
- import os
6
- import shutil
7
- import subprocess
8
- import sys
9
- from datetime import datetime
10
- from pathlib import Path
11
-
12
- from .config import BASE_DIR, config
13
-
14
-
15
- def _backup_active_index(reason: str) -> Path:
16
- """Move active ChromaDB state aside so the server can rebuild cleanly."""
17
- stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
18
- backup_dir = config.data_dir / "backups" / f"auto-repair-{stamp}"
19
- backup_dir.mkdir(parents=True, exist_ok=False)
20
-
21
- if config.chroma_dir.exists():
22
- shutil.move(str(config.chroma_dir), str(backup_dir / f"chroma_db.{reason}"))
23
-
24
- metadata_file = config.data_dir / "index_metadata.json"
25
- if metadata_file.exists():
26
- shutil.move(str(metadata_file), str(backup_dir / f"index_metadata.{reason}.json"))
27
-
28
- return backup_dir
29
-
30
-
31
- def _probe_chroma(timeout_seconds: int = 30) -> subprocess.CompletedProcess[str]:
32
- """Check Chroma in a child process so native crashes do not kill MCP startup."""
33
- code = r"""
34
- import chromadb
35
-
36
- from mcp_server.config import config
37
-
38
- if not config.chroma_dir.exists():
39
- print("missing")
40
- raise SystemExit(0)
41
-
42
- client = chromadb.PersistentClient(path=str(config.chroma_dir))
43
- collection = client.get_or_create_collection(name=config.collection_name)
44
- print(collection.count())
45
- """
46
- env = os.environ.copy()
47
- env.setdefault("KNOWLEDGE_RAG_DIR", str(BASE_DIR))
48
- return subprocess.run(
49
- [sys.executable, "-c", code],
50
- cwd=str(BASE_DIR),
51
- env=env,
52
- text=True,
53
- stdout=subprocess.PIPE,
54
- stderr=subprocess.PIPE,
55
- timeout=timeout_seconds,
56
- check=False,
57
- )
58
-
59
-
60
- def run_preflight(timeout_seconds: int = 30) -> bool:
61
- """Return True when active Chroma state was moved aside for repair."""
62
- result = _probe_chroma(timeout_seconds=timeout_seconds)
63
- if result.returncode == 0:
64
- return False
65
-
66
- reason = "segfault" if result.returncode in (-11, 139) else "failed"
67
- backup_dir = _backup_active_index(reason)
68
- print(
69
- f"[RECOVERY] Chroma preflight failed with code {result.returncode}; moved active index to {backup_dir}",
70
- file=sys.stderr,
71
- )
72
- if result.stderr:
73
- print(result.stderr[-2000:], file=sys.stderr)
74
- return True
File without changes
File without changes
File without changes