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.
- repolore/__init__.py +4 -0
- repolore/__main__.py +3 -0
- repolore/chunking.py +242 -0
- repolore/cli.py +691 -0
- repolore/clients.py +147 -0
- repolore/config.py +29 -0
- repolore/discovery.py +84 -0
- repolore/doctor.py +252 -0
- repolore/documents.py +271 -0
- repolore/embedding/__init__.py +36 -0
- repolore/embedding/engines.py +34 -0
- repolore/embedding/local.py +250 -0
- repolore/embedding/mlx_engine.py +129 -0
- repolore/embedding/model_files.py +143 -0
- repolore/embedding/voyage-4-nano.tensors.json +1037 -0
- repolore/evals/__init__.py +0 -0
- repolore/evals/harness.py +89 -0
- repolore/evals/metrics.py +30 -0
- repolore/evals/questions.py +64 -0
- repolore/evals/runs.py +34 -0
- repolore/hub.py +20 -0
- repolore/indexing.py +104 -0
- repolore/ingest.py +92 -0
- repolore/instructions.py +14 -0
- repolore/locks.py +18 -0
- repolore/mcp_server.py +280 -0
- repolore/paths.py +41 -0
- repolore/processes.py +53 -0
- repolore/progress.py +35 -0
- repolore/refresh.py +155 -0
- repolore/relay.py +207 -0
- repolore/reranking.py +57 -0
- repolore/retrieval.py +72 -0
- repolore/server_state.py +106 -0
- repolore/setup.py +62 -0
- repolore/shared_server.py +118 -0
- repolore/sources/__init__.py +0 -0
- repolore/sources/github.graphql +156 -0
- repolore/sources/github.py +409 -0
- repolore/sources/models.py +127 -0
- repolore/sources/plans.py +76 -0
- repolore/store.py +293 -0
- repolore_mcp-0.2.0.dist-info/METADATA +344 -0
- repolore_mcp-0.2.0.dist-info/RECORD +47 -0
- repolore_mcp-0.2.0.dist-info/WHEEL +4 -0
- repolore_mcp-0.2.0.dist-info/entry_points.txt +3 -0
- repolore_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
repolore/__init__.py
ADDED
repolore/__main__.py
ADDED
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
|