repolore-mcp 0.2.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 (47) hide show
  1. repolore/__init__.py +4 -0
  2. repolore/__main__.py +3 -0
  3. repolore/chunking.py +242 -0
  4. repolore/cli.py +691 -0
  5. repolore/clients.py +147 -0
  6. repolore/config.py +29 -0
  7. repolore/discovery.py +84 -0
  8. repolore/doctor.py +252 -0
  9. repolore/documents.py +271 -0
  10. repolore/embedding/__init__.py +36 -0
  11. repolore/embedding/engines.py +34 -0
  12. repolore/embedding/local.py +250 -0
  13. repolore/embedding/mlx_engine.py +129 -0
  14. repolore/embedding/model_files.py +143 -0
  15. repolore/embedding/voyage-4-nano.tensors.json +1037 -0
  16. repolore/evals/__init__.py +0 -0
  17. repolore/evals/harness.py +89 -0
  18. repolore/evals/metrics.py +30 -0
  19. repolore/evals/questions.py +64 -0
  20. repolore/evals/runs.py +34 -0
  21. repolore/hub.py +20 -0
  22. repolore/indexing.py +104 -0
  23. repolore/ingest.py +92 -0
  24. repolore/instructions.py +14 -0
  25. repolore/locks.py +18 -0
  26. repolore/mcp_server.py +280 -0
  27. repolore/paths.py +41 -0
  28. repolore/processes.py +53 -0
  29. repolore/progress.py +35 -0
  30. repolore/refresh.py +155 -0
  31. repolore/relay.py +207 -0
  32. repolore/reranking.py +57 -0
  33. repolore/retrieval.py +72 -0
  34. repolore/server_state.py +106 -0
  35. repolore/setup.py +62 -0
  36. repolore/shared_server.py +118 -0
  37. repolore/sources/__init__.py +0 -0
  38. repolore/sources/github.graphql +156 -0
  39. repolore/sources/github.py +409 -0
  40. repolore/sources/models.py +127 -0
  41. repolore/sources/plans.py +76 -0
  42. repolore/store.py +293 -0
  43. repolore_mcp-0.2.0.dist-info/METADATA +344 -0
  44. repolore_mcp-0.2.0.dist-info/RECORD +47 -0
  45. repolore_mcp-0.2.0.dist-info/WHEEL +4 -0
  46. repolore_mcp-0.2.0.dist-info/entry_points.txt +3 -0
  47. repolore_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
repolore/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from importlib.metadata import version
2
+
3
+ DIST = "repolore-mcp"
4
+ __version__ = version(DIST)
repolore/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from repolore.cli import app
2
+
3
+ app()
repolore/chunking.py ADDED
@@ -0,0 +1,242 @@
1
+ import re
2
+ from collections.abc import Callable, Iterator, Sequence
3
+ from dataclasses import dataclass
4
+
5
+ from markdown_it import MarkdownIt
6
+ from pydantic import BaseModel, ConfigDict
7
+
8
+ from repolore.documents import Document, DocumentKind
9
+
10
+ CHARS_PER_TOKEN = 4
11
+ CEILING = 8192
12
+ FENCE_OPENING = re.compile(r"^ {0,3}(`{3,}|~{3,})")
13
+ TABLE_SEPARATOR = re.compile(r"^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$")
14
+ FRONT_MATTER = re.compile(r"\A---\n(.*?)\n---[ \t]*(?:\n|\Z)", re.DOTALL)
15
+ MARKDOWN = MarkdownIt("commonmark").enable("table")
16
+ SHORT_SHA = 7
17
+
18
+
19
+ class Chunk(BaseModel):
20
+ model_config = ConfigDict(frozen=True)
21
+
22
+ id: str
23
+ document: Document
24
+ section: str | None
25
+ header: str
26
+ text: str
27
+ part: int = 0
28
+
29
+ @property
30
+ def embedded_text(self) -> str:
31
+ return f"{self.header}\n\n{self.text}"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Section:
36
+ heading: str | None
37
+ text: str
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class _Block:
42
+ text: str
43
+ heading: str | None
44
+ level: int = 0
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class _Section:
49
+ path: tuple[str, ...]
50
+ blocks: list[str]
51
+
52
+ @property
53
+ def heading(self) -> str | None:
54
+ return self.path[-1] if self.path else None
55
+
56
+
57
+ TokenCounter = Callable[[Sequence[str]], list[int]]
58
+
59
+
60
+ def estimate_tokens(text: str) -> int:
61
+ """About 4 characters per token for English prose; not an upper bound, so it only sizes chunks for tests."""
62
+ return -(-len(text) // CHARS_PER_TOKEN)
63
+
64
+
65
+ def estimate_counts(texts: Sequence[str]) -> list[int]:
66
+ return [estimate_tokens(text) for text in texts]
67
+
68
+
69
+ def chunk_limit(max_tokens: int | None, window: int) -> int:
70
+ return min(max_tokens or CEILING, window)
71
+
72
+
73
+ def chunk_document(
74
+ document: Document, max_tokens: int | None = None, count: TokenCounter = estimate_counts
75
+ ) -> list[Chunk]:
76
+ """One chunk per section; with max_tokens, no chunk's embedded text (header included) is longer than that,
77
+ unless the header alone takes over half of it."""
78
+ sections = _sections(document.body)
79
+ headers = [_header(document, section.path) for section in sections]
80
+ # Most sections fit whole, and one count per document is far cheaper than counting their blocks one by one.
81
+ texts = [f"{header}\n\n" + "\n\n".join(section.blocks) for header, section in zip(headers, sections, strict=True)]
82
+ whole = [0] * len(texts) if max_tokens is None else count(texts)
83
+ pieces: list[tuple[str | None, str, int, str]] = []
84
+ for section, header, tokens in zip(sections, headers, whole, strict=True):
85
+ budget = None if max_tokens is None or tokens <= max_tokens else _text_budget(max_tokens, header, count)
86
+ packed = _pack(section.heading, section.blocks, budget, count)
87
+ pieces += [(section.heading, header, part, text) for part, text in enumerate(packed)]
88
+ return [
89
+ Chunk(id=f"{document.url}::{ordinal}", document=document, section=heading, header=header, text=text, part=part)
90
+ for ordinal, (heading, header, part, text) in enumerate(
91
+ pieces or [(None, _header(document), 0, document.title)]
92
+ )
93
+ ]
94
+
95
+
96
+ def split_markdown(text: str, max_tokens: int | None = None, count: TokenCounter = estimate_counts) -> list[Section]:
97
+ """One section per heading; with max_tokens, a longer section is split between, and if need be inside, blocks."""
98
+ return [
99
+ Section(section.heading, piece)
100
+ for section in _sections(text)
101
+ for piece in _pack(section.heading, section.blocks, max_tokens, count)
102
+ ]
103
+
104
+
105
+ def count_split_sections(chunks: Sequence[Chunk]) -> int:
106
+ return sum(1 for chunk in chunks if chunk.part == 1)
107
+
108
+
109
+ def _sections(text: str) -> list[_Section]:
110
+ sections: list[_Section] = []
111
+ path: tuple[str, ...] = ()
112
+ levels: tuple[int, ...] = ()
113
+ blocks: list[str] = []
114
+ for block in _blocks(_fence_front_matter(text.replace("\r\n", "\n"))):
115
+ if block.heading is None:
116
+ blocks.append(block.text)
117
+ continue
118
+ sections.append(_Section(path, blocks))
119
+ kept = sum(1 for level in levels if level < block.level)
120
+ path, levels, blocks = (*path[:kept], block.heading), (*levels[:kept], block.level), [block.text]
121
+ return [*sections, _Section(path, blocks)]
122
+
123
+
124
+ def _fence_front_matter(text: str) -> str:
125
+ return FRONT_MATTER.sub(lambda match: f"```yaml\n{match.group(1)}\n```\n", text, count=1)
126
+
127
+
128
+ def _blocks(text: str) -> Iterator[_Block]:
129
+ lines = text.split("\n")
130
+ tokens = MARKDOWN.parse(text)
131
+ for index, token in enumerate(tokens):
132
+ if token.level != 0 or token.map is None or token.nesting < 0:
133
+ continue
134
+ start, end = token.map
135
+ text = "\n".join(lines[start:end]).strip()
136
+ if token.type == "heading_open":
137
+ yield _Block(text, heading=tokens[index + 1].content.strip(), level=int(token.tag[1:]))
138
+ else:
139
+ yield _Block(text, heading=None)
140
+
141
+
142
+ def _text_budget(max_tokens: int, header: str, count: TokenCounter) -> int:
143
+ # A header over half the limit lets its chunks pass the limit rather than leave almost no room for text.
144
+ [header_tokens] = count([f"{header}\n\n"])
145
+ return max(max_tokens - header_tokens, max_tokens // 2, 1)
146
+
147
+
148
+ def _pack(heading: str | None, blocks: list[str], budget: int | None, count: TokenCounter) -> list[str]:
149
+ content = blocks[1:] if heading is not None else blocks
150
+ if not any(content):
151
+ return []
152
+ if budget is None:
153
+ return ["\n\n".join(blocks)]
154
+ limits = [budget] * len(blocks)
155
+ if heading is not None and len(blocks) > 1:
156
+ # The first content block shares a chunk with the heading, so it must fit beside it or split to.
157
+ [heading_tokens] = count([f"{blocks[0]}\n\n"])
158
+ limits[1] = max(budget - heading_tokens, budget // 2, 1)
159
+ units = [
160
+ unit
161
+ for block, tokens, limit in zip(blocks, count(blocks), limits, strict=True)
162
+ for unit in ([block] if tokens <= limit else _split_block(block, limit, count))
163
+ ]
164
+ return _joined(units, budget, count, "\n\n")
165
+
166
+
167
+ def _split_block(block: str, budget: int, count: TokenCounter) -> list[str]:
168
+ head, body, tail = _frame(block.split("\n"))
169
+ [frame_tokens] = count(["\n".join([*head, "", *tail])])
170
+ if frame_tokens > budget // 2 or not body:
171
+ head, body, tail, frame_tokens = [], block.split("\n"), [], 0
172
+ line_budget = budget - frame_tokens
173
+ lines = [
174
+ piece
175
+ for line, tokens in zip(body, count(body), strict=True)
176
+ for piece in ([line] if tokens <= line_budget else _cut(line, line_budget, count))
177
+ ]
178
+ return _joined(lines, budget, count, "\n", lambda text: "\n".join([*head, text, *tail]))
179
+
180
+
181
+ def _frame(lines: list[str]) -> tuple[list[str], list[str], list[str]]:
182
+ """The lines every piece of a split block repeats: a fence's opening and closing, or a table's header."""
183
+ if opening := FENCE_OPENING.match(lines[0]):
184
+ marker = opening.group(1)
185
+ if len(lines) > 1 and lines[-1].strip().startswith(marker):
186
+ return lines[:1], lines[1:-1], lines[-1:]
187
+ return lines[:1], lines[1:], [marker]
188
+ if len(lines) > 2 and TABLE_SEPARATOR.match(lines[1]):
189
+ return lines[:2], lines[2:], []
190
+ return [], lines, []
191
+
192
+
193
+ def _cut(line: str, budget: int, count: TokenCounter) -> list[str]:
194
+ [tokens] = count([line])
195
+ if tokens <= budget or len(line) == 1:
196
+ return [line]
197
+ step = max(1, len(line) * budget // tokens)
198
+ return [piece for start in range(0, len(line), step) for piece in _cut(line[start : start + step], budget, count)]
199
+
200
+
201
+ def _joined(
202
+ units: list[str], budget: int, count: TokenCounter, separator: str, wrap: Callable[[str], str] = str
203
+ ) -> list[str]:
204
+ """Units joined greedily into pieces of at most budget tokens, each unit fitting on its own."""
205
+ separator_tokens, frame_tokens = count([separator, wrap("")])
206
+ groups: list[list[str]] = []
207
+ size = budget + 1
208
+ for unit, tokens in zip(units, count(units), strict=True):
209
+ if size + separator_tokens + tokens > budget:
210
+ groups.append([])
211
+ size = frame_tokens - separator_tokens
212
+ groups[-1].append(unit)
213
+ size += separator_tokens + tokens
214
+ texts = [wrap(separator.join(group)) for group in groups]
215
+ # Counts summed per unit can drift from the joined text's at the seams, so each piece is counted once as a whole.
216
+ pieces: list[str] = []
217
+ for group, text, tokens in zip(groups, texts, count(texts), strict=True):
218
+ if tokens <= budget or len(group) == 1:
219
+ pieces.append(text)
220
+ else:
221
+ half = len(group) // 2
222
+ pieces += _joined(group[:half], budget, count, separator, wrap)
223
+ pieces += _joined(group[half:], budget, count, separator, wrap)
224
+ return pieces
225
+
226
+
227
+ def _header(document: Document, path: tuple[str, ...] = ()) -> str:
228
+ parts = [document.repo, _reference(document), document.title, " > ".join(heading for heading in path if heading)]
229
+ return " · ".join(part for part in parts if part)
230
+
231
+
232
+ def _reference(document: Document) -> str | None:
233
+ last_segment = document.url.rsplit("/", 1)[-1]
234
+ match document.kind:
235
+ case DocumentKind.PR:
236
+ return f"PR #{last_segment}"
237
+ case DocumentKind.ISSUE:
238
+ return f"issue #{last_segment}"
239
+ case DocumentKind.COMMIT:
240
+ return f"commit {last_segment[:SHORT_SHA]}"
241
+ case _:
242
+ return None