local-coding-agent 1.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 (101) hide show
  1. local_coding_agent/__init__.py +314 -0
  2. local_coding_agent/__main__.py +5 -0
  3. local_coding_agent/acp_server/__init__.py +38 -0
  4. local_coding_agent/acp_server/_codec.py +95 -0
  5. local_coding_agent/acp_server/_protocol.py +13 -0
  6. local_coding_agent/acp_server/_server.py +663 -0
  7. local_coding_agent/acp_server/_session.py +24 -0
  8. local_coding_agent/acp_server/_tools.py +126 -0
  9. local_coding_agent/ast_compactor.py +130 -0
  10. local_coding_agent/atomizer.py +77 -0
  11. local_coding_agent/benchmark/__init__.py +57 -0
  12. local_coding_agent/benchmark/_cases.py +307 -0
  13. local_coding_agent/benchmark/_judge.py +175 -0
  14. local_coding_agent/benchmark/_oracles.py +157 -0
  15. local_coding_agent/benchmark/_runner.py +259 -0
  16. local_coding_agent/benchmark/_stats.py +22 -0
  17. local_coding_agent/benchmark/_summary.py +143 -0
  18. local_coding_agent/benchmark_oracle_worker.py +169 -0
  19. local_coding_agent/calibration.py +154 -0
  20. local_coding_agent/capability.py +450 -0
  21. local_coding_agent/chat_compaction.py +276 -0
  22. local_coding_agent/cli/__init__.py +12 -0
  23. local_coding_agent/cli/_dispatch.py +377 -0
  24. local_coding_agent/cli/_handlers.py +320 -0
  25. local_coding_agent/cli/_handlers2.py +725 -0
  26. local_coding_agent/cli/_input.py +61 -0
  27. local_coding_agent/cli/_main.py +159 -0
  28. local_coding_agent/cli/_parser.py +327 -0
  29. local_coding_agent/context_manager.py +155 -0
  30. local_coding_agent/controller/__init__.py +18 -0
  31. local_coding_agent/controller/_constants.py +108 -0
  32. local_coding_agent/controller/_controller.py +1008 -0
  33. local_coding_agent/controller/_post_apply.py +216 -0
  34. local_coding_agent/delegator.py +180 -0
  35. local_coding_agent/desktop/__init__.py +8 -0
  36. local_coding_agent/desktop/app.py +100 -0
  37. local_coding_agent/desktop/assets/lucide.min.js +12 -0
  38. local_coding_agent/desktop/assets/tailwind.css +1 -0
  39. local_coding_agent/desktop/client_js.py +1271 -0
  40. local_coding_agent/desktop/components.py +555 -0
  41. local_coding_agent/desktop/server/__init__.py +37 -0
  42. local_coding_agent/desktop/server/_handlers.py +2178 -0
  43. local_coding_agent/desktop/server/_models.py +204 -0
  44. local_coding_agent/desktop/server/_server.py +347 -0
  45. local_coding_agent/desktop/server/_telemetry.py +87 -0
  46. local_coding_agent/desktop/styles.py +76 -0
  47. local_coding_agent/desktop/ui.py +53 -0
  48. local_coding_agent/doctor.py +413 -0
  49. local_coding_agent/hooks/__init__.py +19 -0
  50. local_coding_agent/hooks/_adapters.py +458 -0
  51. local_coding_agent/hooks/_bridge.py +346 -0
  52. local_coding_agent/hooks/_decision.py +26 -0
  53. local_coding_agent/lsp/__init__.py +60 -0
  54. local_coding_agent/lsp/_client.py +322 -0
  55. local_coding_agent/lsp/_fallback.py +297 -0
  56. local_coding_agent/lsp/_manager.py +173 -0
  57. local_coding_agent/lsp/_mock.py +200 -0
  58. local_coding_agent/lsp/_types.py +398 -0
  59. local_coding_agent/mcp_config.py +398 -0
  60. local_coding_agent/mcp_server.py +377 -0
  61. local_coding_agent/memory.py +140 -0
  62. local_coding_agent/mode_router.py +200 -0
  63. local_coding_agent/model_scanner.py +525 -0
  64. local_coding_agent/monitor.py +635 -0
  65. local_coding_agent/observation_policy.py +184 -0
  66. local_coding_agent/ollama_adapter.py +736 -0
  67. local_coding_agent/plan_mode.py +653 -0
  68. local_coding_agent/prescriptions.py +278 -0
  69. local_coding_agent/profiles.py +239 -0
  70. local_coding_agent/repository_tools.py +782 -0
  71. local_coding_agent/ripgrep.py +326 -0
  72. local_coding_agent/semantic_linter.py +122 -0
  73. local_coding_agent/service.py +405 -0
  74. local_coding_agent/session_events.py +684 -0
  75. local_coding_agent/session_query.py +578 -0
  76. local_coding_agent/skill_config.py +426 -0
  77. local_coding_agent/smoke.py +268 -0
  78. local_coding_agent/speculative_racing.py +85 -0
  79. local_coding_agent/spill.py +258 -0
  80. local_coding_agent/stats.py +255 -0
  81. local_coding_agent/stdio.py +132 -0
  82. local_coding_agent/subagent.py +516 -0
  83. local_coding_agent/task.py +90 -0
  84. local_coding_agent/task_store.py +142 -0
  85. local_coding_agent/tasks.py +240 -0
  86. local_coding_agent/terminal/__init__.py +35 -0
  87. local_coding_agent/terminal/_manager.py +156 -0
  88. local_coding_agent/terminal/_process.py +148 -0
  89. local_coding_agent/terminal/_session.py +452 -0
  90. local_coding_agent/terminal/_tools.py +233 -0
  91. local_coding_agent/terminal/_types.py +60 -0
  92. local_coding_agent/tool_call_parser.py +178 -0
  93. local_coding_agent/validators.py +563 -0
  94. local_coding_agent/vram_fit.py +240 -0
  95. local_coding_agent/worker_pool.py +516 -0
  96. local_coding_agent-1.1.0.dist-info/METADATA +202 -0
  97. local_coding_agent-1.1.0.dist-info/RECORD +101 -0
  98. local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
  99. local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
  100. local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
  101. local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,326 @@
1
+ """Fast file search and content grep using ripgrep with stdlib fallback.
2
+
3
+ Adapted from DeepSeek Harness @deepseek-ai/dsh-tool-fs-search.
4
+ Provides RipgrepMatch structured results, glob filtering, regex and literal search,
5
+ and resilient stdlib traversal fallback when `rg` is not available on PATH.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import fnmatch
11
+ import json
12
+ import os
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class RipgrepMatch:
22
+ """A single match line found in a search."""
23
+
24
+ file: str
25
+ line_number: int
26
+ line_content: str
27
+
28
+
29
+ IGNORED_DIRECTORIES: frozenset[str] = frozenset(
30
+ {
31
+ ".git",
32
+ ".hg",
33
+ ".svn",
34
+ "__pycache__",
35
+ ".pytest_cache",
36
+ ".venv",
37
+ "venv",
38
+ "env",
39
+ "node_modules",
40
+ ".tox",
41
+ ".local_agent",
42
+ ".mypy_cache",
43
+ ".ruff_cache",
44
+ "dist",
45
+ "build",
46
+ }
47
+ )
48
+
49
+
50
+ def _is_binary_file(file_path: Path) -> bool:
51
+ """Check whether a file appears to be binary by inspecting initial bytes."""
52
+ try:
53
+ with open(file_path, "rb") as f:
54
+ chunk = f.read(1024)
55
+ return b"\x00" in chunk
56
+ except OSError:
57
+ return True
58
+
59
+
60
+ def _match_globs(rel_path_str: str, file_name: str, globs: list[str] | None) -> bool:
61
+ """Check whether relative path and filename satisfy glob inclusion/exclusion rules."""
62
+ if not globs:
63
+ return True
64
+
65
+ positive_globs = [g for g in globs if not g.startswith("!")]
66
+ negative_globs = [g[1:] for g in globs if g.startswith("!")]
67
+
68
+ # Check exclusions first
69
+ for neg in negative_globs:
70
+ if fnmatch.fnmatch(rel_path_str, neg) or fnmatch.fnmatch(file_name, neg):
71
+ return False
72
+
73
+ # Check positive globs if any are specified
74
+ if positive_globs:
75
+ matched = any(
76
+ fnmatch.fnmatch(rel_path_str, pos) or fnmatch.fnmatch(file_name, pos)
77
+ for pos in positive_globs
78
+ )
79
+ if not matched:
80
+ return False
81
+
82
+ return True
83
+
84
+
85
+ def _stdlib_search(
86
+ query: str,
87
+ root_path: Path,
88
+ globs: list[str] | None = None,
89
+ is_regex: bool = False,
90
+ case_sensitive: bool = False,
91
+ max_results: int = 100,
92
+ ) -> list[RipgrepMatch]:
93
+ """Pure-Python fallback for grep search across repository files."""
94
+ flags = 0 if case_sensitive else re.IGNORECASE
95
+ try:
96
+ pattern = re.compile(query if is_regex else re.escape(query), flags)
97
+ except re.error:
98
+ # Invalid regex fallback
99
+ return []
100
+
101
+ results: list[RipgrepMatch] = []
102
+
103
+ if root_path.is_file():
104
+ candidates = [root_path]
105
+ else:
106
+ candidates = []
107
+ for dirpath, dirnames, filenames in os.walk(root_path):
108
+ # Prune ignored directory trees in-place
109
+ dirnames[:] = [d for d in dirnames if d not in IGNORED_DIRECTORIES]
110
+ for fname in filenames:
111
+ file_p = Path(dirpath) / fname
112
+ candidates.append(file_p)
113
+
114
+ for file_path in candidates:
115
+ if not file_path.is_file():
116
+ continue
117
+
118
+ try:
119
+ rel_path = file_path.relative_to(root_path)
120
+ rel_str = rel_path.as_posix()
121
+ except ValueError:
122
+ rel_str = file_path.name
123
+
124
+ if not _match_globs(rel_str, file_path.name, globs):
125
+ continue
126
+
127
+ if _is_binary_file(file_path):
128
+ continue
129
+
130
+ try:
131
+ with open(file_path, "r", encoding="utf-8", errors="replace") as f:
132
+ for line_idx, line in enumerate(f, start=1):
133
+ if pattern.search(line):
134
+ clean_line = line.rstrip("\r\n")
135
+ results.append(
136
+ RipgrepMatch(
137
+ file=rel_str,
138
+ line_number=line_idx,
139
+ line_content=clean_line,
140
+ )
141
+ )
142
+ if len(results) >= max_results:
143
+ return results
144
+ except OSError:
145
+ continue
146
+
147
+ return results
148
+
149
+
150
+ def _stdlib_files(
151
+ pattern: str,
152
+ root_path: Path,
153
+ max_results: int = 100,
154
+ ) -> list[str]:
155
+ """Pure-Python fallback for listing matching files."""
156
+ matched_files: list[str] = []
157
+ has_pattern = bool(pattern and pattern != "*")
158
+
159
+ for dirpath, dirnames, filenames in os.walk(root_path):
160
+ dirnames[:] = [d for d in dirnames if d not in IGNORED_DIRECTORIES]
161
+ for fname in sorted(filenames):
162
+ full_path = Path(dirpath) / fname
163
+ try:
164
+ rel_path = full_path.relative_to(root_path)
165
+ rel_str = rel_path.as_posix()
166
+ except ValueError:
167
+ rel_str = full_path.name
168
+
169
+ if has_pattern:
170
+ if not (fnmatch.fnmatch(rel_str, pattern) or fnmatch.fnmatch(fname, pattern)):
171
+ continue
172
+
173
+ matched_files.append(rel_str)
174
+ if len(matched_files) >= max_results:
175
+ return matched_files
176
+
177
+ return matched_files
178
+
179
+
180
+ def ripgrep_search(
181
+ query: str,
182
+ root: Path | str,
183
+ globs: list[str] | None = None,
184
+ is_regex: bool = False,
185
+ case_sensitive: bool = False,
186
+ max_results: int = 100,
187
+ _force_fallback: bool = False,
188
+ ) -> list[RipgrepMatch]:
189
+ """Perform a fast text or regex search across files under root.
190
+
191
+ Uses `rg` when available on PATH, falling back to stdlib search seamlessly.
192
+ """
193
+ root_path = Path(root).resolve()
194
+ if not root_path.exists():
195
+ return []
196
+
197
+ rg_bin = None if _force_fallback else shutil.which("rg")
198
+
199
+ if rg_bin:
200
+ cmd: list[str] = [rg_bin, "--json"]
201
+ if not case_sensitive:
202
+ cmd.append("-i")
203
+ else:
204
+ cmd.append("-s")
205
+
206
+ if not is_regex:
207
+ cmd.append("-F")
208
+
209
+ if globs:
210
+ for g in globs:
211
+ cmd.extend(["-g", g])
212
+
213
+ cmd.extend(["--max-count", str(max_results)])
214
+ cmd.extend(["--", query, str(root_path)])
215
+
216
+ try:
217
+ proc = subprocess.run(
218
+ cmd,
219
+ capture_output=True,
220
+ text=True,
221
+ encoding="utf-8",
222
+ errors="replace",
223
+ cwd=str(root_path if root_path.is_dir() else root_path.parent),
224
+ check=False,
225
+ )
226
+
227
+ if proc.returncode in (0, 1):
228
+ matches: list[RipgrepMatch] = []
229
+ for line in proc.stdout.splitlines():
230
+ if not line.strip():
231
+ continue
232
+ try:
233
+ entry = json.loads(line)
234
+ except json.JSONDecodeError:
235
+ continue
236
+
237
+ if entry.get("type") == "match":
238
+ data = entry.get("data", {})
239
+ path_text = data.get("path", {}).get("text", "")
240
+ file_p = Path(path_text)
241
+ try:
242
+ rel_str = file_p.resolve().relative_to(root_path).as_posix()
243
+ except ValueError:
244
+ rel_str = file_p.name or path_text
245
+
246
+ line_num = data.get("line_number", 0)
247
+ line_text = data.get("lines", {}).get("text", "").rstrip("\r\n")
248
+ matches.append(
249
+ RipgrepMatch(
250
+ file=rel_str,
251
+ line_number=line_num,
252
+ line_content=line_text,
253
+ )
254
+ )
255
+ if len(matches) >= max_results:
256
+ break
257
+ return matches
258
+ except (OSError, subprocess.SubprocessError):
259
+ pass
260
+
261
+ return _stdlib_search(
262
+ query=query,
263
+ root_path=root_path,
264
+ globs=globs,
265
+ is_regex=is_regex,
266
+ case_sensitive=case_sensitive,
267
+ max_results=max_results,
268
+ )
269
+
270
+
271
+ def ripgrep_files(
272
+ pattern: str,
273
+ root: Path | str,
274
+ max_results: int = 100,
275
+ _force_fallback: bool = False,
276
+ ) -> list[str]:
277
+ """Find files matching a pattern under root.
278
+
279
+ Uses `rg --files` when available, falling back to stdlib search seamlessly.
280
+ """
281
+ root_path = Path(root).resolve()
282
+ if not root_path.exists():
283
+ return []
284
+
285
+ rg_bin = None if _force_fallback else shutil.which("rg")
286
+
287
+ if rg_bin:
288
+ cmd: list[str] = [rg_bin, "--files"]
289
+ if pattern and pattern != "*":
290
+ cmd.extend(["-g", pattern])
291
+ cmd.extend(["--", str(root_path)])
292
+
293
+ try:
294
+ proc = subprocess.run(
295
+ cmd,
296
+ capture_output=True,
297
+ text=True,
298
+ encoding="utf-8",
299
+ errors="replace",
300
+ cwd=str(root_path if root_path.is_dir() else root_path.parent),
301
+ check=False,
302
+ )
303
+
304
+ if proc.returncode in (0, 1):
305
+ files: list[str] = []
306
+ for line in proc.stdout.splitlines():
307
+ cleaned = line.strip()
308
+ if not cleaned:
309
+ continue
310
+ file_p = Path(cleaned)
311
+ try:
312
+ rel_str = file_p.resolve().relative_to(root_path).as_posix()
313
+ except ValueError:
314
+ rel_str = file_p.name or cleaned
315
+ files.append(rel_str)
316
+ if len(files) >= max_results:
317
+ break
318
+ return files
319
+ except (OSError, subprocess.SubprocessError):
320
+ pass
321
+
322
+ return _stdlib_files(
323
+ pattern=pattern,
324
+ root_path=root_path,
325
+ max_results=max_results,
326
+ )
@@ -0,0 +1,122 @@
1
+ """Semantic Linter & Fast Pre-Test Prescriptions Engine (R18).
2
+
3
+ Sub-50ms static analysis pre-gate catching syntax, indentation, and structural
4
+ errors before executing heavy unit test runners, translating diagnostics into
5
+ deterministic pinpointed prescriptions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import tempfile
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Sequence
15
+
16
+ from .validators import apply_patch
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class LinterDiagnostic:
21
+ file: str
22
+ line: int | None
23
+ message: str
24
+ rule: str = "syntax"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class LinterReport:
29
+ valid: bool
30
+ diagnostics: tuple[LinterDiagnostic, ...] = field(default_factory=tuple)
31
+ prescriptions: tuple[str, ...] = field(default_factory=tuple)
32
+
33
+
34
+ def lint_source_code(filename: str, source_code: str) -> LinterReport:
35
+ """Run fast in-memory static linting on a single file."""
36
+ diagnostics: list[LinterDiagnostic] = []
37
+ prescriptions: list[str] = []
38
+
39
+ path = Path(filename)
40
+ if path.suffix == ".py":
41
+ try:
42
+ ast.parse(source_code, filename=filename)
43
+ except SyntaxError as exc:
44
+ line_no = exc.lineno
45
+ msg = exc.msg or "Syntax error"
46
+ diag = LinterDiagnostic(file=filename, line=line_no, message=msg, rule="SyntaxError")
47
+ diagnostics.append(diag)
48
+ prescriptions.append(
49
+ f"Синтаксическая ошибка в {filename}:{line_no}: {msg}. Проверьте двоеточия, скобки и отступы."
50
+ )
51
+ except Exception as exc:
52
+ diag = LinterDiagnostic(file=filename, line=None, message=str(exc), rule="ParseError")
53
+ diagnostics.append(diag)
54
+ prescriptions.append(f"Ошибка синтаксического анализа в {filename}: {exc}")
55
+
56
+ return LinterReport(
57
+ valid=len(diagnostics) == 0,
58
+ diagnostics=tuple(diagnostics),
59
+ prescriptions=tuple(prescriptions),
60
+ )
61
+
62
+
63
+ def lint_patch_in_memory(workspace_root: str, patch_content: str) -> LinterReport:
64
+ """Simulate patch application and lint all modified files."""
65
+ if not patch_content.strip():
66
+ return LinterReport(valid=True)
67
+
68
+ with tempfile.TemporaryDirectory() as tmp_dir:
69
+ tmp_root = Path(tmp_dir)
70
+ # Copy modified files from workspace_root to tmp_root
71
+ ws_root = Path(workspace_root).resolve()
72
+
73
+ # Parse target files from patch
74
+ target_files: set[str] = set()
75
+ for line in patch_content.splitlines():
76
+ if line.startswith("--- a/") or line.startswith("+++ b/"):
77
+ fname = line[6:].strip()
78
+ if fname and fname != "/dev/null":
79
+ p = Path(fname)
80
+ if ".." in p.parts or p.is_absolute():
81
+ diag = LinterDiagnostic(file=fname, line=None, message="Path escapes workspace", rule="PathTraversal")
82
+ return LinterReport(
83
+ valid=False,
84
+ diagnostics=(diag,),
85
+ prescriptions=(f"Патч содержит недопустимый путь '{fname}', выходящий за пределы рабочей области",),
86
+ )
87
+ target_files.add(fname)
88
+
89
+ for rel_file in target_files:
90
+ src_file = ws_root / rel_file
91
+ dst_file = tmp_root / rel_file
92
+ if src_file.exists():
93
+ dst_file.parent.mkdir(parents=True, exist_ok=True)
94
+ dst_file.write_bytes(src_file.read_bytes())
95
+
96
+ # Apply patch to tmp_root
97
+ applied, detail = apply_patch(str(tmp_root), patch_content)
98
+ if not applied:
99
+ diag = LinterDiagnostic(file="patch", line=None, message=detail, rule="GitApplyFailed")
100
+ return LinterReport(
101
+ valid=False,
102
+ diagnostics=(diag,),
103
+ prescriptions=(f"Патч не может быть применён к рабочей области: {detail}",),
104
+ )
105
+
106
+ # Lint all target files
107
+ all_diags: list[LinterDiagnostic] = []
108
+ all_prescriptions: list[str] = []
109
+ for rel_file in target_files:
110
+ target_path = tmp_root / rel_file
111
+ if target_path.exists() and target_path.is_file():
112
+ content = target_path.read_text(encoding="utf-8", errors="replace")
113
+ report = lint_source_code(rel_file, content)
114
+ if not report.valid:
115
+ all_diags.extend(report.diagnostics)
116
+ all_prescriptions.extend(report.prescriptions)
117
+
118
+ return LinterReport(
119
+ valid=len(all_diags) == 0,
120
+ diagnostics=tuple(all_diags),
121
+ prescriptions=tuple(all_prescriptions),
122
+ )