skelpr 0.1.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 (121) hide show
  1. skelpr/__init__.py +5 -0
  2. skelpr/agents/__init__.py +0 -0
  3. skelpr/agents/bug_fix_agent.py +251 -0
  4. skelpr/agents/patch_agent.py +36 -0
  5. skelpr/agents/pr_review_agent.py +42 -0
  6. skelpr/agents/repo_qa_agent.py +36 -0
  7. skelpr/agents/test_generation_agent.py +46 -0
  8. skelpr/cli/__init__.py +0 -0
  9. skelpr/cli/commands/__init__.py +0 -0
  10. skelpr/cli/commands/ask.py +76 -0
  11. skelpr/cli/commands/chat.py +218 -0
  12. skelpr/cli/commands/context.py +143 -0
  13. skelpr/cli/commands/fix.py +120 -0
  14. skelpr/cli/commands/github.py +352 -0
  15. skelpr/cli/commands/index.py +30 -0
  16. skelpr/cli/commands/init.py +168 -0
  17. skelpr/cli/commands/install_mcp.py +449 -0
  18. skelpr/cli/commands/register_agent.py +168 -0
  19. skelpr/cli/commands/review.py +73 -0
  20. skelpr/cli/commands/serve.py +26 -0
  21. skelpr/cli/commands/setup.py +253 -0
  22. skelpr/cli/commands/uninstall_mcp.py +118 -0
  23. skelpr/cli/commands/validate.py +69 -0
  24. skelpr/cli/console.py +31 -0
  25. skelpr/cli/main.py +100 -0
  26. skelpr/config/__init__.py +0 -0
  27. skelpr/config/defaults.py +96 -0
  28. skelpr/config/loader.py +181 -0
  29. skelpr/config/schema.py +205 -0
  30. skelpr/db/__init__.py +0 -0
  31. skelpr/db/migrations/0001_initial.sql +177 -0
  32. skelpr/db/migrations/0002_add_tenant_columns.sql +148 -0
  33. skelpr/db/postgres.py +474 -0
  34. skelpr/db/repositories.py +592 -0
  35. skelpr/embeddings/__init__.py +0 -0
  36. skelpr/embeddings/embedder.py +57 -0
  37. skelpr/embeddings/local_embedding_client.py +139 -0
  38. skelpr/engine.py +527 -0
  39. skelpr/indexers/__init__.py +0 -0
  40. skelpr/indexers/chunker.py +160 -0
  41. skelpr/indexers/docs_indexer.py +59 -0
  42. skelpr/indexers/graphify_adapter.py +213 -0
  43. skelpr/indexers/hashing.py +17 -0
  44. skelpr/indexers/repo_scanner.py +113 -0
  45. skelpr/indexers/scip_indexer.py +107 -0
  46. skelpr/indexers/tree_sitter_indexer.py +458 -0
  47. skelpr/integrations/__init__.py +0 -0
  48. skelpr/integrations/common.py +37 -0
  49. skelpr/integrations/github/__init__.py +0 -0
  50. skelpr/integrations/github/checks.py +50 -0
  51. skelpr/integrations/github/comments.py +54 -0
  52. skelpr/integrations/github/webhook.py +54 -0
  53. skelpr/integrations/gitlab/__init__.py +0 -0
  54. skelpr/integrations/gitlab/comments.py +50 -0
  55. skelpr/integrations/gitlab/webhook.py +39 -0
  56. skelpr/integrations/mcp_server.py +928 -0
  57. skelpr/models/__init__.py +0 -0
  58. skelpr/models/constants.py +122 -0
  59. skelpr/models/local_llm_client.py +282 -0
  60. skelpr/models/model_router.py +66 -0
  61. skelpr/models/prompt_templates.py +105 -0
  62. skelpr/models/remote_llm_client.py +191 -0
  63. skelpr/patches/__init__.py +0 -0
  64. skelpr/patches/diff_generator.py +438 -0
  65. skelpr/patches/patch_validator.py +54 -0
  66. skelpr/patches/patch_writer.py +46 -0
  67. skelpr/plugins/README.md +124 -0
  68. skelpr/plugins/__init__.py +103 -0
  69. skelpr/plugins/__main__.py +39 -0
  70. skelpr/plugins/api.py +378 -0
  71. skelpr/plugins/server.py +43 -0
  72. skelpr/py.typed +0 -0
  73. skelpr/retrieval/__init__.py +0 -0
  74. skelpr/retrieval/context_builder.py +72 -0
  75. skelpr/retrieval/graph_retriever.py +48 -0
  76. skelpr/retrieval/hybrid_retriever.py +337 -0
  77. skelpr/retrieval/lexical_retriever.py +395 -0
  78. skelpr/retrieval/reranker.py +114 -0
  79. skelpr/retrieval/symbol_retriever.py +44 -0
  80. skelpr/retrieval/types.py +42 -0
  81. skelpr/retrieval/vector_retriever.py +307 -0
  82. skelpr/review/__init__.py +0 -0
  83. skelpr/review/confidence_filter.py +64 -0
  84. skelpr/review/diff_parser.py +107 -0
  85. skelpr/review/finding_schema.py +94 -0
  86. skelpr/review/review_formatter.py +88 -0
  87. skelpr/review/review_gate.py +246 -0
  88. skelpr/review/severity_ranker.py +16 -0
  89. skelpr/sandbox/Dockerfile.runner +88 -0
  90. skelpr/sandbox/__init__.py +0 -0
  91. skelpr/sandbox/command_runner.py +95 -0
  92. skelpr/sandbox/docker_runner.py +81 -0
  93. skelpr/sandbox/security.py +34 -0
  94. skelpr/sandbox/validation_runner.py +119 -0
  95. skelpr/server/__init__.py +0 -0
  96. skelpr/server/app.py +55 -0
  97. skelpr/server/deps.py +202 -0
  98. skelpr/server/routes/__init__.py +0 -0
  99. skelpr/server/routes/ask.py +24 -0
  100. skelpr/server/routes/fix.py +43 -0
  101. skelpr/server/routes/health.py +16 -0
  102. skelpr/server/routes/repos.py +24 -0
  103. skelpr/server/routes/review.py +26 -0
  104. skelpr/server/routes/validate.py +21 -0
  105. skelpr/server/schemas.py +72 -0
  106. skelpr/server/security.py +232 -0
  107. skelpr/utils/__init__.py +0 -0
  108. skelpr/utils/errors.py +51 -0
  109. skelpr/utils/files.py +60 -0
  110. skelpr/utils/git.py +103 -0
  111. skelpr/utils/logging.py +91 -0
  112. skelpr/utils/paths.py +49 -0
  113. skelpr/utils/tracing.py +344 -0
  114. skelpr-0.1.0.dist-info/METADATA +458 -0
  115. skelpr-0.1.0.dist-info/RECORD +121 -0
  116. skelpr-0.1.0.dist-info/WHEEL +5 -0
  117. skelpr-0.1.0.dist-info/entry_points.txt +4 -0
  118. skelpr-0.1.0.dist-info/licenses/LICENSE +201 -0
  119. skelpr-0.1.0.dist-info/licenses/NOTICE +24 -0
  120. skelpr-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +48 -0
  121. skelpr-0.1.0.dist-info/top_level.txt +1 -0
skelpr/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Skelpr: Fully local, paid-API-free AI development assistant."""
2
+ from __future__ import annotations
3
+
4
+ __version__ = "0.1.0"
5
+ __all__ = ["__version__"]
File without changes
@@ -0,0 +1,251 @@
1
+ """Bug-fix agent: issue -> grounded root-cause hypothesis + minimal edits.
2
+
3
+ Flow:
4
+ 1. Retrieve context from the hybrid index (chunks, symbols, graph).
5
+ 2. Ask the model for a diagnosis + edits (first pass).
6
+ 3. If edits are missing or any find-string doesn't match the real file,
7
+ read the actual file(s) in full and retry once with exact source injected.
8
+ 4. If the model returned empty/garbage, extract candidate files from the
9
+ issue text and inject them for the retry pass.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ from ..models.model_router import ModelRouter, Task
18
+ from ..models.prompt_templates import bug_fix_messages
19
+ from ..patches.diff_generator import Edit
20
+ from ..retrieval.context_builder import build_context, context_is_sufficient
21
+ from ..retrieval.hybrid_retriever import HybridRetriever
22
+ from ..utils.errors import ModelError
23
+ from ..utils.logging import get_logger
24
+
25
+ log = get_logger(__name__)
26
+
27
+ _ERR_TERM = re.compile(r"[A-Za-z_][A-Za-z0-9_]{3,}")
28
+ _TRACE_FILE = re.compile(r'File "([^"]+)", line (\d+)')
29
+
30
+ # How many chars of each real file to inject on the retry pass.
31
+ _MAX_FILE_INJECT_CHARS = 12_000
32
+
33
+ # File extension pattern used to find candidate files in the issue text.
34
+ _FILE_PATTERN = re.compile(
35
+ r"\b([\w/\\.-]+\.(?:js|ts|tsx|jsx|py|css|scss|sass|java|go|rs|vue|svelte))\b"
36
+ )
37
+ # CamelCase component/class names (e.g. CategoryManagement, AdminPanel)
38
+ _COMPONENT_PATTERN = re.compile(r"\b([A-Z][a-zA-Z]{3,})\b")
39
+ _CODE_EXTENSIONS = {".js", ".ts", ".tsx", ".jsx", ".py", ".css", ".scss",
40
+ ".sass", ".java", ".go", ".rs", ".vue", ".svelte"}
41
+
42
+
43
+ def _error_terms(issue: str, stack_trace: str | None) -> list[str]:
44
+ blob = f"{issue}\n{stack_trace or ''}"
45
+ quoted = re.findall(r"[\"'`]([^\"'`]{3,60})[\"'`]", blob)
46
+ errors = re.findall(r"\b(\w*(?:Error|Exception|Warning))\b", blob)
47
+ idents: list[str] = []
48
+ for m in _ERR_TERM.finditer(issue):
49
+ idents.append(m.group(0))
50
+ seen: list[str] = []
51
+ for t in errors + quoted + idents:
52
+ t = t.strip()
53
+ if t and t not in seen:
54
+ seen.append(t)
55
+ return seen[:12]
56
+
57
+
58
+ def _parse_edits(raw: dict) -> list[Edit]:
59
+ return [
60
+ Edit(file=e.get("file", ""), find=e.get("find", ""),
61
+ replace=e.get("replace", ""), reason=e.get("reason", ""))
62
+ for e in raw.get("edits", []) if isinstance(e, dict)
63
+ ]
64
+
65
+
66
+ def _extract_files_from_issue(issue: str, root: Path) -> list[str]:
67
+ """Find files that exist in the repo and are mentioned in the issue text.
68
+
69
+ Looks for:
70
+ - Explicit file paths with extensions (e.g. app/containers/Foo/index.js)
71
+ - CamelCase component/class names (e.g. CategoryManagement -> CategoryManagement/index.js)
72
+ """
73
+ result: list[str] = []
74
+
75
+ # 1. Explicit file references with extensions
76
+ for m in _FILE_PATTERN.finditer(issue):
77
+ name = Path(m.group(1)).name
78
+ for p in root.rglob(name):
79
+ try:
80
+ rel = str(p.relative_to(root)).replace("\\", "/")
81
+ except ValueError:
82
+ continue
83
+ if rel not in result:
84
+ result.append(rel)
85
+
86
+ # 2. CamelCase component/class names — search for files whose path contains the name
87
+ for m in _COMPONENT_PATTERN.finditer(issue):
88
+ name = m.group(1)
89
+ for p in root.rglob(f"*{name}*"):
90
+ if p.suffix in _CODE_EXTENSIONS:
91
+ try:
92
+ rel = str(p.relative_to(root)).replace("\\", "/")
93
+ except ValueError:
94
+ continue
95
+ if rel not in result:
96
+ result.append(rel)
97
+
98
+ return result[:4]
99
+
100
+
101
+ def _files_needing_injection(edits: list[Edit], affected: list[str],
102
+ root: Path) -> list[str]:
103
+ """Return files that need full-source injection:
104
+ - any file in affected_files the model didn't produce edits for, OR
105
+ - any file whose find-string doesn't exist in the real file.
106
+ """
107
+ needed: list[str] = []
108
+
109
+ edit_files = {e.file for e in edits if e.file}
110
+ for f in affected:
111
+ if f not in edit_files and f not in needed:
112
+ needed.append(f)
113
+
114
+ for e in edits:
115
+ if not e.find or not e.file or e.file in needed:
116
+ continue
117
+ fp = root / e.file
118
+ if not fp.exists():
119
+ continue
120
+ try:
121
+ text = fp.read_text(encoding="utf-8", errors="replace")
122
+ except OSError:
123
+ continue
124
+ # Simple check if find string exists (exact match only)
125
+ # The patching system will handle fuzzy matching if needed
126
+ if e.find not in text:
127
+ needed.append(e.file)
128
+
129
+ return needed
130
+
131
+
132
+ def _inject_file_content(root: Path, files: list[str],
133
+ base_context: str) -> str:
134
+ parts = [base_context]
135
+ for rel in files:
136
+ fp = root / rel
137
+ try:
138
+ src = fp.read_text(encoding="utf-8", errors="replace")
139
+ except OSError:
140
+ continue
141
+ snippet = src[:_MAX_FILE_INJECT_CHARS]
142
+ if len(src) > _MAX_FILE_INJECT_CHARS:
143
+ snippet += f"\n... (file truncated at {_MAX_FILE_INJECT_CHARS} chars)"
144
+ parts.append(
145
+ f"\n\n# FULL SOURCE of {rel} "
146
+ f"(your find-strings MUST be verbatim substrings from this):\n{snippet}"
147
+ )
148
+ return "".join(parts)
149
+
150
+
151
+ def _raw_is_empty(raw: dict) -> bool:
152
+ """True when the model returned a dict with no useful diagnosis content."""
153
+ return not (raw.get("root_cause") or raw.get("plan") or
154
+ raw.get("edits") or raw.get("affected_files"))
155
+
156
+
157
+ @dataclass
158
+ class Diagnosis:
159
+ root_cause: str
160
+ affected_files: list[str]
161
+ plan: str
162
+ risk: str
163
+ edits: list[Edit] = field(default_factory=list)
164
+ sufficient_context: bool = True
165
+ backends: dict[str, str] = field(default_factory=dict)
166
+
167
+
168
+ class BugFixAgent:
169
+ def __init__(self, retriever: HybridRetriever, router: ModelRouter,
170
+ max_context_tokens: int = 6000):
171
+ self._retriever = retriever
172
+ self._router = router
173
+ self._max_ctx = max_context_tokens
174
+
175
+ def _safe_complete_json(self, task: Task, messages: list) -> dict:
176
+ """Call complete_json and always return a dict (never raises)."""
177
+ try:
178
+ result = self._router.complete_json(task, messages)
179
+ if isinstance(result, dict):
180
+ return result
181
+ if isinstance(result, list):
182
+ # Model returned a bare list — treat it as affected_files so
183
+ # the retry logic can inject those files.
184
+ log.warning("complete_json returned list; treating as affected_files: %s",
185
+ result[:4])
186
+ return {"affected_files": [r for r in result if isinstance(r, str)]}
187
+ log.warning("complete_json returned unexpected type %s; ignoring", type(result))
188
+ return {}
189
+ except ModelError as exc:
190
+ log.warning("complete_json ModelError: %s", exc)
191
+ return {}
192
+ except Exception as exc:
193
+ log.warning("complete_json unexpected error: %s", exc)
194
+ return {}
195
+
196
+ def diagnose(self, issue: str, stack_trace: str | None = None,
197
+ root: Path | None = None) -> Diagnosis:
198
+ terms = _error_terms(issue, stack_trace)
199
+ trace_files = [m.group(1) for m in _TRACE_FILE.finditer(stack_trace or "")]
200
+
201
+ chunks = self._retriever.retrieve_for_diff(
202
+ changed_files=trace_files, changed_symbols=terms,
203
+ error_terms=terms, top_k=14)
204
+ chunks += self._retriever.retrieve_for_query(issue, top_k=6)
205
+ sufficient = context_is_sufficient(chunks)
206
+ ctx = build_context(
207
+ chunks, max_tokens=self._max_ctx,
208
+ header="# Code context for diagnosis (copy find-strings verbatim)")
209
+
210
+ # --- first pass ---
211
+ raw = self._safe_complete_json(
212
+ Task.fix, bug_fix_messages(issue, ctx.text, stack_trace))
213
+ edits = _parse_edits(raw)
214
+ affected = list(raw.get("affected_files", []) or [])
215
+
216
+ # --- retry: inject full file source ---
217
+ # Triggered when:
218
+ # (a) model returned empty/garbage -> inject files from issue text
219
+ # (b) model listed affected_files but no edits -> inject those files
220
+ # (c) find-strings don't exist in real files -> inject those files
221
+ if root is not None:
222
+ if _raw_is_empty(raw):
223
+ to_inject = _extract_files_from_issue(issue, root)
224
+ log.info("First pass empty; injecting issue-mentioned files: %s", to_inject)
225
+ else:
226
+ to_inject = _files_needing_injection(edits, affected, root)
227
+ if not to_inject and _raw_is_empty({"edits": edits}):
228
+ # Got a diagnosis but no edits — inject affected files
229
+ to_inject = affected[:3]
230
+
231
+ if to_inject:
232
+ enriched = _inject_file_content(root, to_inject, ctx.text)
233
+ retry_issue = (
234
+ issue
235
+ + "\n\nIMPORTANT: The complete source of the relevant files is "
236
+ "appended above. The new patching system will use AST-based "
237
+ "symbol resolution when possible, with fuzzy matching fallback."
238
+ )
239
+ raw2 = self._safe_complete_json(
240
+ Task.fix, bug_fix_messages(retry_issue, enriched, stack_trace))
241
+ if not _raw_is_empty(raw2):
242
+ edits2 = _parse_edits(raw2)
243
+ if edits2 or raw2.get("root_cause"):
244
+ raw, edits = raw2, edits2
245
+
246
+ return Diagnosis(
247
+ root_cause=str(raw.get("root_cause", "")),
248
+ affected_files=list(raw.get("affected_files", []) or []),
249
+ plan=str(raw.get("plan", "")), risk=str(raw.get("risk", "")),
250
+ edits=edits, sufficient_context=sufficient,
251
+ backends=self._retriever.backends())
@@ -0,0 +1,36 @@
1
+ """Patch agent: turn diagnosis edits into a validated, minimal unified diff."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from ..config.schema import CommandsConfig
8
+ from ..patches.diff_generator import Edit, GeneratedPatch, generate_patch
9
+ from ..patches.patch_validator import PatchCheck, check_patch
10
+ from ..utils.errors import PatchError
11
+
12
+
13
+ @dataclass
14
+ class PatchOutcome:
15
+ patch: GeneratedPatch
16
+ check: PatchCheck
17
+ validation_recommendation: str
18
+
19
+
20
+ class PatchAgent:
21
+ def __init__(self, root: Path, commands: CommandsConfig):
22
+ self._root = root
23
+ self._commands = commands
24
+
25
+ def build(self, edits: list[Edit]) -> PatchOutcome:
26
+ if not edits:
27
+ raise PatchError("No edits to apply; cannot generate a patch.")
28
+ patch = generate_patch(self._root, edits)
29
+ check = check_patch(self._root, patch.unified_diff)
30
+ cmds = self._commands.as_mapping()
31
+ rec = (
32
+ "After applying, validate with: "
33
+ + "; ".join(f"{name} (`{cmd}`)" for name, cmd in cmds.items())
34
+ + ". Do not consider the fix complete until validation passes."
35
+ )
36
+ return PatchOutcome(patch=patch, check=check, validation_recommendation=rec)
@@ -0,0 +1,42 @@
1
+ """PR review agent: diff -> retrieval -> model -> filtered structured findings."""
2
+ from __future__ import annotations
3
+
4
+ from ..config.schema import ReviewConfig
5
+ from ..models.model_router import ModelRouter, Task
6
+ from ..models.prompt_templates import pr_review_messages
7
+ from ..retrieval.context_builder import build_context
8
+ from ..retrieval.hybrid_retriever import HybridRetriever
9
+ from ..review.confidence_filter import filter_findings
10
+ from ..review.diff_parser import ParsedDiff, changed_symbol_names, parse_unified_diff
11
+ from ..review.finding_schema import ReviewResult, parse_findings
12
+ from ..review.severity_ranker import rank_and_cap
13
+
14
+
15
+ class PRReviewAgent:
16
+ def __init__(self, retriever: HybridRetriever, router: ModelRouter,
17
+ review_config: ReviewConfig, max_context_tokens: int = 12000):
18
+ self._retriever = retriever
19
+ self._router = router
20
+ self._config = review_config
21
+ self._max_ctx = max_context_tokens
22
+
23
+ def review(self, diff_text: str, diff_range: str = "") -> ReviewResult:
24
+ parsed: ParsedDiff = parse_unified_diff(diff_text)
25
+ if not parsed.files:
26
+ return ReviewResult(diff_range=diff_range, backends=self._retriever.backends())
27
+
28
+ changed_files = parsed.changed_paths
29
+ changed_symbols = changed_symbol_names(parsed)
30
+ context_chunks = self._retriever.retrieve_for_diff(
31
+ changed_files=changed_files, changed_symbols=changed_symbols)
32
+ ctx = build_context(context_chunks, max_tokens=self._max_ctx,
33
+ header="# Context for the change")
34
+
35
+ raw = self._router.complete_json(Task.review, pr_review_messages(diff_text, ctx.text))
36
+ findings = parse_findings(raw)
37
+
38
+ kept, suppressed = filter_findings(findings, self._config, parsed)
39
+ ranked = rank_and_cap(kept, self._config)
40
+ suppressed += len(kept) - len(ranked)
41
+ return ReviewResult(findings=ranked, suppressed=suppressed,
42
+ diff_range=diff_range, backends=self._retriever.backends())
@@ -0,0 +1,36 @@
1
+ """Repo Q&A agent: grounded answers with file:line citations."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ from ..models.model_router import ModelRouter, Task
7
+ from ..models.prompt_templates import repo_qa_messages
8
+ from ..retrieval.context_builder import build_context, context_is_sufficient
9
+ from ..retrieval.hybrid_retriever import HybridRetriever
10
+
11
+
12
+ @dataclass
13
+ class Answer:
14
+ text: str
15
+ citations: list[str]
16
+ sufficient_context: bool
17
+ backends: dict[str, str]
18
+
19
+
20
+ class RepoQAAgent:
21
+ def __init__(self, retriever: HybridRetriever, router: ModelRouter,
22
+ max_context_tokens: int = 16000):
23
+ self._retriever = retriever
24
+ self._router = router
25
+ self._max_ctx = max_context_tokens
26
+
27
+ def ask(self, question: str, top_k: int = 12) -> Answer:
28
+ chunks = self._retriever.retrieve_for_query(question, top_k=top_k)
29
+ sufficient = context_is_sufficient(chunks)
30
+ ctx = build_context(chunks, max_tokens=self._max_ctx,
31
+ header="# Retrieved context (cite these locations)")
32
+ messages = repo_qa_messages(question, ctx.text, insufficient=not sufficient)
33
+ result = self._router.complete(Task.qa, messages)
34
+ return Answer(text=result.text.strip(), citations=ctx.citations,
35
+ sufficient_context=sufficient,
36
+ backends=self._retriever.backends())
@@ -0,0 +1,46 @@
1
+ """Test-generation agent: propose minimal tests for changed behavior."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+
6
+ from ..models.model_router import ModelRouter, Task
7
+ from ..models.prompt_templates import test_gen_messages
8
+ from ..retrieval.context_builder import build_context
9
+ from ..retrieval.hybrid_retriever import HybridRetriever
10
+
11
+
12
+ @dataclass
13
+ class ProposedTest:
14
+ path: str
15
+ content: str
16
+ reason: str
17
+
18
+
19
+ @dataclass
20
+ class TestProposal:
21
+ tests: list[ProposedTest] = field(default_factory=list)
22
+ backends: dict[str, str] = field(default_factory=dict)
23
+
24
+
25
+ class TestGenerationAgent:
26
+ def __init__(self, retriever: HybridRetriever, router: ModelRouter,
27
+ max_context_tokens: int = 12000):
28
+ self._retriever = retriever
29
+ self._router = router
30
+ self._max_ctx = max_context_tokens
31
+
32
+ def generate(self, change_summary: str, changed_files: list[str],
33
+ changed_symbols: list[str]) -> TestProposal:
34
+ chunks = self._retriever.retrieve_for_diff(
35
+ changed_files=changed_files, changed_symbols=changed_symbols, top_k=10)
36
+ ctx = build_context(chunks, max_tokens=self._max_ctx,
37
+ header="# Code under test + existing tests")
38
+ raw = self._router.complete_json(
39
+ Task.test_gen, test_gen_messages(change_summary, ctx.text))
40
+ raw = raw if isinstance(raw, dict) else {}
41
+ tests = [
42
+ ProposedTest(path=t.get("path", ""), content=t.get("content", ""),
43
+ reason=t.get("reason", ""))
44
+ for t in raw.get("tests", []) if isinstance(t, dict)
45
+ ]
46
+ return TestProposal(tests=tests, backends=self._retriever.backends())
skelpr/cli/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,76 @@
1
+ """`skelpr ask` - grounded Q&A over the repo."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from ...engine import Engine
10
+ from ...utils.errors import ModelError
11
+ from ...utils.tracing import (
12
+ build_retrieval_meta,
13
+ build_timing,
14
+ traced_operation,
15
+ )
16
+ from ..console import console, fail
17
+
18
+
19
+ def run(
20
+ question: str = typer.Argument(..., help="Your question about the repository."),
21
+ top_k: int = typer.Option(12, "--top-k", help="Number of context chunks."),
22
+ path: Path = typer.Option(None, "--path"),
23
+ ) -> None:
24
+ t0 = time.perf_counter()
25
+ try:
26
+ engine = Engine.from_path(path)
27
+ except Exception as exc:
28
+ fail(str(exc))
29
+
30
+ with traced_operation("skelpr_ask", engine, {"query": question, "top_k": top_k}) as span:
31
+ try:
32
+ t_retrieval = time.perf_counter()
33
+ answer = engine.ask(question, top_k=top_k)
34
+ retrieval_ms = (time.perf_counter() - t_retrieval) * 1000
35
+
36
+ # Get retrieval metrics if available
37
+ rm = getattr(engine, '_last_retriever', None)
38
+ rm = rm.last_retrieval_metrics if rm else None
39
+
40
+ # Build full metadata
41
+ retrieval_meta = build_retrieval_meta(rm, [])
42
+ timing = build_timing(rm, 0.0, retrieval_ms, 0.0, (time.perf_counter() - t0) * 1000)
43
+
44
+ span.finish(
45
+ output={
46
+ "text": answer.text,
47
+ "sufficient_context": answer.sufficient_context,
48
+ "citations": answer.citations,
49
+ "final_context_char_count": len(answer.text),
50
+ "context_token_estimate": len(answer.text) // 4,
51
+ },
52
+ metadata={
53
+ "success": True,
54
+ "top_k": top_k,
55
+ **retrieval_meta,
56
+ **timing,
57
+ },
58
+ )
59
+ except ModelError as exc:
60
+ span.finish(metadata={"success": False, "error_type": "ModelError", "error": str(exc)})
61
+ fail(str(exc), code=3)
62
+ except Exception as exc:
63
+ span.finish(metadata={"success": False, "error_type": "Exception", "error": str(exc)})
64
+ fail(str(exc))
65
+
66
+ elapsed = time.perf_counter() - t0
67
+ from rich.panel import Panel
68
+
69
+ console.print(f"[dim] {elapsed:.1f}s[/dim]")
70
+ console.print(Panel(answer.text, title="Answer", border_style="cyan"))
71
+ if not answer.sufficient_context:
72
+ console.print("[yellow]Note:[/yellow] retrieved context was thin; "
73
+ "answer may be incomplete.")
74
+ if answer.citations:
75
+ console.print("[dim]Context:[/dim] " + ", ".join(answer.citations))
76
+ engine.close()