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.
Files changed (106) hide show
  1. shellmate/__init__.py +0 -0
  2. shellmate/adapters/__init__.py +67 -0
  3. shellmate/adapters/bash_adapter.sh +7 -0
  4. shellmate/adapters/ble_adapter.sh +350 -0
  5. shellmate/adapters/powershell_adapter.ps1 +417 -0
  6. shellmate/adapters/powershell_predictor.ps1 +97 -0
  7. shellmate/adapters/zsh_adapter.zsh +8 -0
  8. shellmate/agents/__init__.py +23 -0
  9. shellmate/agents/base.py +248 -0
  10. shellmate/agents/chain_patterns.yaml +98 -0
  11. shellmate/agents/command_chain.py +322 -0
  12. shellmate/agents/context_gatherer.py +175 -0
  13. shellmate/agents/environment.py +363 -0
  14. shellmate/agents/librarian.py +502 -0
  15. shellmate/agents/query.py +415 -0
  16. shellmate/agents/repomix_backend.py +171 -0
  17. shellmate/agents/watcher.py +266 -0
  18. shellmate/cache/__init__.py +0 -0
  19. shellmate/cache/semantic_cache.py +41 -0
  20. shellmate/cli/__init__.py +0 -0
  21. shellmate/cli/commands/__init__.py +27 -0
  22. shellmate/cli/commands/analyze.py +5 -0
  23. shellmate/cli/commands/config.py +319 -0
  24. shellmate/cli/commands/configure.py +118 -0
  25. shellmate/cli/commands/disable.py +11 -0
  26. shellmate/cli/commands/enable.py +49 -0
  27. shellmate/cli/commands/init.py +412 -0
  28. shellmate/cli/commands/install.py +242 -0
  29. shellmate/cli/commands/migrate.py +147 -0
  30. shellmate/cli/commands/status.py +31 -0
  31. shellmate/cli/commands/uninstall.py +78 -0
  32. shellmate/cli/main.py +114 -0
  33. shellmate/cli/parser.py +184 -0
  34. shellmate/cli/version.py +1 -0
  35. shellmate/completion/__init__.py +4 -0
  36. shellmate/completion/bash_adapter.py +145 -0
  37. shellmate/config/__init__.py +33 -0
  38. shellmate/config/credentials.py +120 -0
  39. shellmate/config/defaults.py +88 -0
  40. shellmate/config/manager.py +94 -0
  41. shellmate/config/paths.py +77 -0
  42. shellmate/config/secrets.py +66 -0
  43. shellmate/daemon/__init__.py +0 -0
  44. shellmate/daemon/lifecycle.py +308 -0
  45. shellmate/daemon/observability.py +82 -0
  46. shellmate/daemon/pipeline.py +119 -0
  47. shellmate/daemon/server.py +605 -0
  48. shellmate/daemon/session.py +170 -0
  49. shellmate/daemon/startup_ui.py +43 -0
  50. shellmate/environment/__init__.py +0 -0
  51. shellmate/environment/detector.py +130 -0
  52. shellmate/ipc/__init__.py +0 -0
  53. shellmate/ipc/client.py +210 -0
  54. shellmate/ipc/ps_client.py +190 -0
  55. shellmate/orchestration/__init__.py +3 -0
  56. shellmate/orchestration/analysis_graph.py +1274 -0
  57. shellmate/orchestration/state.py +35 -0
  58. shellmate/parser/__init__.py +0 -0
  59. shellmate/parser/command_parser.py +111 -0
  60. shellmate/plugins/__init__.py +0 -0
  61. shellmate/plugins/base.py +80 -0
  62. shellmate/plugins/loader.py +41 -0
  63. shellmate/providers/__init__.py +0 -0
  64. shellmate/providers/base.py +97 -0
  65. shellmate/providers/groq_command_guide.py +108 -0
  66. shellmate/providers/groq_provider.py +127 -0
  67. shellmate/providers/groq_supervisor.py +78 -0
  68. shellmate/providers/openrouter_provider.py +149 -0
  69. shellmate/providers/router.py +272 -0
  70. shellmate/providers/validate_keys.py +98 -0
  71. shellmate/providers/xai_provider.py +142 -0
  72. shellmate/rules/__init__.py +0 -0
  73. shellmate/rules/catastrophic.yaml +96 -0
  74. shellmate/rules/catastrophic_matcher.py +167 -0
  75. shellmate/rules/definitions/__init__.py +0 -0
  76. shellmate/rules/definitions/file_deletion.yaml +54 -0
  77. shellmate/rules/definitions/filesystem.yaml +43 -0
  78. shellmate/rules/definitions/network_execution.yaml +46 -0
  79. shellmate/rules/definitions/permissions.yaml +54 -0
  80. shellmate/rules/definitions/process_control.yaml +37 -0
  81. shellmate/rules/definitions/system_destruction.yaml +67 -0
  82. shellmate/rules/engine.py +200 -0
  83. shellmate/sanitizer/__init__.py +0 -0
  84. shellmate/sanitizer/typo_detector.py +114 -0
  85. shellmate/ui/__init__.py +10 -0
  86. shellmate/ui/analysis.py +164 -0
  87. shellmate/ui/annotations.py +232 -0
  88. shellmate/ui/banner.py +49 -0
  89. shellmate/ui/progress.py +126 -0
  90. shellmate/ui/runtime/__init__.py +15 -0
  91. shellmate/ui/runtime/console_factory.py +65 -0
  92. shellmate/ui/runtime/live_scene.py +116 -0
  93. shellmate/ui/runtime/motion_policy.py +69 -0
  94. shellmate/ui/runtime/tokens.py +47 -0
  95. shellmate/ui/scenes/__init__.py +24 -0
  96. shellmate/ui/scenes/enable.py +316 -0
  97. shellmate/ui/scenes/help.py +53 -0
  98. shellmate/ui/scenes/stages.py +128 -0
  99. shellmate/ui/scenes/status.py +181 -0
  100. shellmate/ui/status.py +3 -0
  101. shellmate_cli-3.0.0.dist-info/METADATA +352 -0
  102. shellmate_cli-3.0.0.dist-info/RECORD +106 -0
  103. shellmate_cli-3.0.0.dist-info/WHEEL +5 -0
  104. shellmate_cli-3.0.0.dist-info/entry_points.txt +3 -0
  105. shellmate_cli-3.0.0.dist-info/licenses/LICENSE +21 -0
  106. 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