shellmate-cli 3.0.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.
- shellmate/__init__.py +0 -0
- shellmate/adapters/__init__.py +67 -0
- shellmate/adapters/bash_adapter.sh +7 -0
- shellmate/adapters/ble_adapter.sh +350 -0
- shellmate/adapters/powershell_adapter.ps1 +417 -0
- shellmate/adapters/powershell_predictor.ps1 +97 -0
- shellmate/adapters/zsh_adapter.zsh +8 -0
- shellmate/agents/__init__.py +23 -0
- shellmate/agents/base.py +248 -0
- shellmate/agents/chain_patterns.yaml +98 -0
- shellmate/agents/command_chain.py +322 -0
- shellmate/agents/context_gatherer.py +175 -0
- shellmate/agents/environment.py +363 -0
- shellmate/agents/librarian.py +502 -0
- shellmate/agents/query.py +415 -0
- shellmate/agents/repomix_backend.py +171 -0
- shellmate/agents/watcher.py +266 -0
- shellmate/cache/__init__.py +0 -0
- shellmate/cache/semantic_cache.py +41 -0
- shellmate/cli/__init__.py +0 -0
- shellmate/cli/commands/__init__.py +27 -0
- shellmate/cli/commands/analyze.py +5 -0
- shellmate/cli/commands/config.py +319 -0
- shellmate/cli/commands/configure.py +118 -0
- shellmate/cli/commands/disable.py +11 -0
- shellmate/cli/commands/enable.py +49 -0
- shellmate/cli/commands/init.py +412 -0
- shellmate/cli/commands/install.py +242 -0
- shellmate/cli/commands/migrate.py +147 -0
- shellmate/cli/commands/status.py +31 -0
- shellmate/cli/commands/uninstall.py +78 -0
- shellmate/cli/main.py +114 -0
- shellmate/cli/parser.py +184 -0
- shellmate/cli/version.py +1 -0
- shellmate/completion/__init__.py +4 -0
- shellmate/completion/bash_adapter.py +145 -0
- shellmate/config/__init__.py +33 -0
- shellmate/config/credentials.py +120 -0
- shellmate/config/defaults.py +88 -0
- shellmate/config/manager.py +94 -0
- shellmate/config/paths.py +77 -0
- shellmate/config/secrets.py +66 -0
- shellmate/daemon/__init__.py +0 -0
- shellmate/daemon/lifecycle.py +308 -0
- shellmate/daemon/observability.py +82 -0
- shellmate/daemon/pipeline.py +119 -0
- shellmate/daemon/server.py +605 -0
- shellmate/daemon/session.py +170 -0
- shellmate/daemon/startup_ui.py +43 -0
- shellmate/environment/__init__.py +0 -0
- shellmate/environment/detector.py +130 -0
- shellmate/ipc/__init__.py +0 -0
- shellmate/ipc/client.py +210 -0
- shellmate/ipc/ps_client.py +190 -0
- shellmate/orchestration/__init__.py +3 -0
- shellmate/orchestration/analysis_graph.py +1274 -0
- shellmate/orchestration/state.py +35 -0
- shellmate/parser/__init__.py +0 -0
- shellmate/parser/command_parser.py +111 -0
- shellmate/plugins/__init__.py +0 -0
- shellmate/plugins/base.py +80 -0
- shellmate/plugins/loader.py +41 -0
- shellmate/providers/__init__.py +0 -0
- shellmate/providers/base.py +97 -0
- shellmate/providers/groq_command_guide.py +108 -0
- shellmate/providers/groq_provider.py +127 -0
- shellmate/providers/groq_supervisor.py +78 -0
- shellmate/providers/openrouter_provider.py +149 -0
- shellmate/providers/router.py +272 -0
- shellmate/providers/validate_keys.py +98 -0
- shellmate/providers/xai_provider.py +142 -0
- shellmate/rules/__init__.py +0 -0
- shellmate/rules/catastrophic.yaml +96 -0
- shellmate/rules/catastrophic_matcher.py +167 -0
- shellmate/rules/definitions/__init__.py +0 -0
- shellmate/rules/definitions/file_deletion.yaml +54 -0
- shellmate/rules/definitions/filesystem.yaml +43 -0
- shellmate/rules/definitions/network_execution.yaml +46 -0
- shellmate/rules/definitions/permissions.yaml +54 -0
- shellmate/rules/definitions/process_control.yaml +37 -0
- shellmate/rules/definitions/system_destruction.yaml +67 -0
- shellmate/rules/engine.py +200 -0
- shellmate/sanitizer/__init__.py +0 -0
- shellmate/sanitizer/typo_detector.py +114 -0
- shellmate/ui/__init__.py +10 -0
- shellmate/ui/analysis.py +164 -0
- shellmate/ui/annotations.py +232 -0
- shellmate/ui/banner.py +49 -0
- shellmate/ui/progress.py +126 -0
- shellmate/ui/runtime/__init__.py +15 -0
- shellmate/ui/runtime/console_factory.py +65 -0
- shellmate/ui/runtime/live_scene.py +116 -0
- shellmate/ui/runtime/motion_policy.py +69 -0
- shellmate/ui/runtime/tokens.py +47 -0
- shellmate/ui/scenes/__init__.py +24 -0
- shellmate/ui/scenes/enable.py +316 -0
- shellmate/ui/scenes/help.py +53 -0
- shellmate/ui/scenes/stages.py +128 -0
- shellmate/ui/scenes/status.py +181 -0
- shellmate/ui/status.py +3 -0
- shellmate_cli-3.0.0.dist-info/METADATA +352 -0
- shellmate_cli-3.0.0.dist-info/RECORD +106 -0
- shellmate_cli-3.0.0.dist-info/WHEEL +5 -0
- shellmate_cli-3.0.0.dist-info/entry_points.txt +3 -0
- shellmate_cli-3.0.0.dist-info/licenses/LICENSE +21 -0
- shellmate_cli-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
8
|
+
from collections import Counter
|
|
9
|
+
|
|
10
|
+
from shellmate.agents.base import Agent, AgentRequest, AgentResponse
|
|
11
|
+
from shellmate.agents.repomix_backend import get_repomix_backend
|
|
12
|
+
from shellmate.config.secrets import redact_secrets
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from watchdog.observers import Observer
|
|
16
|
+
from watchdog.events import FileSystemEventHandler, FileModifiedEvent, FileCreatedEvent, FileDeletedEvent, FileMovedEvent
|
|
17
|
+
|
|
18
|
+
_HAS_WATCHDOG = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
_HAS_WATCHDOG = False
|
|
21
|
+
|
|
22
|
+
WATCHDOG_POLL_INTERVAL = 2.0
|
|
23
|
+
WATCHDOG_DEBOUNCE = 1.0
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("shellmate.agents.librarian")
|
|
26
|
+
|
|
27
|
+
ELIGIBLE_EXTENSIONS = {
|
|
28
|
+
".py", ".js", ".ts", ".jsx", ".tsx", ".rs", ".go", ".java", ".rb",
|
|
29
|
+
".php", ".c", ".cpp", ".h", ".hpp", ".cs", ".swift", ".kt", ".scala",
|
|
30
|
+
".sh", ".bash", ".zsh", ".ps1", ".bat", ".cmd",
|
|
31
|
+
".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf",
|
|
32
|
+
".md", ".rst", ".txt", ".html", ".css", ".scss", ".less",
|
|
33
|
+
".xml", ".svg", ".dockerfile",
|
|
34
|
+
".env", ".env.example",
|
|
35
|
+
".gitignore", ".gitattributes",
|
|
36
|
+
".editorconfig", ".prettierrc", ".eslintrc",
|
|
37
|
+
"Makefile", "Dockerfile", "docker-compose.yml", "docker-compose.yaml",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
EXCLUDE_DIRS = {
|
|
41
|
+
".git", "__pycache__", "node_modules", "venv", ".venv", "env",
|
|
42
|
+
".env", "virtualenv", ".virtualenv", ".tox", ".nox",
|
|
43
|
+
".mypy_cache", ".pytest_cache", ".ruff_cache",
|
|
44
|
+
"target", "build", "dist", ".next", ".nuxt",
|
|
45
|
+
".cache", ".npm", ".yarn", ".pnpm-store",
|
|
46
|
+
"vendor", ".bundle", "deps", "third_party",
|
|
47
|
+
".idea", ".vscode", ".vs",
|
|
48
|
+
".gitlab", ".github",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
EXCLUDE_FILES = {
|
|
52
|
+
"package-lock.json", "yarn.lock", "pnpm-lock.yaml",
|
|
53
|
+
".DS_Store", "Thumbs.db",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
BINARY_EXTENSIONS = {
|
|
57
|
+
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".svg",
|
|
58
|
+
".woff", ".woff2", ".ttf", ".eot",
|
|
59
|
+
".zip", ".tar", ".gz", ".bz2", ".xz",
|
|
60
|
+
".exe", ".dll", ".so", ".dylib", ".bin",
|
|
61
|
+
".o", ".a", ".lib", ".obj",
|
|
62
|
+
".pyc", ".pyo",
|
|
63
|
+
".pdf", ".doc", ".docx", ".xls", ".xlsx",
|
|
64
|
+
".mp3", ".mp4", ".avi", ".mov",
|
|
65
|
+
".db", ".sqlite", ".sqlite3",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
MAX_FILES = 5000
|
|
69
|
+
MAX_MATCHES = 10
|
|
70
|
+
MAX_SNIPPET_LENGTH = 200
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _LibrarianFileHandler(FileSystemEventHandler):
|
|
74
|
+
def __init__(self, queue: asyncio.Queue, loop: asyncio.AbstractEventLoop):
|
|
75
|
+
self._queue = queue
|
|
76
|
+
self._loop = loop
|
|
77
|
+
|
|
78
|
+
def _put(self, item):
|
|
79
|
+
try:
|
|
80
|
+
self._loop.call_soon_threadsafe(self._queue.put_nowait, item)
|
|
81
|
+
except RuntimeError:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
def on_modified(self, event):
|
|
85
|
+
if not event.is_directory:
|
|
86
|
+
self._put(("modified", event.src_path))
|
|
87
|
+
|
|
88
|
+
def on_created(self, event):
|
|
89
|
+
if not event.is_directory:
|
|
90
|
+
self._put(("created", event.src_path))
|
|
91
|
+
|
|
92
|
+
def on_deleted(self, event):
|
|
93
|
+
if not event.is_directory:
|
|
94
|
+
self._put(("deleted", event.src_path))
|
|
95
|
+
|
|
96
|
+
def on_moved(self, event):
|
|
97
|
+
if not event.is_directory:
|
|
98
|
+
self._put(("deleted", event.src_path))
|
|
99
|
+
self._put(("created", event.dest_path))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class LibrarianAgent(Agent):
|
|
103
|
+
def __init__(self):
|
|
104
|
+
super().__init__("librarian")
|
|
105
|
+
self._project_root: Optional[Path] = None
|
|
106
|
+
self._files: List[Dict[str, Any]] = []
|
|
107
|
+
self._index: Dict[str, List[int]] = {}
|
|
108
|
+
self._ready = False
|
|
109
|
+
self._scan_task: Optional[asyncio.Task] = None
|
|
110
|
+
self._watchdog_queue: asyncio.Queue = asyncio.Queue()
|
|
111
|
+
self._watchdog_task: Optional[asyncio.Task] = None
|
|
112
|
+
self._repomix_task: Optional[asyncio.Task] = None
|
|
113
|
+
self._observer: Optional[Observer] = None
|
|
114
|
+
self._index_ready = asyncio.Event()
|
|
115
|
+
|
|
116
|
+
async def initialize(self) -> None:
|
|
117
|
+
self._project_root = Path.cwd()
|
|
118
|
+
logger.info(f"Librarian initializing for project: {self._project_root}")
|
|
119
|
+
self._scan_task = asyncio.create_task(self._scan_project())
|
|
120
|
+
self._watchdog_task = asyncio.create_task(self._watchdog_worker())
|
|
121
|
+
self._repomix_task = asyncio.create_task(self._init_repomix())
|
|
122
|
+
|
|
123
|
+
async def _init_repomix(self) -> None:
|
|
124
|
+
try:
|
|
125
|
+
backend = get_repomix_backend()
|
|
126
|
+
await backend.initialize()
|
|
127
|
+
except Exception as e:
|
|
128
|
+
logger.warning(f"Repomix initialization failed: {e}")
|
|
129
|
+
|
|
130
|
+
async def shutdown(self) -> None:
|
|
131
|
+
self._ready = False
|
|
132
|
+
self._index_ready.clear()
|
|
133
|
+
if self._observer:
|
|
134
|
+
self._observer.stop()
|
|
135
|
+
self._observer = None
|
|
136
|
+
if self._watchdog_task:
|
|
137
|
+
self._watchdog_task.cancel()
|
|
138
|
+
try:
|
|
139
|
+
await self._watchdog_task
|
|
140
|
+
except asyncio.CancelledError:
|
|
141
|
+
pass
|
|
142
|
+
if self._scan_task:
|
|
143
|
+
self._scan_task.cancel()
|
|
144
|
+
try:
|
|
145
|
+
await self._scan_task
|
|
146
|
+
except asyncio.CancelledError:
|
|
147
|
+
pass
|
|
148
|
+
if self._repomix_task:
|
|
149
|
+
self._repomix_task.cancel()
|
|
150
|
+
try:
|
|
151
|
+
await self._repomix_task
|
|
152
|
+
except asyncio.CancelledError:
|
|
153
|
+
pass
|
|
154
|
+
self._files.clear()
|
|
155
|
+
self._index.clear()
|
|
156
|
+
try:
|
|
157
|
+
backend = get_repomix_backend()
|
|
158
|
+
await backend.cleanup()
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
async def process(self, request: AgentRequest) -> AgentResponse:
|
|
163
|
+
if not self._ready:
|
|
164
|
+
return AgentResponse(
|
|
165
|
+
request_id=request.request_id,
|
|
166
|
+
agent_name=self.name,
|
|
167
|
+
status="not_ready",
|
|
168
|
+
data={"matches": [], "partial": True},
|
|
169
|
+
error="Librarian not yet initialized",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
query = request.data.get("query", "")
|
|
173
|
+
if not query:
|
|
174
|
+
return AgentResponse(
|
|
175
|
+
request_id=request.request_id,
|
|
176
|
+
agent_name=self.name,
|
|
177
|
+
status="ok",
|
|
178
|
+
data={"matches": []},
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
start = time.monotonic()
|
|
182
|
+
matches = self._search(query)
|
|
183
|
+
latency = (time.monotonic() - start) * 1000
|
|
184
|
+
|
|
185
|
+
return AgentResponse(
|
|
186
|
+
request_id=request.request_id,
|
|
187
|
+
agent_name=self.name,
|
|
188
|
+
status="ok",
|
|
189
|
+
data={"matches": matches},
|
|
190
|
+
latency_ms=latency,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
async def _scan_project(self) -> None:
|
|
194
|
+
try:
|
|
195
|
+
await self._scan_project_inner()
|
|
196
|
+
except Exception as e:
|
|
197
|
+
logger.error(f"Librarian scan failed: {e}", exc_info=True)
|
|
198
|
+
self._ready = True
|
|
199
|
+
self._index_ready.set()
|
|
200
|
+
|
|
201
|
+
async def _scan_project_inner(self) -> None:
|
|
202
|
+
if not self._project_root or not self._project_root.exists():
|
|
203
|
+
logger.warning(f"Project root does not exist: {self._project_root}")
|
|
204
|
+
self._ready = True
|
|
205
|
+
self._index_ready.set()
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
eligible = 0
|
|
209
|
+
skipped = {"binary": 0, "excluded_dir": 0, "excluded_file": 0, "other": 0}
|
|
210
|
+
self._files = []
|
|
211
|
+
|
|
212
|
+
for root, dirs, files in os.walk(self._project_root, topdown=True):
|
|
213
|
+
rel_root = Path(root).relative_to(self._project_root)
|
|
214
|
+
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
|
|
215
|
+
|
|
216
|
+
for f in files:
|
|
217
|
+
if eligible >= MAX_FILES:
|
|
218
|
+
logger.warning(
|
|
219
|
+
f"Reached {MAX_FILES} file cap. Skipping remaining files."
|
|
220
|
+
)
|
|
221
|
+
break
|
|
222
|
+
|
|
223
|
+
file_path = Path(root) / f
|
|
224
|
+
rel_path = file_path.relative_to(self._project_root)
|
|
225
|
+
|
|
226
|
+
if f in EXCLUDE_FILES:
|
|
227
|
+
skipped["excluded_file"] += 1
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
ext = file_path.suffix.lower()
|
|
231
|
+
if ext in BINARY_EXTENSIONS:
|
|
232
|
+
skipped["binary"] += 1
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
if not self._is_eligible(file_path):
|
|
236
|
+
skipped["other"] += 1
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
file_record = self._index_file(file_path, rel_path)
|
|
240
|
+
if file_record:
|
|
241
|
+
self._files.append(file_record)
|
|
242
|
+
eligible += 1
|
|
243
|
+
|
|
244
|
+
if eligible >= MAX_FILES:
|
|
245
|
+
break
|
|
246
|
+
|
|
247
|
+
self._build_index()
|
|
248
|
+
self._write_debug_index()
|
|
249
|
+
|
|
250
|
+
total_skipped = sum(skipped.values())
|
|
251
|
+
logger.info(
|
|
252
|
+
f"Indexed {eligible} files"
|
|
253
|
+
+ (f", skipped {total_skipped} files" if total_skipped else "")
|
|
254
|
+
)
|
|
255
|
+
self._ready = True
|
|
256
|
+
self._index_ready.set()
|
|
257
|
+
|
|
258
|
+
def _is_eligible(self, file_path: Path) -> bool:
|
|
259
|
+
ext = file_path.suffix.lower()
|
|
260
|
+
if ext in ELIGIBLE_EXTENSIONS:
|
|
261
|
+
return True
|
|
262
|
+
name = file_path.name.lower()
|
|
263
|
+
if name in ("makefile", "dockerfile", "procfile"):
|
|
264
|
+
return True
|
|
265
|
+
if name.startswith(".") and ext:
|
|
266
|
+
return True
|
|
267
|
+
return False
|
|
268
|
+
|
|
269
|
+
def _index_file(self, file_path: Path, rel_path: Path) -> Optional[Dict[str, Any]]:
|
|
270
|
+
try:
|
|
271
|
+
size = file_path.stat().st_size
|
|
272
|
+
if size > 1024 * 1024:
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
content = file_path.read_text(encoding="utf-8", errors="replace")
|
|
277
|
+
except (OSError, UnicodeDecodeError):
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
snippet = self._make_snippet(content)
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
"path": str(rel_path).replace("\\", "/"),
|
|
284
|
+
"size": size,
|
|
285
|
+
"ext": file_path.suffix.lower(),
|
|
286
|
+
"snippet": snippet,
|
|
287
|
+
}
|
|
288
|
+
except OSError as e:
|
|
289
|
+
logger.debug(f"Permission error reading {file_path}: {e}")
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
def _make_snippet(self, content: str) -> str:
|
|
293
|
+
lines = content.split("\n")
|
|
294
|
+
if not lines:
|
|
295
|
+
return ""
|
|
296
|
+
non_empty = [l for l in lines if l.strip()]
|
|
297
|
+
if not non_empty:
|
|
298
|
+
return ""
|
|
299
|
+
|
|
300
|
+
combined = "\n".join(lines[:20])
|
|
301
|
+
if len(combined) > MAX_SNIPPET_LENGTH:
|
|
302
|
+
combined = combined[: MAX_SNIPPET_LENGTH - 3] + "..."
|
|
303
|
+
return combined
|
|
304
|
+
|
|
305
|
+
def _build_index(self) -> None:
|
|
306
|
+
self._index = {}
|
|
307
|
+
for idx, file_record in enumerate(self._files):
|
|
308
|
+
path = file_record["path"]
|
|
309
|
+
tokens = self._tokenize(path)
|
|
310
|
+
stem = Path(path).stem
|
|
311
|
+
tokens.extend(self._tokenize(stem))
|
|
312
|
+
tokens = list(set(tokens))
|
|
313
|
+
|
|
314
|
+
for token in tokens:
|
|
315
|
+
if token not in self._index:
|
|
316
|
+
self._index[token] = []
|
|
317
|
+
self._index[token].append(idx)
|
|
318
|
+
|
|
319
|
+
def _tokenize(self, text: str) -> List[str]:
|
|
320
|
+
parts = re.split(r"[/\\\.\-_ ]", text.lower())
|
|
321
|
+
result = []
|
|
322
|
+
for part in parts:
|
|
323
|
+
if len(part) >= 2:
|
|
324
|
+
result.append(part)
|
|
325
|
+
sub_parts = re.findall(r"[a-z]+|[A-Z][a-z]*|\d+", part)
|
|
326
|
+
for sp in sub_parts:
|
|
327
|
+
sp_lower = sp.lower()
|
|
328
|
+
if len(sp_lower) >= 2 and sp_lower != part:
|
|
329
|
+
result.append(sp_lower)
|
|
330
|
+
return result
|
|
331
|
+
|
|
332
|
+
def _search(self, query: str) -> List[Dict[str, Any]]:
|
|
333
|
+
query_tokens = self._tokenize(query)
|
|
334
|
+
if not query_tokens:
|
|
335
|
+
return []
|
|
336
|
+
|
|
337
|
+
path_token = query.lower().replace("\\", "/")
|
|
338
|
+
doc_scores = Counter()
|
|
339
|
+
|
|
340
|
+
for idx, file_record in enumerate(self._files):
|
|
341
|
+
file_path = file_record["path"].lower()
|
|
342
|
+
score = 0
|
|
343
|
+
|
|
344
|
+
if path_token in file_path:
|
|
345
|
+
score += 10
|
|
346
|
+
if file_path == path_token:
|
|
347
|
+
score += 20
|
|
348
|
+
|
|
349
|
+
file_stem = Path(file_record["path"]).stem.lower()
|
|
350
|
+
if file_stem == path_token:
|
|
351
|
+
score += 5
|
|
352
|
+
|
|
353
|
+
if score > 0:
|
|
354
|
+
doc_scores[idx] += score
|
|
355
|
+
|
|
356
|
+
for token in query_tokens:
|
|
357
|
+
if token in self._index:
|
|
358
|
+
for idx in self._index[token]:
|
|
359
|
+
doc_scores[idx] += 1
|
|
360
|
+
|
|
361
|
+
for idx, file_record in enumerate(self._files):
|
|
362
|
+
if token in file_record["snippet"].lower():
|
|
363
|
+
doc_scores[idx] += 0.5
|
|
364
|
+
|
|
365
|
+
ranked = sorted(doc_scores.items(), key=lambda x: (-x[1], self._files[x[0]]["path"]))
|
|
366
|
+
matches = []
|
|
367
|
+
for idx, score in ranked[:MAX_MATCHES]:
|
|
368
|
+
file_record = self._files[idx]
|
|
369
|
+
matches.append({
|
|
370
|
+
"path": file_record["path"],
|
|
371
|
+
"score": round(score, 1),
|
|
372
|
+
"snippet": redact_secrets(file_record["snippet"]),
|
|
373
|
+
"size": file_record["size"],
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
return matches
|
|
377
|
+
|
|
378
|
+
def _start_file_watcher(self) -> None:
|
|
379
|
+
if not self._project_root:
|
|
380
|
+
return
|
|
381
|
+
if _HAS_WATCHDOG:
|
|
382
|
+
try:
|
|
383
|
+
handler = _LibrarianFileHandler(self._watchdog_queue, asyncio.get_running_loop())
|
|
384
|
+
self._observer = Observer()
|
|
385
|
+
self._observer.schedule(handler, str(self._project_root), recursive=True)
|
|
386
|
+
self._observer.start()
|
|
387
|
+
logger.info("Watchdog file watcher started")
|
|
388
|
+
except Exception as e:
|
|
389
|
+
logger.warning(f"Failed to start watchdog: {e}. Falling back to polling.")
|
|
390
|
+
self._observer = None
|
|
391
|
+
if not self._observer:
|
|
392
|
+
logger.info("Polling-based file watcher not implemented; index refresh on demand only")
|
|
393
|
+
|
|
394
|
+
async def _watchdog_worker(self) -> None:
|
|
395
|
+
try:
|
|
396
|
+
await self._scan_task
|
|
397
|
+
except asyncio.CancelledError:
|
|
398
|
+
return
|
|
399
|
+
self._start_file_watcher()
|
|
400
|
+
pending: Dict[str, str] = {}
|
|
401
|
+
while True:
|
|
402
|
+
try:
|
|
403
|
+
event_type, path = await asyncio.wait_for(
|
|
404
|
+
self._watchdog_queue.get(), timeout=WATCHDOG_DEBOUNCE
|
|
405
|
+
)
|
|
406
|
+
except asyncio.TimeoutError:
|
|
407
|
+
if pending:
|
|
408
|
+
await self._process_watchdog_batch(pending)
|
|
409
|
+
pending.clear()
|
|
410
|
+
continue
|
|
411
|
+
except asyncio.CancelledError:
|
|
412
|
+
if pending:
|
|
413
|
+
await self._process_watchdog_batch(pending)
|
|
414
|
+
break
|
|
415
|
+
|
|
416
|
+
if not self._project_root:
|
|
417
|
+
continue
|
|
418
|
+
try:
|
|
419
|
+
rel_path = Path(path).relative_to(self._project_root)
|
|
420
|
+
except ValueError:
|
|
421
|
+
continue
|
|
422
|
+
|
|
423
|
+
if any(part in EXCLUDE_DIRS for part in rel_path.parts):
|
|
424
|
+
continue
|
|
425
|
+
file_path = Path(path)
|
|
426
|
+
if file_path.name in EXCLUDE_FILES:
|
|
427
|
+
continue
|
|
428
|
+
ext = file_path.suffix.lower()
|
|
429
|
+
if ext in BINARY_EXTENSIONS:
|
|
430
|
+
continue
|
|
431
|
+
if not self._is_eligible(file_path):
|
|
432
|
+
continue
|
|
433
|
+
|
|
434
|
+
rel_str = str(rel_path).replace("\\", "/")
|
|
435
|
+
pending[rel_str] = event_type
|
|
436
|
+
|
|
437
|
+
def _remove_file(self, rel_path: str) -> None:
|
|
438
|
+
for idx, record in enumerate(self._files):
|
|
439
|
+
if record["path"] == rel_path:
|
|
440
|
+
self._files.pop(idx)
|
|
441
|
+
return
|
|
442
|
+
|
|
443
|
+
def _rebuild_index(self) -> None:
|
|
444
|
+
self._build_index()
|
|
445
|
+
|
|
446
|
+
async def _process_watchdog_batch(self, pending: Dict[str, str]) -> None:
|
|
447
|
+
for rel_str, event_type in pending.items():
|
|
448
|
+
if event_type == "deleted":
|
|
449
|
+
self._remove_file(rel_str)
|
|
450
|
+
logger.debug(f"Watchdog removed: {rel_str}")
|
|
451
|
+
else:
|
|
452
|
+
file_path = self._project_root / rel_str
|
|
453
|
+
rel_path = Path(rel_str)
|
|
454
|
+
record = self._index_file(file_path, rel_path)
|
|
455
|
+
if record:
|
|
456
|
+
self._remove_file(rel_str)
|
|
457
|
+
self._files.append(record)
|
|
458
|
+
logger.debug(f"Watchdog updated: {rel_str}")
|
|
459
|
+
self._rebuild_index()
|
|
460
|
+
logger.debug(f"Watchdog batch processed {len(pending)} changes")
|
|
461
|
+
|
|
462
|
+
def _write_debug_index(self) -> None:
|
|
463
|
+
from shellmate.daemon.session import get_session_dir_path
|
|
464
|
+
session_dir = get_session_dir_path()
|
|
465
|
+
try:
|
|
466
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
467
|
+
index_path = session_dir / "index.md"
|
|
468
|
+
with open(index_path, "w", encoding="utf-8") as f:
|
|
469
|
+
f.write(f"# ShellMate Librarian Index\n")
|
|
470
|
+
f.write(f"Project: {self._project_root}\n")
|
|
471
|
+
f.write(f"Files: {len(self._files)}\n")
|
|
472
|
+
f.write(f"Generated: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n")
|
|
473
|
+
for file_record in sorted(self._files, key=lambda x: x["path"]):
|
|
474
|
+
f.write(f"- {file_record['path']} ({file_record['size']} bytes)\n")
|
|
475
|
+
logger.debug(f"Debug index written to {index_path}")
|
|
476
|
+
except OSError as e:
|
|
477
|
+
logger.warning(f"Failed to write debug index: {e}")
|
|
478
|
+
|
|
479
|
+
@property
|
|
480
|
+
def file_count(self) -> int:
|
|
481
|
+
return len(self._files)
|
|
482
|
+
|
|
483
|
+
@property
|
|
484
|
+
def is_indexed(self) -> bool:
|
|
485
|
+
return self._ready
|
|
486
|
+
|
|
487
|
+
async def wait_until_indexed(self, timeout: Optional[float] = None) -> bool:
|
|
488
|
+
try:
|
|
489
|
+
await asyncio.wait_for(self._index_ready.wait(), timeout=timeout)
|
|
490
|
+
return True
|
|
491
|
+
except asyncio.TimeoutError:
|
|
492
|
+
return False
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
_librarian_agent: Optional[LibrarianAgent] = None
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def get_librarian_agent() -> LibrarianAgent:
|
|
499
|
+
global _librarian_agent
|
|
500
|
+
if _librarian_agent is None:
|
|
501
|
+
_librarian_agent = LibrarianAgent()
|
|
502
|
+
return _librarian_agent
|