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.
- docsif-0.1.0.dist-info/METADATA +393 -0
- docsif-0.1.0.dist-info/RECORD +86 -0
- docsif-0.1.0.dist-info/WHEEL +4 -0
- docsif-0.1.0.dist-info/entry_points.txt +3 -0
- docsif-0.1.0.dist-info/licenses/LICENSE +21 -0
- sif/__init__.py +24 -0
- sif/_version.py +3 -0
- sif/cli/__init__.py +11 -0
- sif/cli/commands/__init__.py +18 -0
- sif/cli/commands/bench.py +148 -0
- sif/cli/commands/collection.py +360 -0
- sif/cli/commands/context.py +154 -0
- sif/cli/commands/get.py +200 -0
- sif/cli/commands/index.py +294 -0
- sif/cli/commands/ls.py +67 -0
- sif/cli/commands/mcp.py +93 -0
- sif/cli/commands/pull.py +111 -0
- sif/cli/commands/search.py +574 -0
- sif/cli/config.py +206 -0
- sif/cli/formatters.py +269 -0
- sif/cli/main.py +205 -0
- sif/config/__init__.py +30 -0
- sif/config/constants.py +52 -0
- sif/config/settings.py +217 -0
- sif/core/__init__.py +21 -0
- sif/core/collection.py +161 -0
- sif/core/context.py +167 -0
- sif/core/document.py +125 -0
- sif/core/models.py +298 -0
- sif/database/__init__.py +23 -0
- sif/database/connection.py +139 -0
- sif/database/database.py +136 -0
- sif/database/migrations.py +187 -0
- sif/database/repositories.py +500 -0
- sif/database/repository.py +171 -0
- sif/database/schema.py +362 -0
- sif/embedding/__init__.py +22 -0
- sif/embedding/cache.py +196 -0
- sif/embedding/embedder.py +328 -0
- sif/embedding/factory.py +92 -0
- sif/embedding/manager.py +200 -0
- sif/embedding/model.py +126 -0
- sif/indexing/__init__.py +21 -0
- sif/indexing/chunker.py +226 -0
- sif/indexing/indexer.py +226 -0
- sif/indexing/parser.py +192 -0
- sif/indexing/scanner.py +148 -0
- sif/indexing/watcher.py +156 -0
- sif/mcp/README.md +258 -0
- sif/mcp/__init__.py +9 -0
- sif/mcp/claude_desktop_config.json +16 -0
- sif/mcp/cli.py +92 -0
- sif/mcp/protocol.py +336 -0
- sif/mcp/server.py +433 -0
- sif/mcp/server_http.py +54 -0
- sif/mcp/test_mcp.py +108 -0
- sif/mcp/tools.py +497 -0
- sif/mcp/transport_http.py +424 -0
- sif/mcp/transport_stdio.py +292 -0
- sif/mcp_server/__init__.py +26 -0
- sif/mcp_server/handlers.py +68 -0
- sif/mcp_server/server.py +58 -0
- sif/mcp_server/tools.py +216 -0
- sif/mcp_server/transport.py +221 -0
- sif/models/__init__.py +70 -0
- sif/models/collection.py +51 -0
- sif/models/context.py +45 -0
- sif/models/document.py +76 -0
- sif/models/download.py +208 -0
- sif/models/embedding.py +71 -0
- sif/models/search.py +92 -0
- sif/search/__init__.py +22 -0
- sif/search/benchmark.py +110 -0
- sif/search/bm25.py +216 -0
- sif/search/expansion.py +157 -0
- sif/search/hybrid.py +323 -0
- sif/search/rerank.py +390 -0
- sif/search/rrf.py +186 -0
- sif/search/snippets.py +130 -0
- sif/search/strategy.py +97 -0
- sif/search/vector.py +177 -0
- sif/utils/__init__.py +26 -0
- sif/utils/logging.py +143 -0
- sif/utils/paths.py +82 -0
- sif/utils/progress.py +83 -0
- 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()
|