rag-your-code 0.4.1__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.
- rag_your_code-0.4.1.dist-info/METADATA +237 -0
- rag_your_code-0.4.1.dist-info/RECORD +19 -0
- rag_your_code-0.4.1.dist-info/WHEEL +5 -0
- rag_your_code-0.4.1.dist-info/entry_points.txt +2 -0
- rag_your_code-0.4.1.dist-info/licenses/LICENSE +21 -0
- rag_your_code-0.4.1.dist-info/top_level.txt +1 -0
- ragyourcode/__init__.py +6 -0
- ragyourcode/agentic.py +55 -0
- ragyourcode/annotate.py +35 -0
- ragyourcode/cli.py +575 -0
- ragyourcode/config.py +572 -0
- ragyourcode/descriptions.py +248 -0
- ragyourcode/embeddings.py +60 -0
- ragyourcode/graph.py +198 -0
- ragyourcode/indexer.py +411 -0
- ragyourcode/models.py +86 -0
- ragyourcode/parser.py +485 -0
- ragyourcode/py.typed +0 -0
- ragyourcode/search.py +131 -0
ragyourcode/cli.py
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
"""Command line and JSON-lines agent entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import config as config_module
|
|
12
|
+
from . import descriptions as descriptions_module
|
|
13
|
+
from .annotate import comment_for
|
|
14
|
+
from .agentic import research
|
|
15
|
+
from .config import BY_PATH, SETTINGS, Config, ConfigError
|
|
16
|
+
from .descriptions import DescriptionStore, guidance, index_descriptions_fingerprint
|
|
17
|
+
from .embeddings import embed, embedding_metadata
|
|
18
|
+
from .graph import build_graph, graph_from_dict, graph_search
|
|
19
|
+
from .indexer import StaleMonitor, build_units, fingerprint, index_config_fingerprint, read_index, snapshot_repository, write_index
|
|
20
|
+
from .search import build_search_index, context, search
|
|
21
|
+
|
|
22
|
+
# Derived from the settings table so the default is written down once.
|
|
23
|
+
# `tests/test_agent_protocol.py` imports these to assert the bound it enforces.
|
|
24
|
+
MAX_OPEN_BYTES = BY_PATH["agent.max_open_bytes"].default
|
|
25
|
+
MAX_OPEN_CHARS = BY_PATH["agent.max_open_chars"].default
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _default_index(root: Path) -> Path:
|
|
29
|
+
return root / ".rag-your-code" / "index.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _refresh_index(root: Path, output: Path, full: bool = False, compact: bool | None = None, cfg: Config | None = None) -> dict:
|
|
33
|
+
cfg = cfg if cfg is not None else config_module.load(root)
|
|
34
|
+
previous_payload: dict = {}
|
|
35
|
+
previous_units = []
|
|
36
|
+
if output.exists() and not full:
|
|
37
|
+
try:
|
|
38
|
+
previous_payload, previous_units = read_index(output)
|
|
39
|
+
except (OSError, TypeError, ValueError, json.JSONDecodeError):
|
|
40
|
+
previous_payload, previous_units = {}, []
|
|
41
|
+
if previous_payload.get("embedding") != embedding_metadata(cfg["embedding.dimensions"]):
|
|
42
|
+
for unit in previous_units:
|
|
43
|
+
unit.vector = []
|
|
44
|
+
if compact is None:
|
|
45
|
+
compact = bool(previous_payload.get("vector_store"))
|
|
46
|
+
# Settings that decide what an index contains make the previous one an
|
|
47
|
+
# index of something else, not a stale index of this one. Deciding it here
|
|
48
|
+
# rather than only inside build_units is what lets the reported
|
|
49
|
+
# `incremental` describe what the run actually did: it claimed reuse on
|
|
50
|
+
# exactly the runs where the configuration change had forbidden it.
|
|
51
|
+
previous_config = index_config_fingerprint(previous_payload) if previous_payload else None
|
|
52
|
+
config_changed = previous_config is not None and previous_config != cfg.build_fingerprint
|
|
53
|
+
if config_changed:
|
|
54
|
+
previous_payload, previous_units = {}, []
|
|
55
|
+
diagnostics: list[dict] = []
|
|
56
|
+
# One snapshot for both halves: parsing from one walk and publishing hashes
|
|
57
|
+
# from another let a save landing between them poison incremental reuse.
|
|
58
|
+
snapshot = snapshot_repository(root, cfg)
|
|
59
|
+
store = descriptions_module.load(root)
|
|
60
|
+
units = build_units(
|
|
61
|
+
root,
|
|
62
|
+
previous_units=previous_units,
|
|
63
|
+
previous_files=previous_payload.get("files"),
|
|
64
|
+
diagnostics=diagnostics,
|
|
65
|
+
snapshot=snapshot,
|
|
66
|
+
cfg=cfg,
|
|
67
|
+
previous_config=None if config_changed else previous_config,
|
|
68
|
+
descriptions=store,
|
|
69
|
+
)
|
|
70
|
+
graph = build_graph(units)
|
|
71
|
+
write_index(output, root, units, graph.to_dict(), compact=compact, diagnostics=diagnostics, snapshot=snapshot, cfg=cfg, descriptions_fingerprint=store.fingerprint)
|
|
72
|
+
groups = store.classify(units)
|
|
73
|
+
return {
|
|
74
|
+
"indexed_units": len(units),
|
|
75
|
+
"graph_edges": len(graph.edges),
|
|
76
|
+
"warnings": len(diagnostics),
|
|
77
|
+
"incremental": bool(previous_units) and not full,
|
|
78
|
+
"rebuilt_for_config": config_changed,
|
|
79
|
+
"compact": bool(compact),
|
|
80
|
+
"described": len(groups["described"]),
|
|
81
|
+
"pending_descriptions": len(groups["missing"]) + len(groups["superseded"]),
|
|
82
|
+
"index": str(output),
|
|
83
|
+
"root": str(root),
|
|
84
|
+
"config": str(cfg.source) if cfg.source else None,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _cmd_index(args: argparse.Namespace) -> int:
|
|
89
|
+
root = Path(args.root).resolve()
|
|
90
|
+
cfg = config_module.load(root)
|
|
91
|
+
output = Path(args.output) if args.output else _default_index(root)
|
|
92
|
+
print(json.dumps(_refresh_index(root, output, args.full, args.compact, cfg), ensure_ascii=False))
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _load(args: argparse.Namespace):
|
|
97
|
+
root = Path(args.root).resolve()
|
|
98
|
+
cfg = config_module.load(root)
|
|
99
|
+
store = descriptions_module.load(root)
|
|
100
|
+
path = Path(args.index) if args.index else _default_index(root)
|
|
101
|
+
payload, units = read_index(path)
|
|
102
|
+
try:
|
|
103
|
+
# Both authored inputs are invisible to a file fingerprint. A changed
|
|
104
|
+
# configuration means the index describes a different corpus; changed
|
|
105
|
+
# descriptions mean it serves text nobody wrote any more. Neither moves
|
|
106
|
+
# a tracked file, so each has to report itself.
|
|
107
|
+
stale = payload.get("fingerprint") != fingerprint(root, cfg)
|
|
108
|
+
stale = stale or index_config_fingerprint(payload) != cfg.build_fingerprint
|
|
109
|
+
payload["stale"] = stale or index_descriptions_fingerprint(payload) != store.fingerprint
|
|
110
|
+
except OSError:
|
|
111
|
+
payload["stale"] = True
|
|
112
|
+
return payload, units, graph_from_dict(units, payload.get("graph")), cfg, store
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _cmd_search(args: argparse.Namespace) -> int:
|
|
116
|
+
payload, units, graph, cfg, _ = _load(args)
|
|
117
|
+
limit = args.limit if args.limit is not None else cfg["search.limit"]
|
|
118
|
+
max_chars = args.max_chars if args.max_chars is not None else cfg["search.max_chars"]
|
|
119
|
+
weight = cfg["search.vector_weight"]
|
|
120
|
+
search_index = build_search_index(units)
|
|
121
|
+
results = (
|
|
122
|
+
graph_search(units, args.query, limit, args.hops, graph, search_index, vector_weight=weight)
|
|
123
|
+
if args.graph
|
|
124
|
+
else search(units, args.query, limit, search_index=search_index, vector_weight=weight)
|
|
125
|
+
)
|
|
126
|
+
if args.json:
|
|
127
|
+
print(json.dumps({"query": args.query, "mode": "graph" if args.graph else "hybrid", "stale": payload.get("stale", True), "degraded": payload.get("degraded"), "results": [result.to_dict() for result in results], "context": context(results, max_chars)}, ensure_ascii=False))
|
|
128
|
+
else:
|
|
129
|
+
if payload.get("stale"):
|
|
130
|
+
print("Warning: index is stale; run `rag-your-code index` to refresh.", file=sys.stderr)
|
|
131
|
+
print(context(results, max_chars) or "No matching code units.")
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _cmd_annotate(args: argparse.Namespace) -> int:
|
|
136
|
+
payload, units, _, _, _ = _load(args)
|
|
137
|
+
if payload.get("stale"):
|
|
138
|
+
print("Index is stale; run `rag-your-code index` before annotating.", file=sys.stderr)
|
|
139
|
+
return 2
|
|
140
|
+
output = Path(args.output) if args.output else Path(args.root) / ".rag-your-code" / "annotations.md"
|
|
141
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
lines = ["# RAG Your Code annotations", "", "Generated sidecar comments; source files are unchanged.", ""]
|
|
143
|
+
for unit in units:
|
|
144
|
+
lines.extend([f"## [{unit.serial:05d}] {unit.id}", "", f"- Location: `{unit.path}:{unit.start_line}-{unit.end_line}`", f"- Kind: `{unit.kind}`", f"- Comment: {comment_for(unit.description, unit.serial, unit.id)}", "", unit.description, ""])
|
|
145
|
+
output.write_text("\n".join(lines), encoding="utf-8")
|
|
146
|
+
print(json.dumps({"annotations": len(units), "output": str(output)}, ensure_ascii=False))
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _cmd_config(args: argparse.Namespace) -> int:
|
|
151
|
+
root = Path(args.root).resolve()
|
|
152
|
+
path = config_module.config_path(root)
|
|
153
|
+
if args.action == "path":
|
|
154
|
+
print(json.dumps({"path": str(path), "exists": path.is_file()}, ensure_ascii=False))
|
|
155
|
+
return 0
|
|
156
|
+
if args.action == "init":
|
|
157
|
+
if path.is_file() and not args.force:
|
|
158
|
+
print(f"error: {path.name} already exists; pass --force to overwrite", file=sys.stderr)
|
|
159
|
+
return 2
|
|
160
|
+
path.write_text(config_module.render_template(), encoding="utf-8", newline="\n")
|
|
161
|
+
print(json.dumps({"created": str(path), "settings": len(SETTINGS)}, ensure_ascii=False))
|
|
162
|
+
return 0
|
|
163
|
+
cfg = config_module.load(root)
|
|
164
|
+
if args.action == "get":
|
|
165
|
+
if args.name not in BY_PATH:
|
|
166
|
+
print(f"error: unknown setting {args.name}", file=sys.stderr)
|
|
167
|
+
return 2
|
|
168
|
+
print(json.dumps({"name": args.name, "value": cfg[args.name]}, ensure_ascii=False, default=list))
|
|
169
|
+
return 0
|
|
170
|
+
if args.action == "set":
|
|
171
|
+
setting = BY_PATH.get(args.name)
|
|
172
|
+
if setting is None:
|
|
173
|
+
print(f"error: unknown setting {args.name}", file=sys.stderr)
|
|
174
|
+
return 2
|
|
175
|
+
value = config_module.parse_literal(setting, args.value)
|
|
176
|
+
config_module.update_file(path, args.name, value)
|
|
177
|
+
print(json.dumps({"name": args.name, "value": value, "path": str(path), "rebuild_required": setting.affects_build}, ensure_ascii=False, default=list))
|
|
178
|
+
return 0
|
|
179
|
+
listing = [
|
|
180
|
+
{
|
|
181
|
+
"name": setting.path,
|
|
182
|
+
"value": cfg[setting.path],
|
|
183
|
+
"default": setting.default,
|
|
184
|
+
"customised": cfg[setting.path] != setting.default,
|
|
185
|
+
"affects_build": setting.affects_build,
|
|
186
|
+
"help": setting.help,
|
|
187
|
+
}
|
|
188
|
+
for setting in SETTINGS
|
|
189
|
+
]
|
|
190
|
+
print(json.dumps({"source": str(cfg.source) if cfg.source else None, "build_fingerprint": cfg.build_fingerprint, "settings": listing}, ensure_ascii=False, indent=2, default=list))
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _describe_batch(units: list, store: DescriptionStore, cfg: Config, limit: int) -> dict:
|
|
195
|
+
"""The work packet handed to an agent: what to describe, and how.
|
|
196
|
+
|
|
197
|
+
The source is included because the brief forbids describing behaviour the
|
|
198
|
+
source does not show, and an agent cannot honour that without seeing it.
|
|
199
|
+
The generated description goes along so the agent can tell what retrieval
|
|
200
|
+
already has and add what it lacks rather than paraphrasing it.
|
|
201
|
+
"""
|
|
202
|
+
groups = store.classify(units)
|
|
203
|
+
pending = store.pending(units, limit)
|
|
204
|
+
languages = cfg["describe.languages"]
|
|
205
|
+
return {
|
|
206
|
+
"languages": list(languages),
|
|
207
|
+
"max_chars": cfg["describe.max_chars"],
|
|
208
|
+
"guidance": guidance(languages, cfg["describe.max_chars"]),
|
|
209
|
+
"described": len(groups["described"]),
|
|
210
|
+
"superseded": len(groups["superseded"]),
|
|
211
|
+
"missing": len(groups["missing"]),
|
|
212
|
+
"remaining": len(groups["missing"]) + len(groups["superseded"]),
|
|
213
|
+
"units": [
|
|
214
|
+
{
|
|
215
|
+
"id": unit.id,
|
|
216
|
+
"path": unit.path,
|
|
217
|
+
"language": unit.language,
|
|
218
|
+
"kind": unit.kind,
|
|
219
|
+
"qualified_name": unit.qualified_name,
|
|
220
|
+
"signature": unit.signature,
|
|
221
|
+
"start_line": unit.start_line,
|
|
222
|
+
"end_line": unit.end_line,
|
|
223
|
+
"generated_description": unit.description,
|
|
224
|
+
"source": unit.source,
|
|
225
|
+
}
|
|
226
|
+
for unit in pending
|
|
227
|
+
],
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _store_descriptions(units: list, store: DescriptionStore, cfg: Config, items) -> dict:
|
|
232
|
+
"""Validate and persist a batch, reporting every rejection with a reason.
|
|
233
|
+
|
|
234
|
+
Nothing is truncated to fit. A description silently cut at the limit would
|
|
235
|
+
lose exactly the trailing synonyms that make it worth writing, and the
|
|
236
|
+
agent would have no way to learn that it had happened.
|
|
237
|
+
"""
|
|
238
|
+
if not isinstance(items, list):
|
|
239
|
+
raise ValueError("descriptions must be a list of {id, text} objects")
|
|
240
|
+
by_id = {unit.id: unit for unit in units}
|
|
241
|
+
max_chars = cfg["describe.max_chars"]
|
|
242
|
+
stored: list[str] = []
|
|
243
|
+
rejected: list[dict] = []
|
|
244
|
+
for item in items:
|
|
245
|
+
if not isinstance(item, dict):
|
|
246
|
+
rejected.append({"id": None, "reason": "not_an_object"})
|
|
247
|
+
continue
|
|
248
|
+
unit_id = str(item.get("id", ""))
|
|
249
|
+
text = str(item.get("text", "")).strip()
|
|
250
|
+
unit = by_id.get(unit_id)
|
|
251
|
+
if unit is None:
|
|
252
|
+
rejected.append({"id": unit_id, "reason": "unknown_unit"})
|
|
253
|
+
elif not text:
|
|
254
|
+
rejected.append({"id": unit_id, "reason": "empty"})
|
|
255
|
+
elif len(text) > max_chars:
|
|
256
|
+
rejected.append({"id": unit_id, "reason": "too_long", "length": len(text), "limit": max_chars})
|
|
257
|
+
else:
|
|
258
|
+
store.put(unit, text)
|
|
259
|
+
stored.append(unit_id)
|
|
260
|
+
if stored:
|
|
261
|
+
store.save(units)
|
|
262
|
+
groups = store.classify(units)
|
|
263
|
+
return {
|
|
264
|
+
"stored": len(stored),
|
|
265
|
+
"stored_ids": stored,
|
|
266
|
+
"rejected": rejected,
|
|
267
|
+
"remaining": len(groups["missing"]) + len(groups["superseded"]),
|
|
268
|
+
# The store is written but the published index still holds the previous
|
|
269
|
+
# text. Said as its own field rather than folded into `stale`, which
|
|
270
|
+
# answers a different question -- whether the index still describes the
|
|
271
|
+
# repository -- and is correctly False here.
|
|
272
|
+
"reindex_required": len(stored) > 0,
|
|
273
|
+
"path": str(store.path),
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _apply_descriptions(units: list, store: DescriptionStore, cfg: Config) -> int:
|
|
278
|
+
"""Push newly stored text into the in-memory units, re-embedding as needed.
|
|
279
|
+
|
|
280
|
+
Without this an agent would describe a unit and keep retrieving against the
|
|
281
|
+
sentence it replaced until the next `refresh`, which is the slowest way to
|
|
282
|
+
discover that the work had an effect.
|
|
283
|
+
"""
|
|
284
|
+
authored = store.applicable(units)
|
|
285
|
+
changed = 0
|
|
286
|
+
for unit in units:
|
|
287
|
+
text = authored.get(unit.id)
|
|
288
|
+
if text and unit.description != text:
|
|
289
|
+
unit.description = text
|
|
290
|
+
unit.vector = embed(comment_for(text, unit.serial, unit.id) + "\n" + unit.searchable_text, cfg["embedding.dimensions"])
|
|
291
|
+
changed += 1
|
|
292
|
+
return changed
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _cmd_describe(args: argparse.Namespace) -> int:
|
|
296
|
+
payload, units, _, cfg, store = _load(args)
|
|
297
|
+
if args.action == "status":
|
|
298
|
+
groups = store.classify(units)
|
|
299
|
+
print(json.dumps({
|
|
300
|
+
"units": len(units),
|
|
301
|
+
"described": len(groups["described"]),
|
|
302
|
+
"superseded": len(groups["superseded"]),
|
|
303
|
+
"missing": len(groups["missing"]),
|
|
304
|
+
"coverage": round(len(groups["described"]) / len(units), 4) if units else 0.0,
|
|
305
|
+
"path": str(store.path),
|
|
306
|
+
"exists": store.path.is_file(),
|
|
307
|
+
"stale_index": bool(payload.get("stale")),
|
|
308
|
+
}, ensure_ascii=False, indent=2))
|
|
309
|
+
return 0
|
|
310
|
+
if args.action == "export":
|
|
311
|
+
limit = args.limit if args.limit is not None else cfg["describe.batch"]
|
|
312
|
+
batch = _describe_batch(units, store, cfg, limit)
|
|
313
|
+
text = json.dumps(batch, ensure_ascii=False, indent=2)
|
|
314
|
+
if args.output:
|
|
315
|
+
Path(args.output).write_text(text + "\n", encoding="utf-8", newline="\n")
|
|
316
|
+
print(json.dumps({"exported": len(batch["units"]), "remaining": batch["remaining"], "output": args.output}, ensure_ascii=False))
|
|
317
|
+
else:
|
|
318
|
+
print(text)
|
|
319
|
+
return 0
|
|
320
|
+
incoming = json.loads(Path(args.file).read_text(encoding="utf-8"))
|
|
321
|
+
if isinstance(incoming, dict):
|
|
322
|
+
incoming = incoming.get("descriptions", [])
|
|
323
|
+
report = _store_descriptions(units, store, cfg, incoming)
|
|
324
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
325
|
+
return 0 if not report["rejected"] else 1
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _open_source(root: Path, relative_path: str, start_line=None, end_line=None, cfg: Config | None = None) -> dict:
|
|
329
|
+
"""Open only files inside the indexed repository, with bounded output.
|
|
330
|
+
|
|
331
|
+
Two bounds, because a line count is not a size. The indexer skips sources
|
|
332
|
+
over `index.max_file_bytes`, but `open` accepts any in-tree path, and a
|
|
333
|
+
three-line file holding one two-megabyte line satisfied the old 200-line
|
|
334
|
+
bound while returning two megabytes on a single JSON line.
|
|
335
|
+
"""
|
|
336
|
+
max_bytes = cfg["agent.max_open_bytes"] if cfg is not None else MAX_OPEN_BYTES
|
|
337
|
+
max_chars = cfg["agent.max_open_chars"] if cfg is not None else MAX_OPEN_CHARS
|
|
338
|
+
candidate = (root / relative_path).resolve()
|
|
339
|
+
try:
|
|
340
|
+
candidate.relative_to(root)
|
|
341
|
+
except ValueError:
|
|
342
|
+
return {"error": "path_outside_root"}
|
|
343
|
+
if not candidate.is_file():
|
|
344
|
+
return {"error": "file_not_found", "path": relative_path}
|
|
345
|
+
try:
|
|
346
|
+
if candidate.stat().st_size > max_bytes:
|
|
347
|
+
return {"error": "file_too_large", "path": relative_path, "limit_bytes": max_bytes}
|
|
348
|
+
lines = candidate.read_text(encoding="utf-8").splitlines()
|
|
349
|
+
except (OSError, UnicodeDecodeError):
|
|
350
|
+
return {"error": "file_unreadable", "path": relative_path}
|
|
351
|
+
first = max(1, int(start_line or 1))
|
|
352
|
+
last = min(len(lines), int(end_line or min(len(lines), first + 200)))
|
|
353
|
+
if first > last:
|
|
354
|
+
return {"error": "invalid_line_range"}
|
|
355
|
+
source = chr(10).join(lines[first - 1:last])
|
|
356
|
+
response = {"path": relative_path, "start_line": first, "end_line": last}
|
|
357
|
+
if len(source) > max_chars:
|
|
358
|
+
source = source[:max_chars]
|
|
359
|
+
response["truncated"] = True
|
|
360
|
+
response["truncated_at_chars"] = max_chars
|
|
361
|
+
response["source"] = source
|
|
362
|
+
return response
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _request_int(request: dict, key: str, default: int, minimum: int, maximum: int) -> int:
|
|
366
|
+
"""Clamp a numeric request field into [minimum, maximum].
|
|
367
|
+
|
|
368
|
+
The clamp must survive non-finite floats. A host sending `1e400` produces
|
|
369
|
+
`inf`, and `int(inf)` raises OverflowError, which is neither TypeError nor
|
|
370
|
+
ValueError; it escaped the request loop and killed the daemon. Saturating at
|
|
371
|
+
the bound is the reading the caller intended anyway.
|
|
372
|
+
"""
|
|
373
|
+
raw = request.get(key, default)
|
|
374
|
+
if isinstance(raw, float):
|
|
375
|
+
if raw != raw:
|
|
376
|
+
return default
|
|
377
|
+
if raw == math.inf:
|
|
378
|
+
return maximum
|
|
379
|
+
if raw == -math.inf:
|
|
380
|
+
return minimum
|
|
381
|
+
return min(maximum, max(minimum, int(raw)))
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _cmd_agent(args: argparse.Namespace) -> int:
|
|
385
|
+
"""Serve one JSON request per line, suitable for a plugin subprocess."""
|
|
386
|
+
payload, units, graph, cfg, store = _load(args)
|
|
387
|
+
search_index = build_search_index(units)
|
|
388
|
+
root = Path(args.root).resolve()
|
|
389
|
+
weight = cfg["search.vector_weight"]
|
|
390
|
+
default_limit = cfg["search.limit"]
|
|
391
|
+
default_chars = cfg["search.max_chars"]
|
|
392
|
+
stale_monitor = StaleMonitor(root, payload, assume_checked=True, cfg=cfg, descriptions_fingerprint=store.fingerprint)
|
|
393
|
+
# Descriptions stored this session reach the live units immediately but not
|
|
394
|
+
# the published index, which is a different thing from the index being
|
|
395
|
+
# stale against the repository.
|
|
396
|
+
index_behind = False
|
|
397
|
+
for line in sys.stdin:
|
|
398
|
+
if not line.strip():
|
|
399
|
+
continue
|
|
400
|
+
try:
|
|
401
|
+
request = json.loads(line)
|
|
402
|
+
except json.JSONDecodeError as exc:
|
|
403
|
+
print(json.dumps({"error": "invalid_json", "message": str(exc)}, ensure_ascii=False), flush=True)
|
|
404
|
+
continue
|
|
405
|
+
if not isinstance(request, dict):
|
|
406
|
+
print(json.dumps({"error": "invalid_request", "message": "request must be a JSON object"}), flush=True)
|
|
407
|
+
continue
|
|
408
|
+
stale_monitor.check(force=request.get("action") == "stats")
|
|
409
|
+
try:
|
|
410
|
+
action = request.get("action", "search")
|
|
411
|
+
if action == "search":
|
|
412
|
+
query = str(request.get("query", ""))
|
|
413
|
+
limit = _request_int(request, "limit", default_limit, 0, 100)
|
|
414
|
+
hops = _request_int(request, "hops", 1, 0, 3)
|
|
415
|
+
use_graph = bool(request.get("graph", False))
|
|
416
|
+
results = (
|
|
417
|
+
graph_search(units, query, limit, hops, graph, search_index, vector_weight=weight)
|
|
418
|
+
if use_graph
|
|
419
|
+
else search(units, query, limit, search_index=search_index, vector_weight=weight)
|
|
420
|
+
)
|
|
421
|
+
response = {"stale": payload.get("stale", True), "results": [result.to_dict() for result in results], "context": context(results, _request_int(request, "max_chars", default_chars, 0, 100000))}
|
|
422
|
+
elif action == "research":
|
|
423
|
+
response = research(
|
|
424
|
+
units,
|
|
425
|
+
str(request.get("query", "")),
|
|
426
|
+
_request_int(request, "limit", default_limit, 0, 100),
|
|
427
|
+
_request_int(request, "hops", 1, 0, 3),
|
|
428
|
+
_request_int(request, "max_steps", 2, 1, 2),
|
|
429
|
+
float(request.get("confidence_threshold", 0.8)),
|
|
430
|
+
graph,
|
|
431
|
+
search_index,
|
|
432
|
+
vector_weight=weight,
|
|
433
|
+
)
|
|
434
|
+
response["stale"] = payload.get("stale", True)
|
|
435
|
+
elif action == "neighbors":
|
|
436
|
+
unit_id = str(request.get("id", ""))
|
|
437
|
+
neighbors = graph.neighbors(unit_id, hops=_request_int(request, "hops", 1, 0, 3), direction=str(request.get("direction", "both")))
|
|
438
|
+
response_neighbors = []
|
|
439
|
+
for unit, path in neighbors[: _request_int(request, "limit", default_limit, 0, 100)]:
|
|
440
|
+
data = unit.to_dict(include_vector=False)
|
|
441
|
+
response_neighbors.append({"path": path, "unit": data})
|
|
442
|
+
response = {"stale": payload.get("stale", True), "id": unit_id, "neighbors": response_neighbors}
|
|
443
|
+
elif action == "open":
|
|
444
|
+
response = _open_source(root, str(request.get("path", "")), request.get("start_line"), request.get("end_line"), cfg)
|
|
445
|
+
elif action == "describe_pending":
|
|
446
|
+
response = _describe_batch(units, store, cfg, _request_int(request, "limit", cfg["describe.batch"], 0, 200))
|
|
447
|
+
elif action == "describe_put":
|
|
448
|
+
response = _store_descriptions(units, store, cfg, request.get("descriptions", []))
|
|
449
|
+
# Applied to the live units immediately, so the next search in
|
|
450
|
+
# this session already retrieves on the new words rather than
|
|
451
|
+
# waiting for a refresh the agent has no reason to expect.
|
|
452
|
+
response["applied"] = _apply_descriptions(units, store, cfg)
|
|
453
|
+
if response["applied"]:
|
|
454
|
+
search_index = build_search_index(units)
|
|
455
|
+
index_behind = index_behind or response["reindex_required"]
|
|
456
|
+
elif action == "refresh":
|
|
457
|
+
output = Path(args.index) if args.index else _default_index(root)
|
|
458
|
+
response = _refresh_index(root, output, cfg=cfg)
|
|
459
|
+
payload, units, graph, cfg, store = _load(args)
|
|
460
|
+
search_index = build_search_index(units)
|
|
461
|
+
stale_monitor = StaleMonitor(root, payload, assume_checked=True, cfg=cfg, descriptions_fingerprint=store.fingerprint)
|
|
462
|
+
index_behind = False
|
|
463
|
+
elif action == "stats":
|
|
464
|
+
response = {"units": len(units), "files": len({unit.path for unit in units}), "edges": len(graph.edges), "warnings": len(payload.get("diagnostics", [])), "compact": bool(payload.get("vector_store")), "embedding": payload.get("embedding"), "config": str(cfg.source) if cfg.source else None, "described": len(store.applicable(units)), "index_behind": index_behind, "stale": payload.get("stale", True)}
|
|
465
|
+
else:
|
|
466
|
+
response = {"error": f"unsupported action: {action}"}
|
|
467
|
+
except (TypeError, ValueError) as exc:
|
|
468
|
+
response = {"error": "invalid_request", "message": str(exc)}
|
|
469
|
+
except Exception as exc:
|
|
470
|
+
# A daemon serving untrusted request lines must not be able to die
|
|
471
|
+
# because one of them was malformed. Enumerating the expected
|
|
472
|
+
# exception types WAS the defect: int(1e400) raises OverflowError,
|
|
473
|
+
# which is neither TypeError nor ValueError, so a single request
|
|
474
|
+
# terminated the process and every later request went unanswered.
|
|
475
|
+
# This reports the failure in-band rather than swallowing it -- the
|
|
476
|
+
# exception type is returned so a genuine defect stays diagnosable --
|
|
477
|
+
# and KeyboardInterrupt/SystemExit still stop the loop, being
|
|
478
|
+
# BaseException rather than Exception.
|
|
479
|
+
response = {"error": "request_failed", "type": type(exc).__name__, "message": str(exc)}
|
|
480
|
+
if "degraded" not in response:
|
|
481
|
+
response["degraded"] = payload.get("degraded")
|
|
482
|
+
print(json.dumps(response, ensure_ascii=False), flush=True)
|
|
483
|
+
return 0
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
487
|
+
parser = argparse.ArgumentParser(prog="rag-your-code", description="Index and retrieve explainable code units locally.")
|
|
488
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
489
|
+
index = sub.add_parser("index", help="scan a repository and build its local index")
|
|
490
|
+
index.add_argument("root", nargs="?", default=".")
|
|
491
|
+
index.add_argument("--output")
|
|
492
|
+
index.add_argument("--full", action="store_true", help="ignore an existing index and rebuild every file")
|
|
493
|
+
index.add_argument("--compact", action="store_true", default=None, help="store vectors in a float32 sidecar to reduce JSON size")
|
|
494
|
+
index.set_defaults(func=_cmd_index)
|
|
495
|
+
search_parser = sub.add_parser("search", help="retrieve code units")
|
|
496
|
+
search_parser.add_argument("query")
|
|
497
|
+
search_parser.add_argument("--root", default=".")
|
|
498
|
+
search_parser.add_argument("--index")
|
|
499
|
+
# These default to None rather than to a literal so that an unset flag means
|
|
500
|
+
# "whatever the repository configured", not "8". The effective value is
|
|
501
|
+
# reported by `rag-your-code config list`.
|
|
502
|
+
search_parser.add_argument("--limit", type=int, default=None, help=f"results to return (config search.limit, default {BY_PATH['search.limit'].default})")
|
|
503
|
+
search_parser.add_argument("--max-chars", type=int, default=None, help=f"context budget (config search.max_chars, default {BY_PATH['search.max_chars'].default})")
|
|
504
|
+
search_parser.add_argument("--json", action="store_true")
|
|
505
|
+
search_parser.add_argument("--graph", action="store_true", help="expand results through calls/imports/contains edges")
|
|
506
|
+
search_parser.add_argument("--hops", type=int, default=1)
|
|
507
|
+
search_parser.set_defaults(func=_cmd_search)
|
|
508
|
+
annotate = sub.add_parser("annotate", help="write numbered descriptive sidecar comments")
|
|
509
|
+
annotate.add_argument("--root", default=".")
|
|
510
|
+
annotate.add_argument("--index")
|
|
511
|
+
annotate.add_argument("--output")
|
|
512
|
+
annotate.set_defaults(func=_cmd_annotate)
|
|
513
|
+
agent = sub.add_parser("agent", help="serve JSON-lines requests for a coding agent")
|
|
514
|
+
agent.add_argument("--root", default=".")
|
|
515
|
+
agent.add_argument("--index")
|
|
516
|
+
agent.set_defaults(func=_cmd_agent)
|
|
517
|
+
config_parser = sub.add_parser("config", help="inspect or change rag-your-code.toml")
|
|
518
|
+
config_parser.add_argument("action", choices=("list", "get", "set", "init", "path"))
|
|
519
|
+
config_parser.add_argument("name", nargs="?", help="dotted setting name, e.g. search.vector_weight")
|
|
520
|
+
config_parser.add_argument("value", nargs="?", help="TOML literal, e.g. 0.25 or '[\".py\", \".vue\"]'")
|
|
521
|
+
config_parser.add_argument("--root", default=".")
|
|
522
|
+
config_parser.add_argument("--force", action="store_true", help="with init, overwrite an existing file")
|
|
523
|
+
config_parser.set_defaults(func=_cmd_config)
|
|
524
|
+
describe = sub.add_parser("describe", help="inspect or supply agent-authored unit descriptions")
|
|
525
|
+
describe.add_argument("action", choices=("status", "export", "import"))
|
|
526
|
+
describe.add_argument("file", nargs="?", help="with import, a JSON file of {id, text} objects")
|
|
527
|
+
describe.add_argument("--root", default=".")
|
|
528
|
+
describe.add_argument("--index")
|
|
529
|
+
describe.add_argument("--limit", type=int, default=None, help=f"with export, units per batch (config describe.batch, default {BY_PATH['describe.batch'].default})")
|
|
530
|
+
describe.add_argument("--output", help="with export, write the batch here instead of stdout")
|
|
531
|
+
describe.set_defaults(func=_cmd_describe)
|
|
532
|
+
return parser
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _use_utf8_streams() -> None:
|
|
536
|
+
"""Pin the process streams to UTF-8, which is what the protocol promises.
|
|
537
|
+
|
|
538
|
+
The index and the JSON-lines agent protocol are UTF-8 by contract, but
|
|
539
|
+
Python decodes stdio with the OS locale codepage. On a non-UTF-8 console
|
|
540
|
+
(cp936, cp1252) that costs correctness twice: printing a response whose
|
|
541
|
+
source or docstring holds a character outside the codepage raises
|
|
542
|
+
UnicodeEncodeError and kills a long-lived ``agent`` subprocess, and a
|
|
543
|
+
request line written as UTF-8 by the host is mis-decoded into mojibake
|
|
544
|
+
that matches nothing and returns an empty, exit-0 result.
|
|
545
|
+
"""
|
|
546
|
+
for stream in (sys.stdin, sys.stdout, sys.stderr):
|
|
547
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
548
|
+
if reconfigure is None: # pytest capture and other wrappers are not TextIOWrapper
|
|
549
|
+
continue
|
|
550
|
+
try:
|
|
551
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
552
|
+
except (AttributeError, OSError, ValueError):
|
|
553
|
+
# A stream that refuses reconfiguration (already detached, or a
|
|
554
|
+
# binary substitute) keeps its own encoding. Failing the whole
|
|
555
|
+
# command over stream setup would be worse than the mojibake.
|
|
556
|
+
continue
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def main(argv: list[str] | None = None) -> int:
|
|
560
|
+
_use_utf8_streams()
|
|
561
|
+
args = build_parser().parse_args(argv)
|
|
562
|
+
try:
|
|
563
|
+
return args.func(args)
|
|
564
|
+
except ConfigError as exc:
|
|
565
|
+
# Surfaced separately from the generic handler because the fix is
|
|
566
|
+
# always in one named file: say which, so the message is actionable.
|
|
567
|
+
print(f"error: {config_module.CONFIG_FILENAME}: {exc}", file=sys.stderr)
|
|
568
|
+
return 2
|
|
569
|
+
except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
570
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
571
|
+
return 2
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
if __name__ == "__main__":
|
|
575
|
+
raise SystemExit(main())
|