ragmir 6.11.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.
ragmir/__init__.py ADDED
@@ -0,0 +1,55 @@
1
+ """Cited local retrieval for Python: search a project's indexed code and documents with Ragmir.
2
+
3
+ >>> from ragmir import Ragmir
4
+ >>> with Ragmir("path/to/project") as rag:
5
+ ... for passage in rag.search("who approves a production release?"):
6
+ ... print(passage.citation, passage.text)
7
+
8
+ Ragmir runs locally through Node.js (a project install, a global ``rgr``, or the pinned npm
9
+ release through npx) or the official Docker image. See https://github.com/jcode-works/jcode-ragmir.
10
+ """
11
+
12
+ from ._client import DEFAULT_TIMEOUT, AsyncRagmir, Ragmir
13
+ from ._errors import (
14
+ RagmirCommandError,
15
+ RagmirError,
16
+ RagmirNotFoundError,
17
+ RagmirServerError,
18
+ RagmirToolError,
19
+ )
20
+ from ._launcher import Launcher, resolve_launcher
21
+ from ._models import (
22
+ ExpandedCitation,
23
+ IndexedFile,
24
+ IngestReport,
25
+ Passage,
26
+ SourceCatalog,
27
+ Status,
28
+ )
29
+ from ._setup import add_sources, init_project, run_cli
30
+ from ._shared import shared_client
31
+ from ._version import __version__
32
+
33
+ __all__ = [
34
+ "DEFAULT_TIMEOUT",
35
+ "AsyncRagmir",
36
+ "ExpandedCitation",
37
+ "IndexedFile",
38
+ "IngestReport",
39
+ "Launcher",
40
+ "Passage",
41
+ "Ragmir",
42
+ "RagmirCommandError",
43
+ "RagmirError",
44
+ "RagmirNotFoundError",
45
+ "RagmirServerError",
46
+ "RagmirToolError",
47
+ "SourceCatalog",
48
+ "Status",
49
+ "__version__",
50
+ "add_sources",
51
+ "init_project",
52
+ "resolve_launcher",
53
+ "run_cli",
54
+ "shared_client",
55
+ ]
ragmir/_client.py ADDED
@@ -0,0 +1,567 @@
1
+ """Clients that keep one Ragmir MCP server running per project and reuse it for every call."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import concurrent.futures
7
+ import json
8
+ import logging
9
+ import os
10
+ import tempfile
11
+ import threading
12
+ from collections.abc import Coroutine, Mapping, Sequence
13
+ from pathlib import Path
14
+ from typing import Any, TextIO, TypeVar
15
+
16
+ from mcp import Client, Implementation, MCPError, StdioServerParameters, stdio_client
17
+ from mcp.types import TextContent
18
+
19
+ from ._errors import RagmirError, RagmirServerError, RagmirToolError
20
+ from ._launcher import resolve_launcher
21
+ from ._models import ExpandedCitation, IngestReport, Passage, SourceCatalog, Status
22
+ from ._version import __version__
23
+
24
+ logger = logging.getLogger("ragmir")
25
+
26
+ T = TypeVar("T")
27
+
28
+ DEFAULT_TIMEOUT = 120.0
29
+ """Seconds to wait for the server to start and for each call to answer."""
30
+
31
+ # The server caps every answer at its configured mcpMaxOutputBytes; ask for that ceiling.
32
+ _MAX_OUTPUT_BYTES = 1_048_576
33
+ _INGEST_WAIT_SECONDS = 60
34
+ _STDERR_TAIL_CHARACTERS = 2_000
35
+
36
+
37
+ class AsyncRagmir:
38
+ """Async client for one Ragmir knowledge base, for asyncio applications.
39
+
40
+ The MCP server starts on the first call, or with ``connect()``, and serves every later call. A
41
+ background task owns the connection, so any task may call or close the client. Close it with
42
+ ``aclose()``, or use ``async with``.
43
+
44
+ >>> async with AsyncRagmir("path/to/project") as rag:
45
+ ... for passage in await rag.search("release approval"):
46
+ ... print(passage.citation, passage.text)
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ project_root: str | os.PathLike[str] = ".",
52
+ *,
53
+ command: Sequence[str] | str | None = None,
54
+ env: Mapping[str, str] | None = None,
55
+ timeout: float = DEFAULT_TIMEOUT,
56
+ ) -> None:
57
+ self.project_root = Path(project_root).expanduser().resolve()
58
+ self._command = command
59
+ self._env = dict(env) if env is not None else {}
60
+ self._timeout = timeout
61
+ self._client: Client | None = None
62
+ self._owner: asyncio.Task[None] | None = None
63
+ self._stop: asyncio.Event | None = None
64
+ self._lock: asyncio.Lock | None = None
65
+ self._stderr: TextIO | None = None
66
+ self._closed = False
67
+
68
+ async def __aenter__(self) -> AsyncRagmir:
69
+ await self.connect()
70
+ return self
71
+
72
+ async def __aexit__(self, *_exc: object) -> None:
73
+ await self.aclose()
74
+
75
+ async def connect(self) -> None:
76
+ """Start the MCP server now instead of on the first call."""
77
+ await self._session()
78
+
79
+ async def aclose(self) -> None:
80
+ """Stop the MCP server. The client cannot be used afterwards."""
81
+ self._closed = True
82
+ await self._shutdown()
83
+ if self._stderr is not None:
84
+ self._stderr.close()
85
+ self._stderr = None
86
+
87
+ async def search(
88
+ self,
89
+ query: str,
90
+ *,
91
+ top_k: int | None = None,
92
+ include_paths: Sequence[str] | None = None,
93
+ exclude_paths: Sequence[str] | None = None,
94
+ scope: str | None = None,
95
+ max_chunks_per_document: int | None = None,
96
+ context_radius: int | None = None,
97
+ ) -> list[Passage]:
98
+ """Cited passages that answer ``query``; an empty list means the base holds no evidence.
99
+
100
+ ``include_paths`` and ``exclude_paths`` take folders, files, or globs such as ``**/*.pdf``.
101
+ ``top_k`` is capped by the base's ``mcpMaxTopK`` setting (10 by default).
102
+ """
103
+ arguments = _search_arguments(
104
+ top_k, include_paths, exclude_paths, scope, max_chunks_per_document, context_radius
105
+ )
106
+ data = await self._call_json("ragmir_search", {"query": query, **arguments})
107
+ return [Passage.from_json(item) for item in _list(data)]
108
+
109
+ async def search_many(
110
+ self,
111
+ queries: Sequence[str],
112
+ *,
113
+ top_k: int | None = None,
114
+ include_paths: Sequence[str] | None = None,
115
+ exclude_paths: Sequence[str] | None = None,
116
+ scope: str | None = None,
117
+ max_chunks_per_document: int | None = None,
118
+ context_radius: int | None = None,
119
+ ) -> dict[str, list[Passage]]:
120
+ """Up to five related questions in one call, each with its own cited passages."""
121
+ arguments = _search_arguments(
122
+ top_k, include_paths, exclude_paths, scope, max_chunks_per_document, context_radius
123
+ )
124
+ data = await self._call_json("ragmir_search", {"queries": list(queries), **arguments})
125
+ if len(queries) == 1:
126
+ return {queries[0]: [Passage.from_json(item) for item in _list(data)]}
127
+ return {
128
+ str(group.get("query", "")): [
129
+ Passage.from_json(item) for item in _list(group.get("results"))
130
+ ]
131
+ for group in _list(data)
132
+ }
133
+
134
+ async def expand(
135
+ self,
136
+ citation: str,
137
+ *,
138
+ context_radius: int | None = None,
139
+ expected_evidence_id: str | None = None,
140
+ ) -> ExpandedCitation:
141
+ """A citation with up to three neighbouring passages on each side.
142
+
143
+ Expand ``next_citation`` with the same radius to read the rest of the document. Pass the
144
+ ``evidence_id`` of a search result to fail if the indexed passage changed since.
145
+ """
146
+ arguments: dict[str, Any] = {"citation": citation, "maxBytes": _MAX_OUTPUT_BYTES}
147
+ if context_radius is not None:
148
+ arguments["contextRadius"] = context_radius
149
+ if expected_evidence_id is not None:
150
+ arguments["expectedEvidenceId"] = expected_evidence_id
151
+ data = await self._call_json("ragmir_expand", arguments)
152
+ return ExpandedCitation.from_json(_mapping(data))
153
+
154
+ async def sources(
155
+ self,
156
+ *,
157
+ paths: Sequence[str] | None = None,
158
+ offset: int | None = None,
159
+ limit: int | None = None,
160
+ ) -> SourceCatalog:
161
+ """One page of the indexed documents, optionally under folders or matching globs."""
162
+ arguments: dict[str, Any] = {"maxBytes": _MAX_OUTPUT_BYTES}
163
+ if paths is not None:
164
+ arguments["paths"] = list(paths)
165
+ if offset is not None:
166
+ arguments["offset"] = offset
167
+ if limit is not None:
168
+ arguments["limit"] = limit
169
+ data = await self._call_json("ragmir_sources", arguments)
170
+ return SourceCatalog.from_json(_mapping(data))
171
+
172
+ async def status(self) -> Status:
173
+ """Readiness, freshness, and identity of the knowledge base."""
174
+ return Status.from_json(_mapping(await self._call_json("ragmir_status", {})))
175
+
176
+ async def audit(self, *, detail: bool = False) -> dict[str, Any]:
177
+ """Missing, stale, unsupported, and duplicated sources, as the server reports them."""
178
+ data = await self._call_json(
179
+ "ragmir_audit", {"detail": detail, "maxBytes": _MAX_OUTPUT_BYTES}
180
+ )
181
+ return dict(_mapping(data))
182
+
183
+ async def ingest(self, *, wait: bool = True) -> IngestReport:
184
+ """Refresh the index incrementally: only new and changed files are read again.
185
+
186
+ With ``wait=False`` the run continues in the background and the report says ``running``.
187
+ Ragmir refuses when the base disables MCP indexing or ``rgr security-audit`` warns; run
188
+ ``run_cli(project, "ingest")`` from an operator script in that case.
189
+ """
190
+ while True:
191
+ data = _mapping(
192
+ await self._call_json(
193
+ "ragmir_ingest",
194
+ {"waitSeconds": _INGEST_WAIT_SECONDS if wait else 0},
195
+ timeout=_INGEST_WAIT_SECONDS + self._timeout,
196
+ )
197
+ )
198
+ report = IngestReport.from_json(data)
199
+ if report.status != "running" or not wait:
200
+ return report
201
+
202
+ async def _call_json(
203
+ self, tool: str, arguments: dict[str, Any], *, timeout: float | None = None
204
+ ) -> Any:
205
+ client = await self._session()
206
+ try:
207
+ result = await client.call_tool(
208
+ tool, arguments, read_timeout_seconds=timeout or self._timeout
209
+ )
210
+ except MCPError as error:
211
+ raise RagmirServerError(f"{tool} failed: {error}") from error
212
+ except Exception as error:
213
+ # Tool failures come back as results; an exception means the connection broke.
214
+ raise RagmirServerError(
215
+ f"{tool} lost the Ragmir server: {error}.{self._stderr_tail()}"
216
+ ) from error
217
+ texts = [item.text for item in result.content if isinstance(item, TextContent)]
218
+ if result.is_error:
219
+ raise RagmirToolError("\n".join(texts) or f"{tool} failed.")
220
+ if not texts:
221
+ raise RagmirServerError(f"{tool} returned no content.")
222
+ for notice in texts[1:]:
223
+ logger.warning("%s: %s", tool, notice)
224
+ try:
225
+ return json.loads(texts[0])
226
+ except json.JSONDecodeError as error:
227
+ raise RagmirServerError(f"{tool} returned invalid JSON: {texts[0][:200]}") from error
228
+
229
+ async def _session(self) -> Client:
230
+ if self._lock is None:
231
+ self._lock = asyncio.Lock()
232
+ async with self._lock:
233
+ if self._closed:
234
+ raise RagmirError("This Ragmir client is closed.")
235
+ if self._client is not None and self._owner is not None and not self._owner.done():
236
+ return self._client
237
+ # The server stopped since the last call: start a fresh one.
238
+ await self._shutdown()
239
+ loop = asyncio.get_running_loop()
240
+ ready: asyncio.Future[Client] = loop.create_future()
241
+ self._stop = asyncio.Event()
242
+ self._owner = asyncio.create_task(
243
+ self._own_session(ready, self._stop), name="ragmir-mcp-session"
244
+ )
245
+ try:
246
+ self._client = await asyncio.wait_for(asyncio.shield(ready), self._timeout)
247
+ except Exception as error:
248
+ await self._shutdown()
249
+ if isinstance(error, RagmirError):
250
+ raise
251
+ raise RagmirServerError(
252
+ f"The Ragmir MCP server did not start for {self.project_root}: {error}."
253
+ f"{self._stderr_tail()}"
254
+ ) from error
255
+ return self._client
256
+
257
+ async def _own_session(self, ready: asyncio.Future[Client], stop: asyncio.Event) -> None:
258
+ """Enter and leave the MCP connection in this one task, as anyio requires."""
259
+ try:
260
+ launcher = resolve_launcher(self.project_root, self._command)
261
+ argv = launcher.command("serve-mcp", interactive=True)
262
+ env = {**os.environ, **self._env}
263
+ if launcher.kind != "docker":
264
+ env["RAGMIR_PROJECT_ROOT"] = str(self.project_root)
265
+ if self._stderr is None:
266
+ # Kept for the client's lifetime to report the server's output; aclose() closes it.
267
+ self._stderr = tempfile.TemporaryFile("w+", encoding="utf-8") # noqa: SIM115
268
+ params = StdioServerParameters(
269
+ command=argv[0], args=argv[1:], env=env, cwd=str(self.project_root)
270
+ )
271
+ async with Client(
272
+ stdio_client(params, errlog=self._stderr),
273
+ mode="legacy",
274
+ read_timeout_seconds=self._timeout,
275
+ client_info=Implementation(name="ragmir-python", version=__version__),
276
+ ) as client:
277
+ ready.set_result(client)
278
+ await stop.wait()
279
+ except BaseException as error:
280
+ if not ready.done():
281
+ ready.set_exception(error)
282
+ elif not isinstance(error, asyncio.CancelledError):
283
+ logger.warning("The Ragmir MCP server stopped: %s", error)
284
+ if isinstance(error, asyncio.CancelledError | KeyboardInterrupt | SystemExit):
285
+ raise
286
+
287
+ async def _shutdown(self) -> None:
288
+ owner, stop = self._owner, self._stop
289
+ self._client = None
290
+ self._owner = None
291
+ self._stop = None
292
+ if owner is None:
293
+ return
294
+ if stop is not None:
295
+ stop.set()
296
+ try:
297
+ await asyncio.wait_for(owner, timeout=10)
298
+ except (TimeoutError, Exception):
299
+ owner.cancel()
300
+
301
+ def _stderr_tail(self) -> str:
302
+ if self._stderr is None:
303
+ return ""
304
+ try:
305
+ self._stderr.flush()
306
+ self._stderr.seek(0)
307
+ tail = self._stderr.read()[-_STDERR_TAIL_CHARACTERS:].strip()
308
+ except (OSError, ValueError):
309
+ return ""
310
+ return f"\nServer output:\n{tail}" if tail else ""
311
+
312
+
313
+ class Ragmir:
314
+ """Client for one Ragmir knowledge base, from synchronous or asynchronous code.
315
+
316
+ One MCP server runs per client on a private event loop thread, so the client works from any
317
+ thread, a notebook, or a web framework. Each method has an ``a``-prefixed async twin for
318
+ asyncio code. Close the client with ``close()``, or use ``with``.
319
+
320
+ >>> with Ragmir("path/to/project") as rag:
321
+ ... for passage in rag.search("release approval"):
322
+ ... print(passage.citation, passage.text)
323
+ """
324
+
325
+ def __init__(
326
+ self,
327
+ project_root: str | os.PathLike[str] = ".",
328
+ *,
329
+ command: Sequence[str] | str | None = None,
330
+ env: Mapping[str, str] | None = None,
331
+ timeout: float = DEFAULT_TIMEOUT,
332
+ ) -> None:
333
+ self._async = AsyncRagmir(project_root, command=command, env=env, timeout=timeout)
334
+ self._loop: asyncio.AbstractEventLoop | None = None
335
+ self._thread: threading.Thread | None = None
336
+ self._lock = threading.Lock()
337
+ self._closed = False
338
+
339
+ @property
340
+ def project_root(self) -> Path:
341
+ return self._async.project_root
342
+
343
+ @property
344
+ def closed(self) -> bool:
345
+ return self._closed
346
+
347
+ def __enter__(self) -> Ragmir:
348
+ return self
349
+
350
+ def __exit__(self, *_exc: object) -> None:
351
+ self.close()
352
+
353
+ def connect(self) -> None:
354
+ """Start the MCP server now instead of on the first call."""
355
+ self._run(self._async.connect())
356
+
357
+ def close(self) -> None:
358
+ """Stop the MCP server and its thread. The client cannot be used afterwards."""
359
+ with self._lock:
360
+ if self._closed:
361
+ return
362
+ self._closed = True
363
+ loop, thread = self._loop, self._thread
364
+ if loop is None or thread is None:
365
+ return
366
+ try:
367
+ asyncio.run_coroutine_threadsafe(self._async.aclose(), loop).result(timeout=30)
368
+ except (concurrent.futures.TimeoutError, RagmirError, RuntimeError):
369
+ logger.warning("The Ragmir MCP server did not stop cleanly.")
370
+ finally:
371
+ loop.call_soon_threadsafe(loop.stop)
372
+ thread.join(timeout=10)
373
+
374
+ def search(
375
+ self,
376
+ query: str,
377
+ *,
378
+ top_k: int | None = None,
379
+ include_paths: Sequence[str] | None = None,
380
+ exclude_paths: Sequence[str] | None = None,
381
+ scope: str | None = None,
382
+ max_chunks_per_document: int | None = None,
383
+ context_radius: int | None = None,
384
+ ) -> list[Passage]:
385
+ """Cited passages that answer ``query``; an empty list means the base holds no evidence."""
386
+ return self._run(
387
+ self._async.search(
388
+ query,
389
+ top_k=top_k,
390
+ include_paths=include_paths,
391
+ exclude_paths=exclude_paths,
392
+ scope=scope,
393
+ max_chunks_per_document=max_chunks_per_document,
394
+ context_radius=context_radius,
395
+ )
396
+ )
397
+
398
+ async def asearch(
399
+ self,
400
+ query: str,
401
+ *,
402
+ top_k: int | None = None,
403
+ include_paths: Sequence[str] | None = None,
404
+ exclude_paths: Sequence[str] | None = None,
405
+ scope: str | None = None,
406
+ max_chunks_per_document: int | None = None,
407
+ context_radius: int | None = None,
408
+ ) -> list[Passage]:
409
+ """Async twin of ``search``."""
410
+ return await self._arun(
411
+ self._async.search(
412
+ query,
413
+ top_k=top_k,
414
+ include_paths=include_paths,
415
+ exclude_paths=exclude_paths,
416
+ scope=scope,
417
+ max_chunks_per_document=max_chunks_per_document,
418
+ context_radius=context_radius,
419
+ )
420
+ )
421
+
422
+ def search_many(self, queries: Sequence[str], **options: Any) -> dict[str, list[Passage]]:
423
+ """Up to five related questions in one call, each with its own cited passages."""
424
+ return self._run(self._async.search_many(queries, **options))
425
+
426
+ async def asearch_many(
427
+ self, queries: Sequence[str], **options: Any
428
+ ) -> dict[str, list[Passage]]:
429
+ """Async twin of ``search_many``."""
430
+ return await self._arun(self._async.search_many(queries, **options))
431
+
432
+ def expand(
433
+ self,
434
+ citation: str,
435
+ *,
436
+ context_radius: int | None = None,
437
+ expected_evidence_id: str | None = None,
438
+ ) -> ExpandedCitation:
439
+ """A citation with its neighbours; expand ``next_citation`` to read on."""
440
+ return self._run(
441
+ self._async.expand(
442
+ citation, context_radius=context_radius, expected_evidence_id=expected_evidence_id
443
+ )
444
+ )
445
+
446
+ async def aexpand(
447
+ self,
448
+ citation: str,
449
+ *,
450
+ context_radius: int | None = None,
451
+ expected_evidence_id: str | None = None,
452
+ ) -> ExpandedCitation:
453
+ """Async twin of ``expand``."""
454
+ return await self._arun(
455
+ self._async.expand(
456
+ citation, context_radius=context_radius, expected_evidence_id=expected_evidence_id
457
+ )
458
+ )
459
+
460
+ def sources(
461
+ self,
462
+ *,
463
+ paths: Sequence[str] | None = None,
464
+ offset: int | None = None,
465
+ limit: int | None = None,
466
+ ) -> SourceCatalog:
467
+ """One page of the indexed documents, optionally under folders or matching globs."""
468
+ return self._run(self._async.sources(paths=paths, offset=offset, limit=limit))
469
+
470
+ async def asources(
471
+ self,
472
+ *,
473
+ paths: Sequence[str] | None = None,
474
+ offset: int | None = None,
475
+ limit: int | None = None,
476
+ ) -> SourceCatalog:
477
+ """Async twin of ``sources``."""
478
+ return await self._arun(self._async.sources(paths=paths, offset=offset, limit=limit))
479
+
480
+ def status(self) -> Status:
481
+ """Readiness, freshness, and identity of the knowledge base."""
482
+ return self._run(self._async.status())
483
+
484
+ async def astatus(self) -> Status:
485
+ """Async twin of ``status``."""
486
+ return await self._arun(self._async.status())
487
+
488
+ def audit(self, *, detail: bool = False) -> dict[str, Any]:
489
+ """Missing, stale, unsupported, and duplicated sources."""
490
+ return self._run(self._async.audit(detail=detail))
491
+
492
+ async def aaudit(self, *, detail: bool = False) -> dict[str, Any]:
493
+ """Async twin of ``audit``."""
494
+ return await self._arun(self._async.audit(detail=detail))
495
+
496
+ def ingest(self, *, wait: bool = True) -> IngestReport:
497
+ """Refresh the index incrementally; only new and changed files are read again."""
498
+ return self._run(self._async.ingest(wait=wait))
499
+
500
+ async def aingest(self, *, wait: bool = True) -> IngestReport:
501
+ """Async twin of ``ingest``."""
502
+ return await self._arun(self._async.ingest(wait=wait))
503
+
504
+ def _submit(self, coroutine: Coroutine[Any, Any, T]) -> concurrent.futures.Future[T]:
505
+ with self._lock:
506
+ if self._closed:
507
+ coroutine.close()
508
+ raise RagmirError("This Ragmir client is closed.")
509
+ if self._loop is None:
510
+ loop = asyncio.new_event_loop()
511
+ # A daemon thread never blocks interpreter exit; the server exits on stdin EOF.
512
+ thread = threading.Thread(
513
+ target=_run_loop, args=(loop,), name="ragmir-mcp", daemon=True
514
+ )
515
+ thread.start()
516
+ self._loop, self._thread = loop, thread
517
+ return asyncio.run_coroutine_threadsafe(coroutine, self._loop)
518
+
519
+ def _run(self, coroutine: Coroutine[Any, Any, T]) -> T:
520
+ return self._submit(coroutine).result()
521
+
522
+ async def _arun(self, coroutine: Coroutine[Any, Any, T]) -> T:
523
+ return await asyncio.wrap_future(self._submit(coroutine))
524
+
525
+
526
+ def _run_loop(loop: asyncio.AbstractEventLoop) -> None:
527
+ asyncio.set_event_loop(loop)
528
+ try:
529
+ loop.run_forever()
530
+ finally:
531
+ loop.close()
532
+
533
+
534
+ def _search_arguments(
535
+ top_k: int | None,
536
+ include_paths: Sequence[str] | None,
537
+ exclude_paths: Sequence[str] | None,
538
+ scope: str | None,
539
+ max_chunks_per_document: int | None,
540
+ context_radius: int | None,
541
+ ) -> dict[str, Any]:
542
+ # Full passages, every time: a library caller expects the same answer to the same question,
543
+ # so passages already returned in the session are not shortened to stubs.
544
+ arguments: dict[str, Any] = {"compact": False, "seen": "include", "maxBytes": _MAX_OUTPUT_BYTES}
545
+ if top_k is not None:
546
+ arguments["topK"] = top_k
547
+ if include_paths is not None:
548
+ arguments["includePaths"] = list(include_paths)
549
+ if exclude_paths is not None:
550
+ arguments["excludePaths"] = list(exclude_paths)
551
+ if scope is not None:
552
+ arguments["scope"] = scope
553
+ if max_chunks_per_document is not None:
554
+ arguments["maxChunksPerDocument"] = max_chunks_per_document
555
+ if context_radius is not None:
556
+ arguments["contextRadius"] = context_radius
557
+ return arguments
558
+
559
+
560
+ def _list(value: object) -> list[Mapping[str, Any]]:
561
+ return [item for item in value if isinstance(item, Mapping)] if isinstance(value, list) else []
562
+
563
+
564
+ def _mapping(value: object) -> Mapping[str, Any]:
565
+ if not isinstance(value, Mapping):
566
+ raise RagmirServerError("Ragmir returned an unexpected answer.")
567
+ return value
ragmir/_errors.py ADDED
@@ -0,0 +1,28 @@
1
+ """Errors raised by the Ragmir Python client."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class RagmirError(Exception):
7
+ """Base class of every error the Ragmir client raises."""
8
+
9
+
10
+ class RagmirNotFoundError(RagmirError):
11
+ """No way to run Ragmir was found: no local install, no Node.js with npx, and no Docker."""
12
+
13
+
14
+ class RagmirServerError(RagmirError):
15
+ """The Ragmir MCP server could not start, or stopped answering."""
16
+
17
+
18
+ class RagmirToolError(RagmirError):
19
+ """Ragmir refused or failed a request; the message says why and what to run instead."""
20
+
21
+
22
+ class RagmirCommandError(RagmirError):
23
+ """A Ragmir command such as ``init`` or ``sources add`` failed."""
24
+
25
+ def __init__(self, message: str, *, returncode: int, output: str) -> None:
26
+ super().__init__(message)
27
+ self.returncode = returncode
28
+ self.output = output