docsif 0.1.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 (86) hide show
  1. docsif-0.1.0.dist-info/METADATA +393 -0
  2. docsif-0.1.0.dist-info/RECORD +86 -0
  3. docsif-0.1.0.dist-info/WHEEL +4 -0
  4. docsif-0.1.0.dist-info/entry_points.txt +3 -0
  5. docsif-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. sif/__init__.py +24 -0
  7. sif/_version.py +3 -0
  8. sif/cli/__init__.py +11 -0
  9. sif/cli/commands/__init__.py +18 -0
  10. sif/cli/commands/bench.py +148 -0
  11. sif/cli/commands/collection.py +360 -0
  12. sif/cli/commands/context.py +154 -0
  13. sif/cli/commands/get.py +200 -0
  14. sif/cli/commands/index.py +294 -0
  15. sif/cli/commands/ls.py +67 -0
  16. sif/cli/commands/mcp.py +93 -0
  17. sif/cli/commands/pull.py +111 -0
  18. sif/cli/commands/search.py +574 -0
  19. sif/cli/config.py +206 -0
  20. sif/cli/formatters.py +269 -0
  21. sif/cli/main.py +205 -0
  22. sif/config/__init__.py +30 -0
  23. sif/config/constants.py +52 -0
  24. sif/config/settings.py +217 -0
  25. sif/core/__init__.py +21 -0
  26. sif/core/collection.py +161 -0
  27. sif/core/context.py +167 -0
  28. sif/core/document.py +125 -0
  29. sif/core/models.py +298 -0
  30. sif/database/__init__.py +23 -0
  31. sif/database/connection.py +139 -0
  32. sif/database/database.py +136 -0
  33. sif/database/migrations.py +187 -0
  34. sif/database/repositories.py +500 -0
  35. sif/database/repository.py +171 -0
  36. sif/database/schema.py +362 -0
  37. sif/embedding/__init__.py +22 -0
  38. sif/embedding/cache.py +196 -0
  39. sif/embedding/embedder.py +328 -0
  40. sif/embedding/factory.py +92 -0
  41. sif/embedding/manager.py +200 -0
  42. sif/embedding/model.py +126 -0
  43. sif/indexing/__init__.py +21 -0
  44. sif/indexing/chunker.py +226 -0
  45. sif/indexing/indexer.py +226 -0
  46. sif/indexing/parser.py +192 -0
  47. sif/indexing/scanner.py +148 -0
  48. sif/indexing/watcher.py +156 -0
  49. sif/mcp/README.md +258 -0
  50. sif/mcp/__init__.py +9 -0
  51. sif/mcp/claude_desktop_config.json +16 -0
  52. sif/mcp/cli.py +92 -0
  53. sif/mcp/protocol.py +336 -0
  54. sif/mcp/server.py +433 -0
  55. sif/mcp/server_http.py +54 -0
  56. sif/mcp/test_mcp.py +108 -0
  57. sif/mcp/tools.py +497 -0
  58. sif/mcp/transport_http.py +424 -0
  59. sif/mcp/transport_stdio.py +292 -0
  60. sif/mcp_server/__init__.py +26 -0
  61. sif/mcp_server/handlers.py +68 -0
  62. sif/mcp_server/server.py +58 -0
  63. sif/mcp_server/tools.py +216 -0
  64. sif/mcp_server/transport.py +221 -0
  65. sif/models/__init__.py +70 -0
  66. sif/models/collection.py +51 -0
  67. sif/models/context.py +45 -0
  68. sif/models/document.py +76 -0
  69. sif/models/download.py +208 -0
  70. sif/models/embedding.py +71 -0
  71. sif/models/search.py +92 -0
  72. sif/search/__init__.py +22 -0
  73. sif/search/benchmark.py +110 -0
  74. sif/search/bm25.py +216 -0
  75. sif/search/expansion.py +157 -0
  76. sif/search/hybrid.py +323 -0
  77. sif/search/rerank.py +390 -0
  78. sif/search/rrf.py +186 -0
  79. sif/search/snippets.py +130 -0
  80. sif/search/strategy.py +97 -0
  81. sif/search/vector.py +177 -0
  82. sif/utils/__init__.py +26 -0
  83. sif/utils/logging.py +143 -0
  84. sif/utils/paths.py +82 -0
  85. sif/utils/progress.py +83 -0
  86. sif/utils/text.py +128 -0
sif/cli/config.py ADDED
@@ -0,0 +1,206 @@
1
+ """Configuration management for SIF CLI."""
2
+
3
+ import json
4
+ from dataclasses import asdict, dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+
8
+
9
+ @dataclass
10
+ class CollectionConfig:
11
+ """Configuration for a document collection."""
12
+
13
+ name: str
14
+ path: str
15
+ mask: Optional[str] = None
16
+ enabled: bool = True
17
+
18
+
19
+ @dataclass
20
+ class ContextConfig:
21
+ """Configuration for a document context."""
22
+
23
+ path: str
24
+ context: str
25
+
26
+
27
+ @dataclass
28
+ class EmbedConfig:
29
+ """Configuration for embedding settings."""
30
+
31
+ chunk_strategy: str = "auto" # auto, regex
32
+ chunk_size: int = 512
33
+ chunk_overlap: int = 50
34
+ model: str = "default"
35
+
36
+
37
+ @dataclass
38
+ class SearchConfig:
39
+ """Configuration for search settings."""
40
+
41
+ default_limit: int = 10
42
+ min_score: float = 0.0
43
+ hybrid_weight: float = 0.5
44
+
45
+
46
+ @dataclass
47
+ class Config:
48
+ """Main configuration class for SIF."""
49
+
50
+ # Paths
51
+ index_path: str = field(default_factory=lambda: str(Path.home() / ".sif" / "index.db"))
52
+ config_path: str = field(default_factory=lambda: str(Path.home() / ".sif" / "config.json"))
53
+
54
+ # Collections and contexts
55
+ collections: dict[str, CollectionConfig] = field(default_factory=dict)
56
+ contexts: dict[str, ContextConfig] = field(default_factory=dict)
57
+
58
+ # Settings
59
+ embed: EmbedConfig = field(default_factory=EmbedConfig)
60
+ search: SearchConfig = field(default_factory=SearchConfig)
61
+
62
+ # MCP settings
63
+ mcp_port: int = 8080
64
+ mcp_http: bool = False
65
+
66
+ def to_dict(self) -> dict[str, Any]:
67
+ """Convert config to dictionary."""
68
+ return {
69
+ "index_path": self.index_path,
70
+ "config_path": self.config_path,
71
+ "collections": {name: asdict(coll) for name, coll in self.collections.items()},
72
+ "contexts": {path: asdict(ctx) for path, ctx in self.contexts.items()},
73
+ "embed": asdict(self.embed),
74
+ "search": asdict(self.search),
75
+ "mcp_port": self.mcp_port,
76
+ "mcp_http": self.mcp_http,
77
+ }
78
+
79
+ @classmethod
80
+ def from_dict(cls, data: dict[str, Any]) -> "Config":
81
+ """Create config from dictionary."""
82
+ config = cls()
83
+
84
+ if "index_path" in data:
85
+ config.index_path = data["index_path"]
86
+ if "config_path" in data:
87
+ config.config_path = data["config_path"]
88
+
89
+ # Load collections
90
+ if "collections" in data:
91
+ config.collections = {
92
+ name: CollectionConfig(**coll_data)
93
+ for name, coll_data in data["collections"].items()
94
+ }
95
+
96
+ # Load contexts
97
+ if "contexts" in data:
98
+ config.contexts = {
99
+ path: ContextConfig(**ctx_data) for path, ctx_data in data["contexts"].items()
100
+ }
101
+
102
+ # Load embed config
103
+ if "embed" in data:
104
+ config.embed = EmbedConfig(**data["embed"])
105
+
106
+ # Load search config
107
+ if "search" in data:
108
+ config.search = SearchConfig(**data["search"])
109
+
110
+ # Load MCP settings
111
+ if "mcp_port" in data:
112
+ config.mcp_port = data["mcp_port"]
113
+ if "mcp_http" in data:
114
+ config.mcp_http = data["mcp_http"]
115
+
116
+ return config
117
+
118
+ def save(self, path: Optional[str] = None) -> None:
119
+ """Save configuration to file."""
120
+ save_path = Path(path or self.config_path)
121
+ save_path.parent.mkdir(parents=True, exist_ok=True)
122
+
123
+ with open(save_path, "w") as f:
124
+ json.dump(self.to_dict(), f, indent=2)
125
+
126
+ @classmethod
127
+ def load(cls, path: Optional[str] = None) -> "Config":
128
+ """Load configuration from file."""
129
+ config_path = Path(path or cls().config_path)
130
+
131
+ if not config_path.exists():
132
+ # Return default config
133
+ config = cls()
134
+ config.config_path = str(config_path)
135
+ return config
136
+
137
+ with open(config_path) as f:
138
+ data = json.load(f)
139
+
140
+ config = cls.from_dict(data)
141
+ config.config_path = str(config_path)
142
+ return config
143
+
144
+ def add_collection(self, name: str, path: str, mask: Optional[str] = None) -> None:
145
+ """Add a new collection."""
146
+ self.collections[name] = CollectionConfig(name=name, path=path, mask=mask)
147
+
148
+ def remove_collection(self, name: str) -> bool:
149
+ """Remove a collection. Returns True if removed."""
150
+ if name in self.collections:
151
+ del self.collections[name]
152
+ return True
153
+ return False
154
+
155
+ def rename_collection(self, old_name: str, new_name: str) -> bool:
156
+ """Rename a collection. Returns True if renamed."""
157
+ if old_name in self.collections and new_name not in self.collections:
158
+ coll = self.collections.pop(old_name)
159
+ coll.name = new_name
160
+ self.collections[new_name] = coll
161
+ return True
162
+ return False
163
+
164
+ def add_context(self, path: str, context: str) -> None:
165
+ """Add a context for a path."""
166
+ self.contexts[path] = ContextConfig(path=path, context=context)
167
+
168
+ def remove_context(self, path: str) -> bool:
169
+ """Remove a context. Returns True if removed."""
170
+ if path in self.contexts:
171
+ del self.contexts[path]
172
+ return True
173
+ return False
174
+
175
+ def get_collection(self, name: str) -> Optional[CollectionConfig]:
176
+ """Get a collection by name."""
177
+ return self.collections.get(name)
178
+
179
+ def get_context(self, path: str) -> Optional[ContextConfig]:
180
+ """Get a context by path."""
181
+ return self.contexts.get(path)
182
+
183
+
184
+ # Global config instance
185
+ _config: Optional[Config] = None
186
+
187
+
188
+ def get_config() -> Config:
189
+ """Get the global configuration instance."""
190
+ global _config
191
+ if _config is None:
192
+ _config = Config.load()
193
+ return _config
194
+
195
+
196
+ def set_config(config: Config) -> None:
197
+ """Set the global configuration instance."""
198
+ global _config
199
+ _config = config
200
+
201
+
202
+ def reload_config(path: Optional[str] = None) -> Config:
203
+ """Reload configuration from file."""
204
+ global _config
205
+ _config = Config.load(path)
206
+ return _config
sif/cli/formatters.py ADDED
@@ -0,0 +1,269 @@
1
+ """Output formatters for SIF CLI."""
2
+
3
+ import csv
4
+ import io
5
+ import json
6
+ from dataclasses import asdict, is_dataclass
7
+ from typing import Any, Optional
8
+
9
+ from rich import box
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+ from rich.syntax import Syntax
13
+ from rich.table import Table
14
+ from rich.tree import Tree
15
+
16
+
17
+ # Global console instance
18
+ console = Console()
19
+
20
+
21
+ def format_json(data: Any, indent: int = 2) -> str:
22
+ """Format data as JSON string."""
23
+
24
+ def serialize(obj: Any) -> Any:
25
+ if is_dataclass(obj):
26
+ return asdict(obj)
27
+ if hasattr(obj, "to_dict"):
28
+ return obj.to_dict()
29
+ if hasattr(obj, "__dict__"):
30
+ return obj.__dict__
31
+ return str(obj)
32
+
33
+ return json.dumps(data, indent=indent, default=serialize, ensure_ascii=False)
34
+
35
+
36
+ def format_csv(data: list[dict[str, Any]]) -> str:
37
+ """Format list of dictionaries as CSV string."""
38
+ if not data:
39
+ return ""
40
+
41
+ output = io.StringIO()
42
+ writer = csv.DictWriter(output, fieldnames=data[0].keys())
43
+ writer.writeheader()
44
+ writer.writerows(data)
45
+ return output.getvalue()
46
+
47
+
48
+ def format_markdown_table(data: list[dict[str, Any]], title: Optional[str] = None) -> str:
49
+ """Format list of dictionaries as Markdown table."""
50
+ if not data:
51
+ return ""
52
+
53
+ lines = []
54
+ if title:
55
+ lines.append(f"## {title}\n")
56
+
57
+ # Header
58
+ headers = list(data[0].keys())
59
+ lines.append("| " + " | ".join(headers) + " |")
60
+ lines.append("| " + " | ".join(["---"] * len(headers)) + " |")
61
+
62
+ # Rows
63
+ for row in data:
64
+ values = [str(row.get(h, "")) for h in headers]
65
+ lines.append("| " + " | ".join(values) + " |")
66
+
67
+ return "\n".join(lines)
68
+
69
+
70
+ def format_xml(data: Any, root_tag: str = "root") -> str:
71
+ """Format data as XML string."""
72
+
73
+ def to_xml(obj: Any, tag: str) -> str:
74
+ if isinstance(obj, dict):
75
+ children = "\n".join(to_xml(v, k) for k, v in obj.items())
76
+ return f"<{tag}>\n{children}\n</{tag}>"
77
+ if isinstance(obj, list):
78
+ children = "\n".join(to_xml(item, "item") for item in obj)
79
+ return f"<{tag}>\n{children}\n</{tag}>"
80
+ return f"<{tag}>{obj}</{tag}>"
81
+
82
+ return f'<?xml version="1.0" encoding="UTF-8"?>\n{to_xml(data, root_tag)}'
83
+
84
+
85
+ def format_files_list(paths: list[str]) -> str:
86
+ """Format list of file paths."""
87
+ return "\n".join(paths)
88
+
89
+
90
+ def print_json(data: Any, console: Optional[Console] = None) -> None:
91
+ """Print data as formatted JSON."""
92
+ cons = console or Console()
93
+ json_str = format_json(data)
94
+ syntax = Syntax(json_str, "json", theme="monokai", line_numbers=False)
95
+ cons.print(syntax)
96
+
97
+
98
+ def print_csv(data: list[dict[str, Any]], console: Optional[Console] = None) -> None:
99
+ """Print data as CSV."""
100
+ cons = console or Console()
101
+ cons.print(format_csv(data))
102
+
103
+
104
+ def print_markdown(
105
+ data: list[dict[str, Any]], title: Optional[str] = None, console: Optional[Console] = None
106
+ ) -> None:
107
+ """Print data as Markdown table."""
108
+ cons = console or Console()
109
+ cons.print(format_markdown_table(data, title))
110
+
111
+
112
+ def print_xml(data: Any, root_tag: str = "root", console: Optional[Console] = None) -> None:
113
+ """Print data as XML."""
114
+ cons = console or Console()
115
+ xml_str = format_xml(data, root_tag)
116
+ syntax = Syntax(xml_str, "xml", theme="monokai", line_numbers=False)
117
+ cons.print(syntax)
118
+
119
+
120
+ def print_files(paths: list[str], console: Optional[Console] = None) -> None:
121
+ """Print list of files."""
122
+ cons = console or Console()
123
+ for path in paths:
124
+ cons.print(path)
125
+
126
+
127
+ def print_table(
128
+ data: list[dict[str, Any]], title: Optional[str] = None, console: Optional[Console] = None
129
+ ) -> None:
130
+ """Print data as a rich table."""
131
+ cons = console or Console()
132
+
133
+ if not data:
134
+ cons.print("[yellow]No data to display[/yellow]")
135
+ return
136
+
137
+ table = Table(title=title, box=box.ROUNDED)
138
+
139
+ # Add columns
140
+ for key in data[0].keys():
141
+ table.add_column(str(key), overflow="fold")
142
+
143
+ # Add rows
144
+ for row in data:
145
+ values = [str(row.get(k, "")) for k in data[0].keys()]
146
+ table.add_row(*values)
147
+
148
+ cons.print(table)
149
+
150
+
151
+ def print_success(message: str, console: Optional[Console] = None) -> None:
152
+ """Print a success message."""
153
+ cons = console or Console()
154
+ cons.print(f"[green]✓[/green] {message}")
155
+
156
+
157
+ def print_error(message: str, console: Optional[Console] = None) -> None:
158
+ """Print an error message."""
159
+ cons = console or Console()
160
+ cons.print(f"[red]✗[/red] {message}")
161
+
162
+
163
+ def print_warning(message: str, console: Optional[Console] = None) -> None:
164
+ """Print a warning message."""
165
+ cons = console or Console()
166
+ cons.print(f"[yellow]⚠[/yellow] {message}")
167
+
168
+
169
+ def print_info(message: str, console: Optional[Console] = None) -> None:
170
+ """Print an info message."""
171
+ cons = console or Console()
172
+ cons.print(f"[blue]ℹ[/blue] {message}")
173
+
174
+
175
+ def print_panel(
176
+ content: str,
177
+ title: Optional[str] = None,
178
+ border_style: str = "blue",
179
+ console: Optional[Console] = None,
180
+ ) -> None:
181
+ """Print content in a panel."""
182
+ cons = console or Console()
183
+ panel = Panel(content, title=title, border_style=border_style)
184
+ cons.print(panel)
185
+
186
+
187
+ def print_tree(
188
+ data: dict[str, Any], title: str = "Tree", console: Optional[Console] = None
189
+ ) -> None:
190
+ """Print data as a tree structure."""
191
+ cons = console or Console()
192
+ tree = Tree(f"[bold]{title}[/bold]")
193
+
194
+ def add_branch(node: Tree, obj: Any) -> None:
195
+ if isinstance(obj, dict):
196
+ for key, value in obj.items():
197
+ branch = node.add(f"[cyan]{key}[/cyan]")
198
+ add_branch(branch, value)
199
+ elif isinstance(obj, list):
200
+ for i, item in enumerate(obj):
201
+ branch = node.add(f"[dim][{i}][/dim]")
202
+ add_branch(branch, item)
203
+ else:
204
+ node.add(str(obj))
205
+
206
+ add_branch(tree, data)
207
+ cons.print(tree)
208
+
209
+
210
+ def prepend_line_numbers(content: str) -> str:
211
+ """Prepend line numbers to each line of content."""
212
+ return "\n".join(f"{i + 1:4d}: {line}" for i, line in enumerate(content.split("\n")))
213
+
214
+
215
+ def add_line_numbers_to_results(
216
+ results: list[dict[str, Any]], content_key: str = "content"
217
+ ) -> list[dict[str, Any]]:
218
+ """Add a 'line_numbers' field to each result dict based on content."""
219
+ formatted = []
220
+ for item in results:
221
+ row = dict(item)
222
+ content = row.get(content_key, "")
223
+ if content:
224
+ row["line_numbers"] = "\n".join(f"{i + 1}" for i in range(len(content.split("\n"))))
225
+ else:
226
+ row["line_numbers"] = ""
227
+ formatted.append(row)
228
+ return formatted
229
+
230
+
231
+ class OutputFormatter:
232
+ """Handles output formatting based on format option."""
233
+
234
+ FORMATS = ["table", "json", "csv", "md", "xml", "files"]
235
+
236
+ def __init__(self, format_type: str = "table", console: Optional[Console] = None):
237
+ self.format_type = format_type
238
+ self.console = console or Console()
239
+
240
+ def print(self, data: Any, title: Optional[str] = None) -> None: # noqa: PLR0912
241
+ """Print data in the specified format."""
242
+ if self.format_type == "json":
243
+ print_json(data, self.console)
244
+ elif self.format_type == "csv":
245
+ if isinstance(data, list) and data and isinstance(data[0], dict):
246
+ print_csv(data, self.console)
247
+ else:
248
+ print_json(data, self.console)
249
+ elif self.format_type == "md":
250
+ if isinstance(data, list) and data and isinstance(data[0], dict):
251
+ print_markdown(data, title, self.console)
252
+ else:
253
+ print_json(data, self.console)
254
+ elif self.format_type == "xml":
255
+ print_xml(data, title or "root", self.console)
256
+ elif self.format_type == "files":
257
+ if isinstance(data, list):
258
+ print_files(data, self.console)
259
+ else:
260
+ print_json(data, self.console)
261
+ elif isinstance(data, list) and data and isinstance(data[0], dict):
262
+ print_table(data, title, self.console)
263
+ else:
264
+ print_json(data, self.console)
265
+
266
+
267
+ def get_formatter(format_type: str = "table", console: Optional[Console] = None) -> OutputFormatter:
268
+ """Get an output formatter instance."""
269
+ return OutputFormatter(format_type, console)
sif/cli/main.py ADDED
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/env python3
2
+ """SIF CLI - Main entry point."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from pathlib import Path
7
+
8
+ import click
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from sif import __version__
13
+ from sif.config.settings import get_settings
14
+ from sif.database.database import Database
15
+ from sif.utils.logging import get_logger, setup_logging
16
+
17
+
18
+ logger = get_logger(__name__)
19
+ console = Console()
20
+
21
+ # Default paths
22
+ DEFAULT_INDEX_PATH = Path.home() / ".local" / "share" / "sif" / "sif.db"
23
+ DEFAULT_CONFIG_PATH = Path.home() / ".config" / "sif"
24
+
25
+
26
+ def _get_default_db_path() -> str:
27
+ """Get default database path from Settings."""
28
+ return str(get_settings().get_db_path())
29
+
30
+
31
+ @click.group()
32
+ @click.version_option(version=__version__, prog_name="sif")
33
+ @click.option(
34
+ "--index",
35
+ "-i",
36
+ type=click.Path(),
37
+ default=_get_default_db_path,
38
+ help="Path to the index database",
39
+ )
40
+ @click.option(
41
+ "--config",
42
+ "-c",
43
+ type=click.Path(),
44
+ default=str(DEFAULT_CONFIG_PATH),
45
+ help="Path to the configuration file",
46
+ )
47
+ @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
48
+ @click.option("--quiet", "-q", is_flag=True, help="Suppress non-error output")
49
+ @click.pass_context
50
+ def cli(ctx: click.Context, index: str, config: str, verbose: bool, quiet: bool) -> None:
51
+ """SIF - Search / Index / Find - Local document search engine.
52
+
53
+ SIF indexes your documents and enables fast, intelligent search
54
+ using BM25, vector similarity, and hybrid approaches.
55
+ """
56
+ # Setup logging
57
+ log_level = "DEBUG" if verbose else ("WARNING" if quiet else "INFO")
58
+ setup_logging(log_level)
59
+
60
+ # Ensure context object exists
61
+ ctx.ensure_object(dict)
62
+
63
+ # Store options in context
64
+ ctx.obj["index_path"] = Path(index).expanduser()
65
+ ctx.obj["config_path"] = Path(config).expanduser()
66
+ ctx.obj["verbose"] = verbose
67
+ ctx.obj["quiet"] = quiet
68
+
69
+ # Create index directory if needed
70
+ ctx.obj["index_path"].parent.mkdir(parents=True, exist_ok=True)
71
+
72
+
73
+ # Import and register subcommands
74
+ from sif.cli.commands.bench import bench_cmd
75
+ from sif.cli.commands.collection import collection_group
76
+ from sif.cli.commands.context import context_group
77
+ from sif.cli.commands.get import get_group
78
+ from sif.cli.commands.index import index_group
79
+ from sif.cli.commands.ls import ls_cmd
80
+ from sif.cli.commands.mcp import mcp_group
81
+ from sif.cli.commands.pull import pull_cmd
82
+ from sif.cli.commands.search import search_group
83
+
84
+
85
+ cli.add_command(collection_group)
86
+ cli.add_command(context_group)
87
+ cli.add_command(index_group)
88
+ cli.add_command(search_group)
89
+ cli.add_command(get_group)
90
+ cli.add_command(mcp_group)
91
+ cli.add_command(ls_cmd)
92
+ cli.add_command(pull_cmd)
93
+ cli.add_command(bench_cmd)
94
+
95
+
96
+ @cli.command("status")
97
+ @click.pass_context
98
+ def status_cmd(ctx: click.Context) -> None:
99
+ """Show index status."""
100
+ ctx.ensure_object(dict)
101
+ index_path = ctx.obj.get("index_path")
102
+ if index_path is None:
103
+ index_path = get_settings().get_db_path()
104
+
105
+ if not index_path.exists():
106
+ console.print("[yellow]No index found. Run 'sif update' to create one.[/yellow]")
107
+ return
108
+
109
+ try:
110
+ db = Database(index_path)
111
+ db.init_schema()
112
+ stats = db.get_stats()
113
+
114
+ table = Table(title="Index Status")
115
+ table.add_column("Metric", style="cyan")
116
+ table.add_column("Value", style="green")
117
+
118
+ table.add_row("Index Path", str(index_path))
119
+ table.add_row("Collections", str(stats.get("collections", 0)))
120
+ table.add_row("Documents", str(stats.get("documents", 0)))
121
+ table.add_row("Chunks", str(stats.get("chunks", 0)))
122
+ table.add_row("Contexts", str(stats.get("contexts", 0)))
123
+
124
+ total_size = stats.get("total_size_bytes", 0)
125
+ if total_size > 0:
126
+ size_mb = total_size / (1024 * 1024)
127
+ table.add_row("Total Size", f"{size_mb:.2f} MB")
128
+
129
+ console.print(table)
130
+
131
+ except Exception as e:
132
+ console.print(f"[red]Error reading index: {e}[/red]")
133
+ raise click.ClickException(str(e)) from e
134
+
135
+
136
+ @cli.command("cleanup")
137
+ @click.confirmation_option(prompt="Are you sure you want to clean up the index?")
138
+ @click.pass_context
139
+ def cleanup_cmd(ctx: click.Context) -> None:
140
+ """Clean up the index (remove orphaned entries)."""
141
+ index_path = ctx.obj["index_path"]
142
+
143
+ if not index_path.exists():
144
+ console.print("[yellow]No index found.[/yellow]")
145
+ return
146
+
147
+ try:
148
+ db = Database(index_path)
149
+ db.init_schema()
150
+
151
+ with db.transaction() as conn:
152
+ # Clean up orphaned chunks
153
+ cursor = conn.execute("""
154
+ DELETE FROM document_chunks
155
+ WHERE document_id NOT IN (SELECT id FROM documents)
156
+ """)
157
+ chunks_removed = cursor.rowcount
158
+
159
+ # Clean up orphaned embeddings
160
+ try:
161
+ cursor = conn.execute("""
162
+ DELETE FROM document_embeddings
163
+ WHERE document_id NOT IN (SELECT id FROM documents)
164
+ """)
165
+ embeddings_removed = cursor.rowcount
166
+ except sqlite3.OperationalError: # noqa: F821
167
+ embeddings_removed = 0
168
+
169
+ # Clean up expired LLM cache
170
+ from datetime import datetime, timezone # noqa: PLC0415
171
+
172
+ cursor = conn.execute(
173
+ """
174
+ DELETE FROM llm_cache
175
+ WHERE expires_at IS NOT NULL AND expires_at <= ?
176
+ """,
177
+ (datetime.now(timezone.utc).isoformat(),),
178
+ )
179
+ cache_removed = cursor.rowcount
180
+
181
+ console.print("[green]Cleanup complete:[/green]")
182
+ console.print(f" - Removed {chunks_removed} orphaned chunks")
183
+ console.print(f" - Removed {embeddings_removed} orphaned embeddings")
184
+ console.print(f" - Removed {cache_removed} expired cache entries")
185
+
186
+ except Exception as e:
187
+ console.print(f"[red]Error during cleanup: {e}[/red]")
188
+ raise click.ClickException(str(e)) from e
189
+
190
+
191
+ def main() -> None:
192
+ """Main entry point."""
193
+ # Migrate old docsift model cache if present
194
+ old_model_dir = Path.home() / ".local" / "share" / "docsift" / "models"
195
+ new_model_dir = Path.home() / ".local" / "share" / "sif" / "models"
196
+ if old_model_dir.exists() and not new_model_dir.exists():
197
+ new_model_dir.parent.mkdir(parents=True, exist_ok=True)
198
+ old_model_dir.rename(new_model_dir)
199
+ console.print("[green]Migrated: ~/.local/share/docsift -> ~/.local/share/sif[/green]")
200
+
201
+ cli()
202
+
203
+
204
+ if __name__ == "__main__":
205
+ main()