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,233 @@
1
+ """Model-facing terminal tool schemas and handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..terminal._manager import TerminalManager
8
+
9
+
10
+ # ============================================================================
11
+ # Model-Facing Tool Schemas and Handlers
12
+ # ============================================================================
13
+
14
+ def get_terminal_tool_schemas() -> list[dict[str, Any]]:
15
+ """Return JSON schemas for the 6 persistent terminal tools."""
16
+ return [
17
+ {
18
+ "name": "terminal_open",
19
+ "description": "Create a persistent interactive terminal session (e.g. bash, cmd, REPL) that survives across tool calls.",
20
+ "parameters": {
21
+ "type": "object",
22
+ "properties": {
23
+ "session_id": {
24
+ "type": "string",
25
+ "description": "Unique identifier for this terminal session (e.g. 'main', 'build', 'gdb').",
26
+ },
27
+ "cwd": {
28
+ "type": "string",
29
+ "description": "Initial working directory. Defaults to workspace root.",
30
+ },
31
+ "shell": {
32
+ "type": "string",
33
+ "description": "Shell or interactive command to launch (e.g. 'bash', 'powershell', 'python -i'). Defaults to system shell.",
34
+ },
35
+ },
36
+ "required": ["session_id"],
37
+ },
38
+ },
39
+ {
40
+ "name": "terminal_send",
41
+ "description": "Send text or commands to a persistent terminal and receive the resulting output delta.",
42
+ "parameters": {
43
+ "type": "object",
44
+ "properties": {
45
+ "session_id": {
46
+ "type": "string",
47
+ "description": "Session ID returned by terminal_open.",
48
+ },
49
+ "text": {
50
+ "type": "string",
51
+ "description": "Input text or command line to send.",
52
+ },
53
+ "wait_ms": {
54
+ "type": "integer",
55
+ "description": "Milliseconds to wait for output after sending (default 500).",
56
+ "default": 500,
57
+ },
58
+ "submit": {
59
+ "type": "boolean",
60
+ "description": "Whether to append a newline (Enter) after text (default true).",
61
+ "default": True,
62
+ },
63
+ },
64
+ "required": ["session_id", "text"],
65
+ },
66
+ },
67
+ {
68
+ "name": "terminal_read",
69
+ "description": "Read a bounded window from a terminal session's retained output buffer without sending input.",
70
+ "parameters": {
71
+ "type": "object",
72
+ "properties": {
73
+ "session_id": {
74
+ "type": "string",
75
+ "description": "Session ID to read from.",
76
+ },
77
+ "offset": {
78
+ "type": "integer",
79
+ "description": "Character offset to start reading from (default 0; negative offsets count from end).",
80
+ "default": 0,
81
+ },
82
+ "limit": {
83
+ "type": "integer",
84
+ "description": "Maximum number of characters to return (default 4096).",
85
+ "default": 4096,
86
+ },
87
+ },
88
+ "required": ["session_id"],
89
+ },
90
+ },
91
+ {
92
+ "name": "terminal_signal",
93
+ "description": "Deliver a control signal to the terminal (e.g. SIGINT/Ctrl+C, SIGTERM, SIGKILL).",
94
+ "parameters": {
95
+ "type": "object",
96
+ "properties": {
97
+ "session_id": {
98
+ "type": "string",
99
+ "description": "Target terminal session ID.",
100
+ },
101
+ "signal": {
102
+ "type": "string",
103
+ "description": "Signal to deliver ('SIGINT', 'SIGTERM', 'SIGKILL', etc.).",
104
+ "enum": ["SIGINT", "SIGTERM", "SIGKILL", "CTRL_C", "SIGBREAK"],
105
+ },
106
+ },
107
+ "required": ["session_id", "signal"],
108
+ },
109
+ },
110
+ {
111
+ "name": "terminal_list",
112
+ "description": "List all active persistent terminal sessions and their current status.",
113
+ "parameters": {
114
+ "type": "object",
115
+ "properties": {},
116
+ },
117
+ },
118
+ {
119
+ "name": "terminal_close",
120
+ "description": "Close a persistent terminal session and terminate its entire child process tree.",
121
+ "parameters": {
122
+ "type": "object",
123
+ "properties": {
124
+ "session_id": {
125
+ "type": "string",
126
+ "description": "Session ID to terminate and close.",
127
+ },
128
+ },
129
+ "required": ["session_id"],
130
+ },
131
+ },
132
+ ]
133
+
134
+
135
+ def execute_terminal_tool(
136
+ manager: TerminalManager,
137
+ name: str,
138
+ arguments: dict[str, Any],
139
+ ) -> dict[str, Any]:
140
+ """Execute a model-facing terminal tool by name with arguments."""
141
+ if name == "terminal_open":
142
+ session_id = arguments.get("session_id")
143
+ if not session_id:
144
+ return {"ok": False, "error": "session_id is required"}
145
+ cwd = arguments.get("cwd")
146
+ shell = arguments.get("shell")
147
+ try:
148
+ session = manager.create_session(session_id, cwd=cwd, shell=shell)
149
+ return {
150
+ "ok": True,
151
+ "session_id": session.session_id,
152
+ "pid": session.pid,
153
+ "cwd": str(session.cwd),
154
+ "shell": session.shell_cmd,
155
+ "status": "running",
156
+ }
157
+ except Exception as e:
158
+ return {"ok": False, "error": str(e)}
159
+
160
+ elif name == "terminal_send":
161
+ session_id = arguments.get("session_id")
162
+ text = arguments.get("text")
163
+ if not session_id or text is None:
164
+ return {"ok": False, "error": "session_id and text are required"}
165
+ wait_ms = int(arguments.get("wait_ms", 500))
166
+ submit = bool(arguments.get("submit", True))
167
+ try:
168
+ output = manager.send_input(session_id, str(text), wait_ms=wait_ms, submit=submit)
169
+ session = manager.get_session(session_id)
170
+ return {
171
+ "ok": True,
172
+ "session_id": session_id,
173
+ "output": output,
174
+ "alive": session.is_alive(),
175
+ "exit_code": session.exit_code,
176
+ }
177
+ except Exception as e:
178
+ return {"ok": False, "error": str(e)}
179
+
180
+ elif name == "terminal_read":
181
+ session_id = arguments.get("session_id")
182
+ if not session_id:
183
+ return {"ok": False, "error": "session_id is required"}
184
+ offset = int(arguments.get("offset", 0))
185
+ limit = max(0, int(arguments.get("limit", 4096)))
186
+ try:
187
+ output = manager.read_buffer(session_id, offset=offset, limit=limit)
188
+ session = manager.get_session(session_id)
189
+ return {
190
+ "ok": True,
191
+ "session_id": session_id,
192
+ "output": output,
193
+ "total_buffer_bytes": len(session.buffer.encode("utf-8")),
194
+ "offset": offset,
195
+ "limit": limit,
196
+ }
197
+ except Exception as e:
198
+ return {"ok": False, "error": str(e)}
199
+
200
+ elif name == "terminal_signal":
201
+ session_id = arguments.get("session_id")
202
+ sig = arguments.get("signal")
203
+ if not session_id or not sig:
204
+ return {"ok": False, "error": "session_id and signal are required"}
205
+ try:
206
+ delivered = manager.send_signal(session_id, str(sig))
207
+ return {
208
+ "ok": delivered,
209
+ "session_id": session_id,
210
+ "signal": sig,
211
+ "delivered": delivered,
212
+ }
213
+ except Exception as e:
214
+ return {"ok": False, "error": str(e)}
215
+
216
+ elif name == "terminal_list":
217
+ try:
218
+ sessions = manager.list_sessions()
219
+ return {"ok": True, "sessions": sessions}
220
+ except Exception as e:
221
+ return {"ok": False, "error": str(e)}
222
+
223
+ elif name == "terminal_close":
224
+ session_id = arguments.get("session_id")
225
+ if not session_id:
226
+ return {"ok": False, "error": "session_id is required"}
227
+ try:
228
+ manager.close_session(session_id)
229
+ return {"ok": True, "session_id": session_id, "closed": True}
230
+ except Exception as e:
231
+ return {"ok": False, "error": str(e)}
232
+
233
+ return {"ok": False, "error": f"Unknown tool: {name}"}
@@ -0,0 +1,60 @@
1
+ """Exceptions and data models for the terminal package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+
9
+ # ============================================================================
10
+ # Exceptions
11
+ # ============================================================================
12
+
13
+ class TerminalError(RuntimeError):
14
+ """Base exception for terminal failures."""
15
+
16
+
17
+ class TerminalSessionNotFoundError(TerminalError):
18
+ """Raised when a requested terminal session does not exist."""
19
+
20
+
21
+ class TerminalSessionExistsError(TerminalError):
22
+ """Raised when creating a session with an ID that is already active."""
23
+
24
+
25
+ class TerminalProcessExitedError(TerminalError):
26
+ """Raised when attempting to interact with a terminal process that has exited."""
27
+
28
+
29
+ class TerminalTimeoutError(TerminalError):
30
+ """Raised when a terminal operation times out."""
31
+
32
+
33
+ # ============================================================================
34
+ # Data Models
35
+ # ============================================================================
36
+
37
+ @dataclass(frozen=True)
38
+ class TerminalSessionInfo:
39
+ """Snapshot metadata describing a persistent terminal session."""
40
+
41
+ session_id: str
42
+ pid: int
43
+ alive: bool
44
+ exit_code: int | None
45
+ cwd: str
46
+ shell: str
47
+ buffer_size: int
48
+ created_at: float
49
+
50
+ def to_dict(self) -> dict[str, Any]:
51
+ return {
52
+ "session_id": self.session_id,
53
+ "pid": self.pid,
54
+ "alive": self.alive,
55
+ "exit_code": self.exit_code,
56
+ "cwd": self.cwd,
57
+ "shell": self.shell,
58
+ "buffer_size": self.buffer_size,
59
+ "created_at": self.created_at,
60
+ }
@@ -0,0 +1,178 @@
1
+ """Small-model tool-call emitters -> controller tool_call dicts.
2
+
3
+ Small local models often cannot emit a proper OpenAI ``tool_calls`` array and
4
+ instead spell a tool call inside their text using one of several ad-hoc
5
+ formats (qwen ``<tool_call>`` JSON, qwen XML ``<function=...>``,
6
+ llama-server ``<|python_tag|>``, mistral ``[TOOL_CALLS]``). This module
7
+ extracts those spans and reshapes them into the SAME dict shape the
8
+ controller's ``_decode_tool_call`` consumes (``{"function": {"name",
9
+ "arguments"}}``), so the controller can promote them into real tool calls.
10
+
11
+ Standalone on purpose (no controller import) to avoid a circular import; the
12
+ dict shape is duplicated here.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import re
19
+ from dataclasses import dataclass, field
20
+ from typing import Any
21
+
22
+
23
+ @dataclass
24
+ class ToolCallExtraction:
25
+ calls: list[dict[str, Any]] = field(default_factory=list)
26
+ remaining_text: str = ""
27
+ formats_detected: list[str] = field(default_factory=list)
28
+ errors: list[str] = field(default_factory=list)
29
+
30
+
31
+ _FORMATS: list[tuple[str, re.Pattern]] = [
32
+ ("<tool_call>", re.compile(r"<tool_call>(.*?)</tool_call>", re.S)),
33
+ ("llama_python_tag", re.compile(r"<\|python_tag\|>(.*?)(?=<|$)", re.S)),
34
+ ("qwen_xml", re.compile(r"<function=([^>]+)>(.*?)</function>", re.S)),
35
+ ("mistral_tool_calls", re.compile(r"\[TOOL_CALLS\]\s*(\[[^\]]*\])", re.S)),
36
+ ]
37
+
38
+ # <parameter=K>V</parameter> or <parameter name="K">V</parameter>
39
+ _PARAMETER_RE = re.compile(
40
+ r"<parameter\s*(?:name=\"([^\"]+)\"|=([^>]+))>(.*?)</parameter>", re.S
41
+ )
42
+
43
+
44
+ def _parse_argument(value: str) -> Any:
45
+ """Parse a qwen-XML parameter value as JSON when possible, else raw string."""
46
+ try:
47
+ return json.loads(value)
48
+ except (TypeError, ValueError):
49
+ return value
50
+
51
+
52
+ def _make_call(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
53
+ return {"function": {"name": name, "arguments": arguments}}
54
+
55
+
56
+ def _matches_allowed(name: str, allowed_names: list[str] | None) -> bool:
57
+ return not allowed_names or name in allowed_names
58
+
59
+
60
+ def _consume(text: str, spans: list[tuple[int, int]]) -> str:
61
+ """Drop the given [start, end) spans from text, keeping everything else."""
62
+ if not spans:
63
+ return text
64
+ spans = sorted(spans)
65
+ parts: list[str] = []
66
+ cursor = 0
67
+ for start, end in spans:
68
+ if start > cursor:
69
+ parts.append(text[cursor:start])
70
+ cursor = max(cursor, end)
71
+ parts.append(text[cursor:])
72
+ return "".join(parts)
73
+
74
+
75
+ def _extract_json_calls(
76
+ text: str,
77
+ body: str,
78
+ start: int,
79
+ end: int,
80
+ allowed_names: list[str] | None,
81
+ calls: list[dict[str, Any]],
82
+ spans: list[tuple[int, int]],
83
+ is_array: bool,
84
+ errors: list[str],
85
+ format_name: str,
86
+ ) -> None:
87
+ """Parse a JSON (object or array) body into calls; consume the span on success."""
88
+ try:
89
+ payload = json.loads(body)
90
+ except (TypeError, ValueError):
91
+ errors.append(f"{format_name}: malformed JSON tool call")
92
+ return # malformed -> span left in remaining_text, no call promoted
93
+ items = payload if is_array and isinstance(payload, list) else [payload]
94
+ if not items:
95
+ return
96
+ promoted: list[dict[str, Any]] = []
97
+ for index, item in enumerate(items):
98
+ if not isinstance(item, dict):
99
+ errors.append(f"{format_name}[{index}]: tool call must be an object")
100
+ continue
101
+ name = item.get("name")
102
+ arguments = item.get("arguments")
103
+ if not isinstance(name, str) or not name or not isinstance(arguments, dict):
104
+ errors.append(f"{format_name}[{index}]: name and object arguments are required")
105
+ continue
106
+ if not _matches_allowed(name, allowed_names):
107
+ return # not allowlisted: keep the whole span in remaining text
108
+ promoted.append(_make_call(name, arguments))
109
+ if promoted:
110
+ calls.extend(promoted)
111
+ spans.append((start, end))
112
+ elif is_array and isinstance(payload, list) and payload:
113
+ # array held only invalid items -> consume the span, but retain errors
114
+ spans.append((start, end))
115
+
116
+
117
+ def extract_tool_calls(
118
+ text: str, allowed_names: list[str] | None = None
119
+ ) -> ToolCallExtraction:
120
+ """Extract small-model tool calls from ``text``.
121
+
122
+ Returns a :class:`ToolCallExtraction`. Consumed spans are removed from
123
+ ``remaining_text``; a call whose name is not in ``allowed_names`` (when
124
+ provided) is left untouched in the text. Malformed input never raises —
125
+ worst case an empty ``calls`` list and the original text.
126
+ """
127
+ if not isinstance(text, str) or not text.strip():
128
+ return ToolCallExtraction(remaining_text=text or "", formats_detected=[])
129
+
130
+ calls: list[dict[str, Any]] = []
131
+ spans: list[tuple[int, int]] = []
132
+ formats_detected: list[str] = []
133
+ errors: list[str] = []
134
+
135
+ for fmt, pattern in _FORMATS:
136
+ matched = False
137
+ for m in pattern.finditer(text):
138
+ matched = True
139
+ start, end = m.span()
140
+ if fmt == "<tool_call>":
141
+ _extract_json_calls(
142
+ text, m.group(1), start, end, allowed_names, calls, spans, False,
143
+ errors, fmt,
144
+ )
145
+ elif fmt == "llama_python_tag":
146
+ _extract_json_calls(
147
+ text, m.group(1), start, end, allowed_names, calls, spans, False,
148
+ errors, fmt,
149
+ )
150
+ elif fmt == "mistral_tool_calls":
151
+ _extract_json_calls(
152
+ text, m.group(1), start, end, allowed_names, calls, spans, True,
153
+ errors, fmt,
154
+ )
155
+ elif fmt == "qwen_xml":
156
+ name = m.group(1).strip()
157
+ arguments: dict[str, Any] = {}
158
+ valid = bool(name)
159
+ for pm in _PARAMETER_RE.finditer(m.group(2)):
160
+ key = (pm.group(1) or pm.group(2) or "").strip()
161
+ if not key:
162
+ continue
163
+ arguments[key] = _parse_argument(pm.group(3))
164
+ if not valid or not _matches_allowed(name, allowed_names):
165
+ if not valid:
166
+ errors.append(f"{fmt}: tool function name is required")
167
+ continue # missing name -> skipped; not allowed -> kept in text
168
+ calls.append(_make_call(name, arguments))
169
+ spans.append((start, end))
170
+ if matched:
171
+ formats_detected.append(fmt)
172
+
173
+ return ToolCallExtraction(
174
+ calls=calls,
175
+ remaining_text=_consume(text, spans),
176
+ formats_detected=formats_detected,
177
+ errors=errors,
178
+ )