pkmrag 0.9.0__py3-none-any.whl

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 (72) hide show
  1. pkmrag/__init__.py +5 -0
  2. pkmrag/cache/__init__.py +8 -0
  3. pkmrag/cache/manager.py +98 -0
  4. pkmrag/cache/sqlite_cache.py +264 -0
  5. pkmrag/cli.py +294 -0
  6. pkmrag/cli_views.py +274 -0
  7. pkmrag/config.py +204 -0
  8. pkmrag/dialects/__init__.py +14 -0
  9. pkmrag/dialects/base.py +46 -0
  10. pkmrag/dialects/commonmark.py +149 -0
  11. pkmrag/dialects/obsidian.py +172 -0
  12. pkmrag/dialects/registry.py +60 -0
  13. pkmrag/discovery/__init__.py +7 -0
  14. pkmrag/discovery/engine.py +278 -0
  15. pkmrag/doctor.py +200 -0
  16. pkmrag/eval/__init__.py +16 -0
  17. pkmrag/eval/harness.py +145 -0
  18. pkmrag/eval/metrics.py +104 -0
  19. pkmrag/eval/models.py +62 -0
  20. pkmrag/eval/views.py +124 -0
  21. pkmrag/graph/__init__.py +6 -0
  22. pkmrag/graph/paths.py +199 -0
  23. pkmrag/graph/schema.py +64 -0
  24. pkmrag/graph/store.py +290 -0
  25. pkmrag/graph/traversal.py +191 -0
  26. pkmrag/inference/__init__.py +21 -0
  27. pkmrag/inference/base.py +69 -0
  28. pkmrag/inference/factory.py +51 -0
  29. pkmrag/inference/limiter.py +134 -0
  30. pkmrag/inference/mock.py +45 -0
  31. pkmrag/inference/ollama.py +37 -0
  32. pkmrag/inference/provider.py +259 -0
  33. pkmrag/ingest/__init__.py +8 -0
  34. pkmrag/ingest/pipeline.py +228 -0
  35. pkmrag/ingest/reindexer.py +258 -0
  36. pkmrag/mcp/__init__.py +8 -0
  37. pkmrag/mcp/auth.py +92 -0
  38. pkmrag/mcp/events.py +47 -0
  39. pkmrag/mcp/http_ops.py +255 -0
  40. pkmrag/mcp/http_server.py +273 -0
  41. pkmrag/mcp/server.py +213 -0
  42. pkmrag/mcp/server_state.py +143 -0
  43. pkmrag/mcp/tools.py +271 -0
  44. pkmrag/mcp/tools_sync.py +92 -0
  45. pkmrag/mcp/tools_vault.py +213 -0
  46. pkmrag/mcp/views.py +99 -0
  47. pkmrag/models.py +255 -0
  48. pkmrag/parser/__init__.py +19 -0
  49. pkmrag/parser/indexer.py +132 -0
  50. pkmrag/parser/markdown.py +227 -0
  51. pkmrag/plugin.py +233 -0
  52. pkmrag/plugin_assets/icon.svg +8 -0
  53. pkmrag/plugin_assets/main.js +1752 -0
  54. pkmrag/plugin_assets/manifest.json +11 -0
  55. pkmrag/plugin_assets/styles.css +201 -0
  56. pkmrag/search/__init__.py +21 -0
  57. pkmrag/search/chunker.py +236 -0
  58. pkmrag/search/embedder.py +80 -0
  59. pkmrag/search/fusion.py +135 -0
  60. pkmrag/search/service.py +188 -0
  61. pkmrag/search/vector_store.py +146 -0
  62. pkmrag/telemetry/__init__.py +28 -0
  63. pkmrag/telemetry/collector.py +75 -0
  64. pkmrag/telemetry/spans.py +105 -0
  65. pkmrag/telemetry/tracer.py +101 -0
  66. pkmrag/telemetry/views.py +102 -0
  67. pkmrag/vault_config.py +120 -0
  68. pkmrag-0.9.0.dist-info/METADATA +290 -0
  69. pkmrag-0.9.0.dist-info/RECORD +72 -0
  70. pkmrag-0.9.0.dist-info/WHEEL +4 -0
  71. pkmrag-0.9.0.dist-info/entry_points.txt +3 -0
  72. pkmrag-0.9.0.dist-info/licenses/LICENSE +21 -0
pkmrag/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """PKMRAG: Embedded Hybrid GraphRAG Context Engine & MCP Server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.9.0"
@@ -0,0 +1,8 @@
1
+ """Query cache package for Project Orbit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pkmrag.cache.manager import CacheManager
6
+ from pkmrag.cache.sqlite_cache import SQLiteQueryCache
7
+
8
+ __all__ = ["CacheManager", "SQLiteQueryCache"]
@@ -0,0 +1,98 @@
1
+ """High-level query cache manager coordinating cache lookup and invalidation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from pkmrag.cache.sqlite_cache import SQLiteQueryCache
9
+ from pkmrag.config import settings
10
+ from pkmrag.models import CacheStats, SearchResult
11
+
12
+
13
+ class CacheManager:
14
+ """Manages query caching lifecycle, key generation, and dependency invalidation."""
15
+
16
+ def __init__(
17
+ self,
18
+ vault_path: Path | str,
19
+ enabled: Optional[bool] = None,
20
+ max_entries: Optional[int] = None,
21
+ ttl_seconds: Optional[int] = None,
22
+ db_path: Optional[Path | str] = None,
23
+ ) -> None:
24
+ self.vault_path = Path(vault_path).resolve()
25
+ self.enabled = settings.cache_enabled if enabled is None else enabled
26
+ self.max_entries = settings.cache_max_entries if max_entries is None else max_entries
27
+ self.ttl_seconds = settings.cache_ttl_seconds if ttl_seconds is None else ttl_seconds
28
+
29
+ target_db = Path(db_path) if db_path else settings.get_cache_db_path(self.vault_path)
30
+ self._cache = (
31
+ SQLiteQueryCache(
32
+ db_path=target_db,
33
+ max_entries=self.max_entries,
34
+ ttl_seconds=self.ttl_seconds,
35
+ )
36
+ if self.enabled
37
+ else None
38
+ )
39
+
40
+ def get(
41
+ self,
42
+ query: str,
43
+ mode: str = "hybrid",
44
+ near: Optional[str] = None,
45
+ limit: int = 5,
46
+ folder: Optional[str] = None,
47
+ tags: Optional[list[str]] = None,
48
+ ) -> Optional[list[SearchResult]]:
49
+ """Look up cached search results for the given query and filter parameters."""
50
+ if not self.enabled or self._cache is None:
51
+ return None
52
+ key = SQLiteQueryCache.compute_key(
53
+ query=query, mode=mode, near=near, limit=limit, folder=folder, tags=tags
54
+ )
55
+ return self._cache.get(key)
56
+
57
+ def put(
58
+ self,
59
+ query: str,
60
+ mode: str,
61
+ results: list[SearchResult],
62
+ near: Optional[str] = None,
63
+ limit: int = 5,
64
+ folder: Optional[str] = None,
65
+ tags: Optional[list[str]] = None,
66
+ ) -> None:
67
+ """Store search results and associated note path dependencies in the cache."""
68
+ if not self.enabled or self._cache is None or not results:
69
+ return
70
+ key = SQLiteQueryCache.compute_key(
71
+ query=query, mode=mode, near=near, limit=limit, folder=folder, tags=tags
72
+ )
73
+ note_paths = list({r.note_path for r in results})
74
+ if near:
75
+ note_paths.append(near)
76
+ self._cache.put(key, query=query, results=results, note_paths=note_paths)
77
+
78
+ def invalidate_notes(self, note_paths: list[str]) -> int:
79
+ """Evict cached queries that returned any of the specified notes."""
80
+ if not self.enabled or self._cache is None or not note_paths:
81
+ return 0
82
+ return self._cache.evict_for_notes(note_paths)
83
+
84
+ def invalidate_all(self) -> None:
85
+ """Purge the entire query cache."""
86
+ if self._cache is not None:
87
+ self._cache.clear()
88
+
89
+ def get_stats(self) -> CacheStats:
90
+ """Fetch current cache metrics."""
91
+ if self._cache is None:
92
+ return CacheStats()
93
+ return self._cache.get_stats()
94
+
95
+ def close(self) -> None:
96
+ """Clean up cache resources."""
97
+ if self._cache is not None:
98
+ self._cache.close()
@@ -0,0 +1,264 @@
1
+ """SQLite-backed L1 query cache with composite hashing and dependency tracking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import logging
8
+ import sqlite3
9
+ import threading
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from pkmrag.models import CacheStats, SearchResult
15
+
16
+ logger = logging.getLogger("pkmrag.cache")
17
+
18
+ SCHEMA_DDL = """
19
+ PRAGMA journal_mode = WAL;
20
+ PRAGMA synchronous = NORMAL;
21
+ PRAGMA busy_timeout = 5000;
22
+
23
+ CREATE TABLE IF NOT EXISTS query_cache (
24
+ cache_key TEXT PRIMARY KEY,
25
+ query_text TEXT NOT NULL,
26
+ result_json TEXT NOT NULL,
27
+ created_at REAL NOT NULL,
28
+ last_accessed_at REAL NOT NULL
29
+ );
30
+
31
+ CREATE TABLE IF NOT EXISTS cache_dependencies (
32
+ cache_key TEXT NOT NULL,
33
+ note_path TEXT NOT NULL,
34
+ PRIMARY KEY (cache_key, note_path)
35
+ );
36
+
37
+ CREATE INDEX IF NOT EXISTS idx_cache_deps_note ON cache_dependencies(note_path);
38
+
39
+ CREATE TABLE IF NOT EXISTS cache_counters (
40
+ metric TEXT PRIMARY KEY,
41
+ val INTEGER NOT NULL DEFAULT 0
42
+ );
43
+ """
44
+
45
+
46
+ class SQLiteQueryCache:
47
+ """Thread-safe SQLite query cache with LRU eviction and reverse dependency tracking."""
48
+
49
+ def __init__(
50
+ self,
51
+ db_path: Path | str,
52
+ max_entries: int = 1000,
53
+ ttl_seconds: Optional[int] = None,
54
+ ) -> None:
55
+ self.db_path = Path(db_path).resolve()
56
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
57
+ self.max_entries = max(1, max_entries)
58
+ self.ttl_seconds = ttl_seconds
59
+ self._lock = threading.Lock()
60
+ self._conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
61
+ self._init_db()
62
+
63
+ def _init_db(self) -> None:
64
+ """Run DDL and initialize counter metrics."""
65
+ with self._lock:
66
+ self._conn.executescript(SCHEMA_DDL)
67
+ for m in ("hits", "misses", "evictions"):
68
+ self._conn.execute(
69
+ "INSERT OR IGNORE INTO cache_counters (metric, val) VALUES (?, 0);", (m,)
70
+ )
71
+ self._conn.commit()
72
+
73
+ @staticmethod
74
+ def compute_key(
75
+ query: str,
76
+ mode: str = "hybrid",
77
+ near: Optional[str] = None,
78
+ limit: int = 5,
79
+ folder: Optional[str] = None,
80
+ tags: Optional[list[str]] = None,
81
+ ) -> str:
82
+ """Build canonical composite cache key from query and all filter parameters."""
83
+ norm_q = " ".join(query.strip().lower().split())
84
+ norm_mode = mode.strip().lower()
85
+ norm_near = (near or "").strip().lower()
86
+ norm_folder = (folder or "").strip("/").lower()
87
+ tag_parts = [t.strip().lstrip("#").lower() for t in (tags or []) if t.strip()]
88
+ norm_tags = ",".join(sorted(tag_parts))
89
+ raw_repr = f"{norm_q}|{norm_mode}|{norm_near}|{limit}|{norm_folder}|{norm_tags}"
90
+ return hashlib.sha256(raw_repr.encode("utf-8")).hexdigest()
91
+
92
+ def get(self, cache_key: str) -> Optional[list[SearchResult]]:
93
+ """Retrieve cached search results if present and not expired."""
94
+ try:
95
+ with self._lock:
96
+ cursor = self._conn.cursor()
97
+ cursor.execute(
98
+ "SELECT result_json, created_at FROM query_cache WHERE cache_key = ?;",
99
+ (cache_key,),
100
+ )
101
+ row = cursor.fetchone()
102
+ if not row:
103
+ self._increment_counter("misses")
104
+ return None
105
+
106
+ res_json, created_at = row[0], float(row[1])
107
+ now = time.time()
108
+ if self.ttl_seconds is not None and (now - created_at) > self.ttl_seconds:
109
+ cursor.execute("DELETE FROM query_cache WHERE cache_key = ?;", (cache_key,))
110
+ cursor.execute(
111
+ "DELETE FROM cache_dependencies WHERE cache_key = ?;", (cache_key,)
112
+ )
113
+ self._conn.commit()
114
+ self._increment_counter("misses")
115
+ return None
116
+
117
+ cursor.execute(
118
+ "UPDATE query_cache SET last_accessed_at = ? WHERE cache_key = ?;",
119
+ (now, cache_key),
120
+ )
121
+ self._increment_counter("hits")
122
+ self._conn.commit()
123
+
124
+ raw_list = json.loads(res_json)
125
+ return [SearchResult.model_validate(item) for item in raw_list]
126
+ except Exception as e:
127
+ logger.warning("Cache get error: %s", e)
128
+ return None
129
+
130
+ def put(
131
+ self,
132
+ cache_key: str,
133
+ query: str,
134
+ results: list[SearchResult],
135
+ note_paths: list[str],
136
+ ) -> None:
137
+ """Persist search results and inverted note dependencies into cache."""
138
+ try:
139
+ raw_json = json.dumps([r.model_dump() for r in results])
140
+ now = time.time()
141
+ clean_paths = sorted({p.strip() for p in note_paths if p.strip()})
142
+
143
+ with self._lock:
144
+ cursor = self._conn.cursor()
145
+ cursor.execute(
146
+ "INSERT OR REPLACE INTO query_cache "
147
+ "(cache_key, query_text, result_json, created_at, last_accessed_at) "
148
+ "VALUES (?, ?, ?, ?, ?);",
149
+ (cache_key, query, raw_json, now, now),
150
+ )
151
+ cursor.execute("DELETE FROM cache_dependencies WHERE cache_key = ?;", (cache_key,))
152
+ if clean_paths:
153
+ cursor.executemany(
154
+ "INSERT OR IGNORE INTO cache_dependencies (cache_key, note_path) "
155
+ "VALUES (?, ?);",
156
+ [(cache_key, p) for p in clean_paths],
157
+ )
158
+
159
+ self._prune_lru(cursor)
160
+ self._conn.commit()
161
+ except Exception as e:
162
+ logger.warning("Cache put error: %s", e)
163
+
164
+ def _prune_lru(self, cursor: sqlite3.Cursor) -> None:
165
+ """Prune oldest entries when cache exceeds capacity."""
166
+ cursor.execute("SELECT COUNT(*) FROM query_cache;")
167
+ count = cursor.fetchone()[0]
168
+ if count > self.max_entries:
169
+ prune_count = max(1, count - self.max_entries + int(self.max_entries * 0.1))
170
+ cursor.execute(
171
+ "DELETE FROM query_cache WHERE cache_key IN ("
172
+ "SELECT cache_key FROM query_cache ORDER BY last_accessed_at ASC LIMIT ?);",
173
+ (prune_count,),
174
+ )
175
+ cursor.execute(
176
+ "DELETE FROM cache_dependencies WHERE cache_key NOT IN "
177
+ "(SELECT cache_key FROM query_cache);"
178
+ )
179
+ self._increment_counter("evictions", count=prune_count)
180
+
181
+ def evict_for_notes(self, note_paths: list[str]) -> int:
182
+ """Evict all cached queries referencing the specified note paths."""
183
+ if not note_paths:
184
+ return 0
185
+ try:
186
+ with self._lock:
187
+ cursor = self._conn.cursor()
188
+ placeholders = ",".join("?" for _ in note_paths)
189
+ cursor.execute(
190
+ f"SELECT DISTINCT cache_key FROM cache_dependencies "
191
+ f"WHERE note_path IN ({placeholders});",
192
+ note_paths,
193
+ )
194
+ matching = [row[0] for row in cursor.fetchall()]
195
+ if not matching:
196
+ return 0
197
+
198
+ key_ph = ",".join("?" for _ in matching)
199
+ cursor.execute(f"DELETE FROM query_cache WHERE cache_key IN ({key_ph});", matching)
200
+ cursor.execute(
201
+ f"DELETE FROM cache_dependencies WHERE cache_key IN ({key_ph});", matching
202
+ )
203
+ self._increment_counter("evictions", count=len(matching))
204
+ self._conn.commit()
205
+ return len(matching)
206
+ except Exception as e:
207
+ logger.warning("Cache eviction error: %s", e)
208
+ return 0
209
+
210
+ def clear(self) -> None:
211
+ """Purge all entries and dependencies from cache."""
212
+ try:
213
+ with self._lock:
214
+ cursor = self._conn.cursor()
215
+ cursor.execute("DELETE FROM query_cache;")
216
+ cursor.execute("DELETE FROM cache_dependencies;")
217
+ cursor.execute("UPDATE cache_counters SET val = 0;")
218
+ self._conn.commit()
219
+ except Exception as e:
220
+ logger.warning("Cache clear error: %s", e)
221
+
222
+ def _increment_counter(self, metric: str, count: int = 1) -> None:
223
+ self._conn.execute(
224
+ "UPDATE cache_counters SET val = val + ? WHERE metric = ?;",
225
+ (count, metric),
226
+ )
227
+
228
+ def get_stats(self) -> CacheStats:
229
+ """Compute live cache statistics and hit rate."""
230
+ try:
231
+ with self._lock:
232
+ cursor = self._conn.cursor()
233
+ cursor.execute("SELECT COUNT(*) FROM query_cache;")
234
+ total = cursor.fetchone()[0]
235
+
236
+ cursor.execute("SELECT metric, val FROM cache_counters;")
237
+ counters = dict(cursor.fetchall())
238
+ hits = counters.get("hits", 0)
239
+ misses = counters.get("misses", 0)
240
+ evictions = counters.get("evictions", 0)
241
+
242
+ total_reqs = hits + misses
243
+ hit_rate = round(hits / total_reqs, 4) if total_reqs > 0 else 0.0
244
+ size_bytes = self.db_path.stat().st_size if self.db_path.exists() else 0
245
+
246
+ return CacheStats(
247
+ total_entries=total,
248
+ hits=hits,
249
+ misses=misses,
250
+ hit_rate=hit_rate,
251
+ evictions=evictions,
252
+ db_size_bytes=size_bytes,
253
+ )
254
+ except Exception as e:
255
+ logger.warning("Cache get_stats error: %s", e)
256
+ return CacheStats()
257
+
258
+ def close(self) -> None:
259
+ """Safely close SQLite connection."""
260
+ with self._lock:
261
+ try:
262
+ self._conn.close()
263
+ except Exception:
264
+ pass
pkmrag/cli.py ADDED
@@ -0,0 +1,294 @@
1
+ """Command Line Interface for PKMRAG."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
13
+
14
+ from pkmrag import __version__
15
+ from pkmrag.cli_views import (
16
+ render_discovery_results,
17
+ render_doctor_report,
18
+ render_ingest_report,
19
+ render_mcp_config,
20
+ render_search_results,
21
+ )
22
+ from pkmrag.doctor import run_diagnostics
23
+ from pkmrag.ingest import IngestPipeline
24
+ from pkmrag.plugin import register_plugin_command
25
+ from pkmrag.search import SearchService
26
+
27
+ app = typer.Typer(
28
+ name="pkmrag",
29
+ help="🛰️ PKMRAG: In-process Hybrid GraphRAG Retrieval Engine & MCP Server.",
30
+ no_args_is_help=True,
31
+ )
32
+ register_plugin_command(app)
33
+
34
+ console = Console()
35
+
36
+
37
+ def version_callback(value: bool) -> None:
38
+ """Print the version of PKMRAG and exit."""
39
+ if value:
40
+ console.print(f"[bold cyan]PKMRAG[/bold cyan] version [green]{__version__}[/green]")
41
+ raise typer.Exit()
42
+
43
+
44
+ def _make_progress() -> Progress:
45
+ return Progress(
46
+ SpinnerColumn(),
47
+ TextColumn("[progress.description]{task.description}"),
48
+ BarColumn(),
49
+ TextColumn("{task.completed}/{task.total}"),
50
+ TimeElapsedColumn(),
51
+ console=console,
52
+ )
53
+
54
+
55
+ def _display_trace_if_active() -> None:
56
+ from pkmrag.telemetry import get_memory_collector, render_trace_tree
57
+
58
+ collector = get_memory_collector()
59
+ if collector:
60
+ render_trace_tree(collector.get_spans(), console)
61
+
62
+
63
+ @app.callback()
64
+ def main(
65
+ version: Optional[bool] = typer.Option(
66
+ None, "--version", "-v", help="Show version.", callback=version_callback, is_eager=True
67
+ ),
68
+ ) -> None:
69
+ """PKMRAG CLI root callback."""
70
+
71
+
72
+ @app.command()
73
+ def doctor(
74
+ json_output: bool = typer.Option(False, "--json", help="Output diagnostics in raw JSON."),
75
+ ) -> None:
76
+ """Verify system requirements and embedded in-process storage engines (LadybugDB & LanceDB)."""
77
+ with console.status("[bold blue]Running PKMRAG diagnostics...[/bold blue]"):
78
+ report = run_diagnostics()
79
+
80
+ if json_output:
81
+ console.print_json(report.model_dump_json())
82
+ else:
83
+ render_doctor_report(report, console)
84
+ sys.exit(0 if report.all_passed else 1)
85
+
86
+
87
+ @app.command()
88
+ def ingest(
89
+ vault_path: Path = typer.Argument(..., help="Path to vault.", exists=True, resolve_path=True),
90
+ target: str = typer.Option("all", "--target", "-t", help="Target: all, graph, or vector."),
91
+ db_dir: Optional[Path] = typer.Option(None, "--db-dir", "-d", help="Custom LadybugDB dir."),
92
+ vector_dir: Optional[Path] = typer.Option(None, "--vector-dir", help="Custom LanceDB dir."),
93
+ rebuild: bool = typer.Option(False, "--rebuild", help="Rebuild indices from scratch."),
94
+ clear_cache: bool = typer.Option(False, "--clear-cache", help="Purge cached search queries."),
95
+ dialect: str = typer.Option("auto", "--dialect", "-m", help="Source dialect (auto, obsidian)."),
96
+ trace: bool = typer.Option(False, "--trace", help="Display visual execution trace tree."),
97
+ json_output: bool = typer.Option(False, "--json", help="Output metrics in raw JSON format."),
98
+ ) -> None:
99
+ """Ingest notes, links, tags, and semantic vectors from a knowledge base."""
100
+ if trace:
101
+ from pkmrag.telemetry import setup_telemetry
102
+
103
+ setup_telemetry(enable_memory_collector=True)
104
+
105
+ if rebuild or clear_cache:
106
+ from pkmrag.cache import CacheManager
107
+
108
+ CacheManager(vault_path).invalidate_all()
109
+
110
+ pipeline = IngestPipeline(
111
+ vault_path=vault_path,
112
+ db_path=db_dir,
113
+ vector_dir=vector_dir,
114
+ rebuild=rebuild,
115
+ dialect=dialect,
116
+ target=target,
117
+ )
118
+
119
+ if json_output:
120
+ console.print_json(pipeline.run().model_dump_json())
121
+ sys.exit(0)
122
+
123
+ if rebuild:
124
+ console.print("[yellow]Rebuild mode enabled: existing indices and cache wiped.[/yellow]")
125
+
126
+ with _make_progress() as progress:
127
+ task_id = progress.add_task("[cyan]Ingesting vault...", total=100)
128
+
129
+ def on_prog(phase: str, cur: int, tot: int) -> None:
130
+ progress.update(
131
+ task_id, description=f"[cyan]{phase}...", total=max(tot, 1), completed=cur
132
+ )
133
+
134
+ stats = pipeline.run(progress_callback=on_prog)
135
+ progress.update(task_id, description="[bold green]Ingestion complete!")
136
+
137
+ render_ingest_report(stats, console)
138
+
139
+ if trace:
140
+ _display_trace_if_active()
141
+
142
+
143
+ @app.command()
144
+ def search(
145
+ query: str = typer.Argument(..., help="Search query string."),
146
+ vault_path: Path = typer.Option(Path.cwd(), "--vault", "-v", help="Vault directory."),
147
+ near: Optional[str] = typer.Option(None, "--near", "-n", help="Note for graph bias."),
148
+ mode: str = typer.Option("hybrid", "--mode", "-m", help="Search mode: hybrid, dense, sparse."),
149
+ limit: int = typer.Option(5, "--limit", "-l", help="Maximum number of search results."),
150
+ trace: bool = typer.Option(False, "--trace", help="Display visual execution trace tree."),
151
+ json_output: bool = typer.Option(False, "--json", help="Output raw JSON results."),
152
+ ) -> None:
153
+ """Search knowledge base chunks using dense vectors, BM25, and optional graph proximity."""
154
+ if trace:
155
+ from pkmrag.telemetry import setup_telemetry
156
+
157
+ setup_telemetry(enable_memory_collector=True)
158
+
159
+ with SearchService(vault_path=vault_path) as service:
160
+ results = service.search(query=query, near=near, mode=mode, limit=limit)
161
+
162
+ if json_output:
163
+ typer.echo(json.dumps([r.model_dump() for r in results], indent=2))
164
+ sys.exit(0)
165
+
166
+ render_search_results(results, query=query, near=near, mode=mode, console=console)
167
+ if trace:
168
+ _display_trace_if_active()
169
+
170
+
171
+ @app.command()
172
+ def serve(
173
+ vault_path: Path = typer.Argument(..., help="Vault directory.", exists=True, resolve_path=True),
174
+ transport: str = typer.Option("stdio", "--transport", "-t", help="Transport (stdio/http/sse)."),
175
+ host: str = typer.Option("127.0.0.1", "--host", "-h", help="Bind address for HTTP/SSE."),
176
+ port: int = typer.Option(3747, "--port", "-p", help="Port for HTTP/SSE."),
177
+ token: Optional[str] = typer.Option(None, "--token", help="Bearer auth token."),
178
+ no_auth: bool = typer.Option(False, "--no-auth", help="Disable token authentication."),
179
+ ) -> None:
180
+ """Start an MCP server exposing PKMRAG tools over stdio or HTTP/SSE."""
181
+ from pkmrag.mcp import run_server
182
+
183
+ try:
184
+ run_server(
185
+ vault_path, transport=transport, host=host, port=port, token=token, no_auth=no_auth
186
+ )
187
+ except ValueError as e:
188
+ console.print(f"[bold red]{e}[/bold red]")
189
+ sys.exit(1)
190
+
191
+
192
+ @app.command(name="mcp-config")
193
+ def mcp_config(
194
+ vault_path: Path = typer.Argument(..., help="Vault directory.", exists=True, resolve_path=True),
195
+ transport: str = typer.Option("stdio", "--transport", "-t", help="Transport (stdio, sse)."),
196
+ port: int = typer.Option(3747, "--port", "-p", help="Port for SSE server."),
197
+ ) -> None:
198
+ """Generate ready-to-use MCP configuration snippets for Claude Desktop and Cursor."""
199
+ render_mcp_config(vault_path, console, transport=transport, port=port)
200
+
201
+
202
+ @app.command()
203
+ def eval(
204
+ vault_path: Optional[Path] = typer.Argument(
205
+ None, help="Vault dir (defaults to benchmarks/vault)."
206
+ ),
207
+ benchmark: Optional[Path] = typer.Option(
208
+ None, "-b", "--benchmark", help="Benchmark JSON file."
209
+ ),
210
+ min_mrr: float = typer.Option(0.80, "--min-mrr", help="Minimum required MRR score."),
211
+ min_recall: float = typer.Option(0.80, "--min-recall", help="Minimum required Recall@5."),
212
+ json_output: bool = typer.Option(False, "--json", help="Output raw JSON evaluation report."),
213
+ ) -> None:
214
+ """Evaluate retrieval engine accuracy against ground truth benchmarks."""
215
+ from pkmrag.eval import EvaluationHarness, render_eval_report
216
+
217
+ harness = EvaluationHarness(vault_path=vault_path, benchmark_path=benchmark)
218
+ report = harness.run(min_mrr=min_mrr, min_recall=min_recall)
219
+
220
+ if json_output:
221
+ console.print_json(report.model_dump_json())
222
+ else:
223
+ render_eval_report(report, console, min_mrr=min_mrr, min_recall=min_recall)
224
+
225
+ sys.exit(0 if report.passed else 1)
226
+
227
+
228
+ @app.command()
229
+ def discover(
230
+ vault_path: Path = typer.Argument(..., help="Vault directory.", exists=True, resolve_path=True),
231
+ threshold: float = typer.Option(0.80, "--threshold", "-t", help="Cosine similarity threshold."),
232
+ limit: int = typer.Option(20, "--limit", "-l", help="Maximum candidate pairs to classify."),
233
+ provider: Optional[str] = typer.Option(None, "--provider", "-p", help="Provider type."),
234
+ model: Optional[str] = typer.Option(None, "--model", help="Inference model name."),
235
+ rpm: Optional[int] = typer.Option(None, "--rpm", help="Rate limit: max requests per minute."),
236
+ tpm: Optional[int] = typer.Option(None, "--tpm", help="Rate limit: max tokens per minute."),
237
+ dry_run: bool = typer.Option(False, "--dry-run", help="Scan candidates without invoking LLM."),
238
+ trace: bool = typer.Option(False, "--trace", help="Display visual execution trace tree."),
239
+ json_output: bool = typer.Option(False, "--json", help="Output raw JSON results."),
240
+ ) -> None:
241
+ """Discover semantic gaps between notes and infer conceptual relationships."""
242
+ if trace:
243
+ from pkmrag.telemetry import setup_telemetry
244
+
245
+ setup_telemetry(enable_memory_collector=True)
246
+
247
+ from pkmrag.discovery import GapDiscoveryEngine
248
+
249
+ engine = GapDiscoveryEngine(vault_path)
250
+ try:
251
+ on_prog = None
252
+ progress = None
253
+ if not (dry_run or json_output):
254
+ progress = _make_progress()
255
+ progress.start()
256
+ t_id = progress.add_task("[cyan]Scanning for semantic gaps...", total=limit)
257
+
258
+ def _cb(cur: int, tot: int, desc: str) -> None:
259
+ style = "yellow" if cur < 0 else "cyan"
260
+ if progress:
261
+ progress.update(
262
+ t_id,
263
+ description=f"[{style}]{desc}[/{style}]",
264
+ total=max(tot, 1) if tot > 0 else limit,
265
+ completed=max(cur, 0),
266
+ )
267
+
268
+ on_prog = _cb
269
+
270
+ candidates, inferred, stats = engine.discover(
271
+ similarity_threshold=threshold,
272
+ limit=limit,
273
+ dry_run=dry_run,
274
+ provider_type=provider,
275
+ model=model,
276
+ rpm=rpm,
277
+ tpm=tpm,
278
+ on_progress=on_prog,
279
+ )
280
+
281
+ if progress:
282
+ progress.stop()
283
+ finally:
284
+ engine.close()
285
+
286
+ render_discovery_results(
287
+ candidates, inferred, stats, dry_run=dry_run, json_output=json_output, console=console
288
+ )
289
+ if trace:
290
+ _display_trace_if_active()
291
+
292
+
293
+ if __name__ == "__main__":
294
+ app()