lcode-cli 0.18.0__py3-none-any.whl → 0.19.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.
- lcode/__init__.py +1 -1
- lcode/acp.py +635 -0
- lcode/agent.py +11 -0
- lcode/cli.py +12 -0
- lcode/permissions.py +5 -3
- lcode/planning.py +5 -0
- lcode/tools.py +20 -5
- {lcode_cli-0.18.0.dist-info → lcode_cli-0.19.0.dist-info}/METADATA +3 -2
- {lcode_cli-0.18.0.dist-info → lcode_cli-0.19.0.dist-info}/RECORD +12 -11
- {lcode_cli-0.18.0.dist-info → lcode_cli-0.19.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.18.0.dist-info → lcode_cli-0.19.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.18.0.dist-info → lcode_cli-0.19.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/acp.py
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
"""`lcode acp`: lcode as the agent inside an editor, over the Agent Client Protocol (ACP, version 1).
|
|
2
|
+
|
|
3
|
+
The editor starts `lcode acp` and talks JSON-RPC 2.0 over its stdin and stdout, one message per
|
|
4
|
+
line. The editor shows what happens:
|
|
5
|
+
- the model's answers and thinking stream as message chunks;
|
|
6
|
+
- each tool call is a tool call with its kind, files and, for edits, a diff;
|
|
7
|
+
- permission requests and plan approvals use the editor's own dialog;
|
|
8
|
+
- the todo list becomes the editor's plan;
|
|
9
|
+
- lcode's permission modes are the session's modes.
|
|
10
|
+
|
|
11
|
+
Files are read from and written through the editor's buffers when it supports that, so the model
|
|
12
|
+
sees unsaved changes and the editor tracks the edits. Everything else (tools, checkpoints, MCP
|
|
13
|
+
servers, memory) works as in the terminal.
|
|
14
|
+
|
|
15
|
+
Nothing but protocol messages may reach stdout: lcode's own output is discarded (or written to
|
|
16
|
+
the file in LCODE_ACP_LOG), and stdin is the protocol channel only.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import base64
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import threading
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import IO, TYPE_CHECKING, Any
|
|
29
|
+
from urllib.parse import unquote, urlparse
|
|
30
|
+
|
|
31
|
+
from rich.console import Console
|
|
32
|
+
|
|
33
|
+
from lcode import __version__
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from lcode.agent import Agent
|
|
37
|
+
|
|
38
|
+
PROTOCOL_VERSION = 1
|
|
39
|
+
MODES = [
|
|
40
|
+
("ask", "Ask", "Ask before changing files or running commands"),
|
|
41
|
+
("auto-edit", "Auto-edit", "Change files without asking; ask before commands"),
|
|
42
|
+
("plan", "Plan", "Look around and plan; change nothing until you approve the plan"),
|
|
43
|
+
("yolo", "Yolo", "Never ask"),
|
|
44
|
+
]
|
|
45
|
+
KINDS = {
|
|
46
|
+
"read_file": "read", "view_image": "read", "list_dir": "search", "glob": "search", "grep": "search",
|
|
47
|
+
"repo_map": "search", "search_code": "search", "lsp": "search", "edit_file": "edit", "write_file": "edit",
|
|
48
|
+
"bash": "execute", "web_search": "fetch", "web_fetch": "fetch", "agent": "think", "todo_write": "think",
|
|
49
|
+
"present_plan": "switch_mode",
|
|
50
|
+
} # fmt: skip
|
|
51
|
+
STOP = {"success": "end_turn", "max_steps": "max_turn_requests", "interrupted": "cancelled"}
|
|
52
|
+
MAX_INLINE = 200_000 # bytes of a file the editor attaches that go into the prompt
|
|
53
|
+
METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL_ERROR, NOT_FOUND = -32601, -32602, -32603, -32002
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RpcError(Exception):
|
|
57
|
+
def __init__(self, code: int, message: str):
|
|
58
|
+
super().__init__(message)
|
|
59
|
+
self.code = code
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ----------------------------------------------------------------------------- JSON-RPC over stdio
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Connection:
|
|
66
|
+
"""Newline-delimited JSON-RPC 2.0, both ways: the editor's requests and lcode's own."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, inp: IO[bytes], out: IO[bytes]):
|
|
69
|
+
self.inp, self.out = inp, out
|
|
70
|
+
self.lock = threading.Lock()
|
|
71
|
+
self.pending: dict[int, tuple[threading.Event, dict]] = {}
|
|
72
|
+
self.next_id = 0
|
|
73
|
+
self.closed = False
|
|
74
|
+
|
|
75
|
+
def send(self, message: dict) -> None:
|
|
76
|
+
data = (json.dumps({"jsonrpc": "2.0", **message}, ensure_ascii=False) + "\n").encode()
|
|
77
|
+
with self.lock:
|
|
78
|
+
self.out.write(data)
|
|
79
|
+
self.out.flush()
|
|
80
|
+
|
|
81
|
+
def notify(self, method: str, params: dict) -> None:
|
|
82
|
+
self.send({"method": method, "params": params})
|
|
83
|
+
|
|
84
|
+
def request(self, method: str, params: dict) -> dict:
|
|
85
|
+
"""Ask the editor and wait for its answer."""
|
|
86
|
+
with self.lock:
|
|
87
|
+
self.next_id += 1
|
|
88
|
+
request_id = self.next_id
|
|
89
|
+
done, slot = threading.Event(), {}
|
|
90
|
+
self.pending[request_id] = (done, slot)
|
|
91
|
+
self.send({"id": request_id, "method": method, "params": params})
|
|
92
|
+
done.wait()
|
|
93
|
+
if "error" in slot:
|
|
94
|
+
error = slot["error"] or {}
|
|
95
|
+
raise RpcError(int(error.get("code", INTERNAL_ERROR)), str(error.get("message", "the editor failed")))
|
|
96
|
+
return slot.get("result") or {}
|
|
97
|
+
|
|
98
|
+
def serve(self, handle) -> None:
|
|
99
|
+
"""Read messages until the editor closes stdin; requests are handled in threads of their own."""
|
|
100
|
+
for raw in self.inp:
|
|
101
|
+
line = raw.strip()
|
|
102
|
+
if not line:
|
|
103
|
+
continue
|
|
104
|
+
try:
|
|
105
|
+
message = json.loads(line)
|
|
106
|
+
except ValueError:
|
|
107
|
+
self.send({"id": None, "error": {"code": -32700, "message": "parse error"}})
|
|
108
|
+
continue
|
|
109
|
+
if "method" in message:
|
|
110
|
+
threading.Thread(target=self._dispatch, args=(handle, message), daemon=True).start()
|
|
111
|
+
elif message.get("id") in self.pending:
|
|
112
|
+
done, slot = self.pending.pop(message["id"])
|
|
113
|
+
slot.update({k: message[k] for k in ("result", "error") if k in message})
|
|
114
|
+
done.set()
|
|
115
|
+
self.closed = True
|
|
116
|
+
for done, slot in list(self.pending.values()): # nobody will answer now
|
|
117
|
+
slot["error"] = {"code": INTERNAL_ERROR, "message": "the editor closed the connection"}
|
|
118
|
+
done.set()
|
|
119
|
+
|
|
120
|
+
def _dispatch(self, handle, message: dict) -> None:
|
|
121
|
+
request_id = message.get("id")
|
|
122
|
+
try:
|
|
123
|
+
result = handle(message["method"], message.get("params") or {})
|
|
124
|
+
if request_id is not None:
|
|
125
|
+
self.send({"id": request_id, "result": result if result is not None else {}})
|
|
126
|
+
except RpcError as e:
|
|
127
|
+
if request_id is not None:
|
|
128
|
+
self.send({"id": request_id, "error": {"code": e.code, "message": str(e)}})
|
|
129
|
+
except Exception as e: # never leave the editor waiting
|
|
130
|
+
if request_id is not None:
|
|
131
|
+
self.send({"id": request_id, "error": {"code": INTERNAL_ERROR, "message": f"{type(e).__name__}: {e}"}})
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ----------------------------------------------------------------------------- one editor session
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def file_path(uri: str) -> Path | None:
|
|
138
|
+
parsed = urlparse(uri)
|
|
139
|
+
if parsed.scheme in ("", "file"):
|
|
140
|
+
return Path(unquote(parsed.path)) if parsed.path else None
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def call_title(agent: Agent, name: str, args: dict) -> str:
|
|
145
|
+
path = args.get("path")
|
|
146
|
+
rel = agent.tools.rel(agent.tools.resolve(path)) if isinstance(path, str) and path else ""
|
|
147
|
+
titles = {
|
|
148
|
+
"read_file": f"Read {rel}",
|
|
149
|
+
"list_dir": f"List {rel or '.'}",
|
|
150
|
+
"glob": f"Find {args.get('pattern', '')}",
|
|
151
|
+
"grep": f"Search for {args.get('pattern', '')}",
|
|
152
|
+
"edit_file": f"Edit {rel}",
|
|
153
|
+
"write_file": f"Write {rel}",
|
|
154
|
+
"bash": str(args.get("command", "")).strip()[:200],
|
|
155
|
+
"web_search": f"Search the web for {args.get('query', '')}",
|
|
156
|
+
"web_fetch": f"Fetch {args.get('url', '')}",
|
|
157
|
+
"todo_write": "Update the plan",
|
|
158
|
+
"present_plan": f"Plan: {args.get('title', '')}",
|
|
159
|
+
"agent": f"Subagent: {args.get('description') or args.get('type', '')}",
|
|
160
|
+
}
|
|
161
|
+
return titles.get(name) or agent.describe_call(name, args)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class Session:
|
|
165
|
+
"""An lcode agent wired to the editor: its output becomes session/update notifications."""
|
|
166
|
+
|
|
167
|
+
def __init__(self, server: Server, agent: Agent):
|
|
168
|
+
self.server, self.agent, self.id = server, agent, agent.session_id
|
|
169
|
+
self.conn = server.conn
|
|
170
|
+
self.before: dict[str, str | None] = {} # file contents before an edit, by tool call id
|
|
171
|
+
self.mode = agent.perms.mode
|
|
172
|
+
agent.cancel = threading.Event()
|
|
173
|
+
agent.on_delta = self.delta
|
|
174
|
+
agent.on_event = self.event
|
|
175
|
+
agent.on_tool = self.started
|
|
176
|
+
agent.perms.approve = self.permission
|
|
177
|
+
agent.plan_review = self.review_plan
|
|
178
|
+
caps = server.client_caps.get("fs") or {}
|
|
179
|
+
if caps.get("readTextFile"):
|
|
180
|
+
agent.tools.reader = self.read_buffer
|
|
181
|
+
if caps.get("writeTextFile"):
|
|
182
|
+
agent.tools.writer = self.write_buffer
|
|
183
|
+
|
|
184
|
+
def update(self, update: dict) -> None:
|
|
185
|
+
self.conn.notify("session/update", {"sessionId": self.id, "update": update})
|
|
186
|
+
|
|
187
|
+
# -- the model's output
|
|
188
|
+
def delta(self, kind: str, text: str) -> None:
|
|
189
|
+
chunk = "agent_message_chunk" if kind == "text" else "agent_thought_chunk"
|
|
190
|
+
self.update({"sessionUpdate": chunk, "content": {"type": "text", "text": text}})
|
|
191
|
+
|
|
192
|
+
def call(self, call_id: str, name: str, args: dict, status: str) -> dict:
|
|
193
|
+
update: dict[str, Any] = {
|
|
194
|
+
"toolCallId": call_id,
|
|
195
|
+
"title": call_title(self.agent, name, args),
|
|
196
|
+
"kind": KINDS.get(name, "other"),
|
|
197
|
+
"status": status,
|
|
198
|
+
"rawInput": args,
|
|
199
|
+
}
|
|
200
|
+
path = args.get("path")
|
|
201
|
+
if isinstance(path, str) and path:
|
|
202
|
+
location: dict[str, Any] = {"path": str(self.agent.tools.resolve(path))}
|
|
203
|
+
if isinstance(args.get("offset"), int):
|
|
204
|
+
location["line"] = args["offset"]
|
|
205
|
+
update["locations"] = [location]
|
|
206
|
+
diff = self.proposed_diff(call_id, name, args)
|
|
207
|
+
if diff:
|
|
208
|
+
update["content"] = [diff]
|
|
209
|
+
return update
|
|
210
|
+
|
|
211
|
+
def proposed_diff(self, call_id: str, name: str, args: dict) -> dict | None:
|
|
212
|
+
"""What an edit will change, as the editor's diff: the whole file before and after."""
|
|
213
|
+
if name not in ("edit_file", "write_file") or not isinstance(args.get("path"), str):
|
|
214
|
+
return None
|
|
215
|
+
path = self.agent.tools.resolve(args["path"])
|
|
216
|
+
try:
|
|
217
|
+
old = self.agent.tools.read_text(path) if path.is_file() else None
|
|
218
|
+
except OSError:
|
|
219
|
+
old = None
|
|
220
|
+
if name == "write_file":
|
|
221
|
+
new = str(args.get("content", ""))
|
|
222
|
+
else:
|
|
223
|
+
find, replace = str(args.get("old_string", "")), str(args.get("new_string", ""))
|
|
224
|
+
if old is None or not find or find not in old:
|
|
225
|
+
return {"type": "diff", "path": str(path), "oldText": find, "newText": replace}
|
|
226
|
+
new = old.replace(find, replace) if args.get("replace_all") else old.replace(find, replace, 1)
|
|
227
|
+
self.before[call_id] = old
|
|
228
|
+
return {"type": "diff", "path": str(path), "oldText": old, "newText": new}
|
|
229
|
+
|
|
230
|
+
def event(self, event: dict) -> None:
|
|
231
|
+
if event["type"] == "assistant":
|
|
232
|
+
for call in event["tool_calls"]:
|
|
233
|
+
self.update(
|
|
234
|
+
{"sessionUpdate": "tool_call", **self.call(call["id"], call["name"], call["arguments"], "pending")}
|
|
235
|
+
)
|
|
236
|
+
elif event["type"] == "tool_result":
|
|
237
|
+
update: dict[str, Any] = {
|
|
238
|
+
"sessionUpdate": "tool_call_update",
|
|
239
|
+
"toolCallId": event["id"],
|
|
240
|
+
"status": "failed" if event["error"] else "completed",
|
|
241
|
+
"rawOutput": {"output": event["output"]},
|
|
242
|
+
}
|
|
243
|
+
content = [{"type": "content", "content": {"type": "text", "text": event["output"]}}]
|
|
244
|
+
if event["name"] in ("edit_file", "write_file") and not event["error"]:
|
|
245
|
+
path = next((c for c in self.pending_paths(event["id"])), None)
|
|
246
|
+
if path is not None:
|
|
247
|
+
try:
|
|
248
|
+
after = path.read_text(errors="replace")
|
|
249
|
+
content = [{"type": "diff", "path": str(path), "oldText": self.before.pop(event["id"], None),
|
|
250
|
+
"newText": after}] # fmt: skip
|
|
251
|
+
except OSError:
|
|
252
|
+
pass
|
|
253
|
+
update["content"] = content
|
|
254
|
+
self.update(update)
|
|
255
|
+
if event["name"] == "todo_write":
|
|
256
|
+
self.send_plan()
|
|
257
|
+
if self.agent.perms.mode != self.mode: # an approved plan switched the mode
|
|
258
|
+
self.mode = self.agent.perms.mode
|
|
259
|
+
self.update({"sessionUpdate": "current_mode_update", "currentModeId": self.mode})
|
|
260
|
+
|
|
261
|
+
def pending_paths(self, call_id: str):
|
|
262
|
+
for message in reversed(self.agent.messages):
|
|
263
|
+
for call in message.get("tool_calls") or []:
|
|
264
|
+
if call.get("id") == call_id:
|
|
265
|
+
args = call.get("function", {}).get("arguments") or {}
|
|
266
|
+
if isinstance(args, dict) and isinstance(args.get("path"), str):
|
|
267
|
+
yield self.agent.tools.resolve(args["path"])
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
def started(self, name: str, args: dict) -> None:
|
|
271
|
+
self.update(
|
|
272
|
+
{"sessionUpdate": "tool_call_update", "toolCallId": self.agent.current_call, "status": "in_progress"}
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def send_plan(self) -> None:
|
|
276
|
+
entries = [
|
|
277
|
+
{"content": str(t.get("content", "")), "priority": "medium",
|
|
278
|
+
"status": t.get("status") if t.get("status") in ("pending", "in_progress", "completed") else "pending"}
|
|
279
|
+
for t in self.agent.tools.todos
|
|
280
|
+
] # fmt: skip
|
|
281
|
+
self.update({"sessionUpdate": "plan", "entries": entries})
|
|
282
|
+
|
|
283
|
+
# -- asking the user, in the editor
|
|
284
|
+
def ask(self, tool_call: dict, options: list[dict]) -> str | None:
|
|
285
|
+
"""The option the user picked, or None if the request was cancelled."""
|
|
286
|
+
try:
|
|
287
|
+
reply = self.conn.request(
|
|
288
|
+
"session/request_permission", {"sessionId": self.id, "toolCall": tool_call, "options": options}
|
|
289
|
+
)
|
|
290
|
+
except RpcError:
|
|
291
|
+
return None
|
|
292
|
+
outcome = reply.get("outcome") or {}
|
|
293
|
+
return outcome.get("optionId") if outcome.get("outcome") == "selected" else None
|
|
294
|
+
|
|
295
|
+
def permission(self, request: dict) -> bool | str:
|
|
296
|
+
call_id = self.agent.current_call
|
|
297
|
+
name = next(
|
|
298
|
+
(c.get("function", {}).get("name", "") for m in reversed(self.agent.messages)
|
|
299
|
+
for c in m.get("tool_calls") or [] if c.get("id") == call_id), "",
|
|
300
|
+
) # fmt: skip
|
|
301
|
+
args = next(
|
|
302
|
+
(c.get("function", {}).get("arguments") or {} for m in reversed(self.agent.messages)
|
|
303
|
+
for c in m.get("tool_calls") or [] if c.get("id") == call_id), {},
|
|
304
|
+
) # fmt: skip
|
|
305
|
+
tool_call = self.call(call_id, name, args if isinstance(args, dict) else {}, "pending") if name else {
|
|
306
|
+
"toolCallId": call_id or "permission", "title": request["title"], "kind": "other", "status": "pending"
|
|
307
|
+
} # fmt: skip
|
|
308
|
+
tool_call["title"] = request["title"]
|
|
309
|
+
scope = "file edits" if request["kind"] == "edit" else request["key"].split(":", 1)[-1]
|
|
310
|
+
choice = self.ask(
|
|
311
|
+
tool_call,
|
|
312
|
+
[
|
|
313
|
+
{"optionId": "allow", "name": "Allow", "kind": "allow_once"},
|
|
314
|
+
{"optionId": "always", "name": f"Always allow {scope} in this session", "kind": "allow_always"},
|
|
315
|
+
{"optionId": "reject", "name": "Reject", "kind": "reject_once"},
|
|
316
|
+
],
|
|
317
|
+
)
|
|
318
|
+
if choice == "always":
|
|
319
|
+
self.agent.perms.always.add("edit" if request["kind"] == "edit" else request["key"])
|
|
320
|
+
if choice in ("allow", "always"):
|
|
321
|
+
return True
|
|
322
|
+
if choice is None and self.agent.cancel is not None and self.agent.cancel.is_set():
|
|
323
|
+
return "The user denied this: they cancelled the request."
|
|
324
|
+
return "The user denied this in the editor. Ask how to proceed, or choose a different approach."
|
|
325
|
+
|
|
326
|
+
def review_plan(self, title: str, plan: str) -> str:
|
|
327
|
+
tool_call = {
|
|
328
|
+
"toolCallId": self.agent.current_call or "plan",
|
|
329
|
+
"title": f"Plan: {title}",
|
|
330
|
+
"kind": "switch_mode",
|
|
331
|
+
"status": "pending",
|
|
332
|
+
"content": [{"type": "content", "content": {"type": "text", "text": plan}}],
|
|
333
|
+
}
|
|
334
|
+
choice = self.ask(
|
|
335
|
+
tool_call,
|
|
336
|
+
[
|
|
337
|
+
{"optionId": "ask", "name": "Approve, and ask before changes", "kind": "allow_once"},
|
|
338
|
+
{"optionId": "auto-edit", "name": "Approve, and change files without asking", "kind": "allow_always"},
|
|
339
|
+
{"optionId": "keep", "name": "Keep planning", "kind": "reject_once"},
|
|
340
|
+
],
|
|
341
|
+
)
|
|
342
|
+
if choice in ("ask", "auto-edit"):
|
|
343
|
+
return choice
|
|
344
|
+
return "The user wants to keep planning. Ask what they'd like to change in the plan; don't change anything yet."
|
|
345
|
+
|
|
346
|
+
# -- the editor's buffers
|
|
347
|
+
def read_buffer(self, path: Path) -> str | None:
|
|
348
|
+
try:
|
|
349
|
+
return self.conn.request("fs/read_text_file", {"sessionId": self.id, "path": str(path)}).get("content")
|
|
350
|
+
except RpcError:
|
|
351
|
+
return None # not open in the editor, or not readable there: lcode reads the file itself
|
|
352
|
+
|
|
353
|
+
def write_buffer(self, path: Path, content: str) -> None:
|
|
354
|
+
try:
|
|
355
|
+
self.conn.request("fs/write_text_file", {"sessionId": self.id, "path": str(path), "content": content})
|
|
356
|
+
except RpcError:
|
|
357
|
+
pass # lcode writes the file itself
|
|
358
|
+
|
|
359
|
+
# -- requests
|
|
360
|
+
def prompt(self, blocks: list[dict]) -> dict:
|
|
361
|
+
from lcode import api
|
|
362
|
+
|
|
363
|
+
text = self.render(blocks)
|
|
364
|
+
if not text.strip():
|
|
365
|
+
return {"stopReason": "end_turn"}
|
|
366
|
+
command = self.command(text)
|
|
367
|
+
if command == "":
|
|
368
|
+
return {"stopReason": "end_turn"}
|
|
369
|
+
if command is not None:
|
|
370
|
+
text = command
|
|
371
|
+
self.agent.cancel.clear()
|
|
372
|
+
result = api.run_request(self.agent, text)
|
|
373
|
+
self.update({"sessionUpdate": "usage_update", "used": self.agent.ctx_used, "size": self.agent.settings.context})
|
|
374
|
+
self.update({"sessionUpdate": "session_info_update", "title": self.agent.session_title or None})
|
|
375
|
+
if result.status == "error":
|
|
376
|
+
raise RpcError(INTERNAL_ERROR, result.error)
|
|
377
|
+
return {"stopReason": STOP.get(result.status, "end_turn")}
|
|
378
|
+
|
|
379
|
+
def command(self, text: str) -> str | None:
|
|
380
|
+
"""A /command from the editor: the user's own commands, and /review."""
|
|
381
|
+
if not text.startswith("/"):
|
|
382
|
+
return None
|
|
383
|
+
name, _, arg = text[1:].partition(" ")
|
|
384
|
+
commands = self.agent.extensions().commands
|
|
385
|
+
if name in commands:
|
|
386
|
+
return commands[name].render(arg.strip())
|
|
387
|
+
if name == "review":
|
|
388
|
+
from lcode import gitflow
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
prompt = gitflow.review_prompt(self.agent, arg.strip())
|
|
392
|
+
except gitflow.GitError as e:
|
|
393
|
+
self.delta("text", f"Nothing to review: {e}")
|
|
394
|
+
return ""
|
|
395
|
+
self.agent.no_changes = "This is a review, so nothing can be changed: report what should change instead."
|
|
396
|
+
return prompt
|
|
397
|
+
return None
|
|
398
|
+
|
|
399
|
+
def render(self, blocks: list[dict]) -> str:
|
|
400
|
+
"""The editor's prompt (text, attached files, images) as one request for lcode."""
|
|
401
|
+
parts = []
|
|
402
|
+
for block in blocks:
|
|
403
|
+
kind = block.get("type")
|
|
404
|
+
if kind == "text":
|
|
405
|
+
parts.append(str(block.get("text", "")))
|
|
406
|
+
elif kind == "resource_link":
|
|
407
|
+
parts.append(self.attached(block.get("uri", ""), None))
|
|
408
|
+
elif kind == "resource":
|
|
409
|
+
resource = block.get("resource") or {}
|
|
410
|
+
parts.append(self.attached(resource.get("uri", ""), resource.get("text")))
|
|
411
|
+
elif kind == "image" and block.get("data"):
|
|
412
|
+
parts.append(self.image(block["data"], block.get("mimeType", "image/png")))
|
|
413
|
+
return "\n\n".join(p for p in parts if p)
|
|
414
|
+
|
|
415
|
+
def attached(self, uri: str, text: str | None) -> str:
|
|
416
|
+
path = file_path(uri)
|
|
417
|
+
where = self.agent.tools.rel(path) if path else uri
|
|
418
|
+
if text is None and path is not None and path.is_file() and path.stat().st_size <= MAX_INLINE:
|
|
419
|
+
text = self.agent.tools.read_text(path)
|
|
420
|
+
if text is None:
|
|
421
|
+
return f"[The user points at {where}]"
|
|
422
|
+
return f"[The user attached {where}]\n```\n{text[:MAX_INLINE].rstrip(chr(10))}\n```"
|
|
423
|
+
|
|
424
|
+
def image(self, data: str, mime: str) -> str:
|
|
425
|
+
from lcode import config
|
|
426
|
+
|
|
427
|
+
raw = base64.b64decode(data)
|
|
428
|
+
folder = config.STATE_DIR / "acp-images"
|
|
429
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
430
|
+
path = folder / f"{hashlib.sha256(raw).hexdigest()[:16]}.{mime.split('/')[-1].split('+')[0] or 'png'}"
|
|
431
|
+
path.write_bytes(raw)
|
|
432
|
+
return f"@{path}"
|
|
433
|
+
|
|
434
|
+
def replay(self) -> None:
|
|
435
|
+
"""A loaded session's conversation, sent to the editor as it happened."""
|
|
436
|
+
results: dict[str, str] = {}
|
|
437
|
+
names: dict[str, str] = {}
|
|
438
|
+
for message in self.agent.messages[1:]:
|
|
439
|
+
role, content = message.get("role"), message.get("content") or ""
|
|
440
|
+
if role == "user" and content:
|
|
441
|
+
self.update({"sessionUpdate": "user_message_chunk", "content": {"type": "text", "text": content}})
|
|
442
|
+
elif role == "assistant":
|
|
443
|
+
if content:
|
|
444
|
+
self.update({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": content}})
|
|
445
|
+
for i, call in enumerate(message.get("tool_calls") or []):
|
|
446
|
+
call.setdefault("id", f"call_loaded_{len(names)}_{i}")
|
|
447
|
+
fn = call.get("function", {})
|
|
448
|
+
args = fn.get("arguments") if isinstance(fn.get("arguments"), dict) else {}
|
|
449
|
+
names[call["id"]] = fn.get("name", "")
|
|
450
|
+
update = {"sessionUpdate": "tool_call", "toolCallId": call["id"],
|
|
451
|
+
"title": call_title(self.agent, fn.get("name", ""), args),
|
|
452
|
+
"kind": KINDS.get(fn.get("name", ""), "other"), "status": "completed", "rawInput": args} # fmt: skip
|
|
453
|
+
self.update(update)
|
|
454
|
+
elif role == "tool":
|
|
455
|
+
call_id = message.get("tool_call_id") or next((i for i in reversed(names) if i not in results), "")
|
|
456
|
+
if call_id:
|
|
457
|
+
results[call_id] = content
|
|
458
|
+
self.update({"sessionUpdate": "tool_call_update", "toolCallId": call_id, "status": "completed",
|
|
459
|
+
"content": [{"type": "content", "content": {"type": "text", "text": content[:4000]}}]}) # fmt: skip
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# ----------------------------------------------------------------------------- the agent side
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class Server:
|
|
466
|
+
def __init__(self, conn: Connection, console: Console):
|
|
467
|
+
self.conn, self.console = conn, console
|
|
468
|
+
self.client_caps: dict = {}
|
|
469
|
+
self.sessions: dict[str, Session] = {}
|
|
470
|
+
|
|
471
|
+
def handle(self, method: str, params: dict) -> dict | None:
|
|
472
|
+
handlers = {
|
|
473
|
+
"initialize": self.initialize,
|
|
474
|
+
"authenticate": lambda p: {},
|
|
475
|
+
"session/new": self.new_session,
|
|
476
|
+
"session/load": self.load_session,
|
|
477
|
+
"session/prompt": self.prompt,
|
|
478
|
+
"session/cancel": self.cancel,
|
|
479
|
+
"session/set_mode": self.set_mode,
|
|
480
|
+
}
|
|
481
|
+
if method not in handlers:
|
|
482
|
+
raise RpcError(METHOD_NOT_FOUND, f"lcode doesn't support {method}")
|
|
483
|
+
return handlers[method](params)
|
|
484
|
+
|
|
485
|
+
def initialize(self, params: dict) -> dict:
|
|
486
|
+
self.client_caps = params.get("clientCapabilities") or {}
|
|
487
|
+
return {
|
|
488
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
489
|
+
"agentCapabilities": {
|
|
490
|
+
"loadSession": True,
|
|
491
|
+
"promptCapabilities": {"image": True, "audio": False, "embeddedContext": True},
|
|
492
|
+
"mcpCapabilities": {"http": True, "sse": False},
|
|
493
|
+
},
|
|
494
|
+
"agentInfo": {"name": "lcode", "title": "lcode", "version": __version__},
|
|
495
|
+
"authMethods": [],
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
def open(self, params: dict) -> Session:
|
|
499
|
+
from lcode import api
|
|
500
|
+
|
|
501
|
+
cwd = Path(str(params.get("cwd") or ""))
|
|
502
|
+
if not cwd.is_absolute():
|
|
503
|
+
raise RpcError(INVALID_PARAMS, "cwd must be an absolute path")
|
|
504
|
+
try:
|
|
505
|
+
agent = api.open_agent(cwd, api.Options(interactive=False), self.console)
|
|
506
|
+
except api.SetupError as e:
|
|
507
|
+
raise RpcError(INTERNAL_ERROR, str(e)) from e
|
|
508
|
+
agent.mcp = self.mcp(cwd, params.get("mcpServers") or [], agent)
|
|
509
|
+
session = Session(self, agent)
|
|
510
|
+
self.sessions[session.id] = session
|
|
511
|
+
return session
|
|
512
|
+
|
|
513
|
+
def mcp(self, cwd: Path, editor_servers: list[dict], agent: Agent):
|
|
514
|
+
"""lcode's MCP servers, plus the ones the editor passes."""
|
|
515
|
+
from lcode import config
|
|
516
|
+
from lcode.mcp import config as mcp_config
|
|
517
|
+
from lcode.mcp.manager import McpManager
|
|
518
|
+
|
|
519
|
+
project = mcp_config.project_file(cwd)
|
|
520
|
+
servers, problems = mcp_config.load_all(cwd, include_project=bool(project and mcp_config.is_approved(project)))
|
|
521
|
+
names = {s.name for s in servers}
|
|
522
|
+
for entry in editor_servers:
|
|
523
|
+
name = str(entry.get("name") or "editor")
|
|
524
|
+
if name in names:
|
|
525
|
+
continue
|
|
526
|
+
if entry.get("url"):
|
|
527
|
+
raw = {
|
|
528
|
+
"type": "http",
|
|
529
|
+
"url": entry["url"],
|
|
530
|
+
"headers": {h["name"]: h["value"] for h in entry.get("headers") or []},
|
|
531
|
+
}
|
|
532
|
+
else:
|
|
533
|
+
raw = {"command": entry.get("command", ""), "args": entry.get("args") or [],
|
|
534
|
+
"env": {e["name"]: e["value"] for e in entry.get("env") or []}} # fmt: skip
|
|
535
|
+
server = mcp_config.parse(name, raw, source="editor")
|
|
536
|
+
if server.error:
|
|
537
|
+
problems.append(f"{name}: {server.error}")
|
|
538
|
+
else:
|
|
539
|
+
servers.append(server)
|
|
540
|
+
for problem in problems:
|
|
541
|
+
self.console.print(f"MCP: {problem}")
|
|
542
|
+
if not servers:
|
|
543
|
+
return None
|
|
544
|
+
manager = McpManager(cwd, servers, config.load()["mcp_tools"])
|
|
545
|
+
manager.start()
|
|
546
|
+
return manager
|
|
547
|
+
|
|
548
|
+
def modes(self, session: Session) -> dict:
|
|
549
|
+
return {
|
|
550
|
+
"currentModeId": session.agent.perms.mode,
|
|
551
|
+
"availableModes": [{"id": i, "name": n, "description": d} for i, n, d in MODES],
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
def new_session(self, params: dict) -> dict:
|
|
555
|
+
session = self.open(params)
|
|
556
|
+
self.commands(session)
|
|
557
|
+
return {"sessionId": session.id, "modes": self.modes(session)}
|
|
558
|
+
|
|
559
|
+
def load_session(self, params: dict) -> dict:
|
|
560
|
+
from lcode import sessions
|
|
561
|
+
|
|
562
|
+
session = self.open(params)
|
|
563
|
+
wanted = str(params.get("sessionId", ""))
|
|
564
|
+
found = next((s for s in sessions.list_sessions(session.agent.cwd, limit=1000) if s.id == wanted), None)
|
|
565
|
+
if found is None:
|
|
566
|
+
self.sessions.pop(session.id, None)
|
|
567
|
+
raise RpcError(NOT_FOUND, f"no saved lcode session {wanted} in {session.agent.cwd}")
|
|
568
|
+
self.sessions.pop(session.id, None)
|
|
569
|
+
session.agent.load(found)
|
|
570
|
+
session.id = session.agent.session_id
|
|
571
|
+
self.sessions[session.id] = session
|
|
572
|
+
session.replay()
|
|
573
|
+
self.commands(session)
|
|
574
|
+
return {"modes": self.modes(session)}
|
|
575
|
+
|
|
576
|
+
def commands(self, session: Session) -> None:
|
|
577
|
+
available = [{"name": "review", "description": "Review the uncommitted changes, or the branch against a base",
|
|
578
|
+
"input": {"hint": "base branch (optional)"}}] # fmt: skip
|
|
579
|
+
for name, command in session.agent.extensions().commands.items():
|
|
580
|
+
if name != "review":
|
|
581
|
+
entry: dict = {"name": name, "description": command.description}
|
|
582
|
+
if command.argument_hint:
|
|
583
|
+
entry["input"] = {"hint": command.argument_hint}
|
|
584
|
+
available.append(entry)
|
|
585
|
+
session.update({"sessionUpdate": "available_commands_update", "availableCommands": available})
|
|
586
|
+
|
|
587
|
+
def session(self, params: dict) -> Session:
|
|
588
|
+
session = self.sessions.get(str(params.get("sessionId", "")))
|
|
589
|
+
if session is None:
|
|
590
|
+
raise RpcError(NOT_FOUND, f"no session {params.get('sessionId')}")
|
|
591
|
+
return session
|
|
592
|
+
|
|
593
|
+
def prompt(self, params: dict) -> dict:
|
|
594
|
+
session = self.session(params)
|
|
595
|
+
try:
|
|
596
|
+
return session.prompt(params.get("prompt") or [])
|
|
597
|
+
finally:
|
|
598
|
+
session.agent.no_changes = ""
|
|
599
|
+
|
|
600
|
+
def cancel(self, params: dict) -> None:
|
|
601
|
+
session = self.sessions.get(str(params.get("sessionId", "")))
|
|
602
|
+
if session is not None and session.agent.cancel is not None:
|
|
603
|
+
session.agent.cancel.set()
|
|
604
|
+
from lcode import subagents
|
|
605
|
+
|
|
606
|
+
if session.agent.response is not None:
|
|
607
|
+
subagents.abort(session.agent.response) # stop the model's answer now
|
|
608
|
+
return None
|
|
609
|
+
|
|
610
|
+
def set_mode(self, params: dict) -> dict:
|
|
611
|
+
session = self.session(params)
|
|
612
|
+
mode = str(params.get("modeId", ""))
|
|
613
|
+
if mode not in {m[0] for m in MODES}:
|
|
614
|
+
raise RpcError(INVALID_PARAMS, f"unknown mode {mode}")
|
|
615
|
+
session.agent.perms.mode = session.mode = mode
|
|
616
|
+
return {}
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def main() -> int:
|
|
620
|
+
"""Serve one editor over stdin and stdout until it disconnects."""
|
|
621
|
+
inp, out = sys.stdin.buffer, sys.stdout.buffer
|
|
622
|
+
sys.stdout = sys.stderr # a stray print must not corrupt the protocol
|
|
623
|
+
# Open for the life of the process:
|
|
624
|
+
sys.stdin = open(os.devnull) # noqa: SIM115 # nothing may wait for typed input: stdin is the protocol
|
|
625
|
+
log = os.environ.get("LCODE_ACP_LOG")
|
|
626
|
+
output = open(log, "a") if log else open(os.devnull, "w") # noqa: SIM115 # lcode's own output
|
|
627
|
+
console = Console(file=output, width=120, force_terminal=False)
|
|
628
|
+
conn = Connection(inp, out)
|
|
629
|
+
server = Server(conn, console)
|
|
630
|
+
conn.serve(server.handle)
|
|
631
|
+
for session in server.sessions.values():
|
|
632
|
+
from lcode import api
|
|
633
|
+
|
|
634
|
+
api.close_agent(session.agent)
|
|
635
|
+
return 0
|
lcode/agent.py
CHANGED
|
@@ -257,6 +257,10 @@ class Agent:
|
|
|
257
257
|
self.cancel: threading.Event | None = None # set from another thread to stop
|
|
258
258
|
self.on_tool = None # called with (name, arguments) before each tool runs
|
|
259
259
|
self.on_event: Callable[[dict], None] | None = None # each step and tool result, for --output stream-json
|
|
260
|
+
self.on_delta: Callable[[str, str], None] | None = None # ("text" or "thinking", piece) as it streams
|
|
261
|
+
self.current_call = "" # id of the tool call that's running, so a permission request can name it
|
|
262
|
+
# Decides on a presented plan instead of asking in the terminal: "ask", "auto-edit", or why not.
|
|
263
|
+
self.plan_review: Callable[[str, str], str] | None = None
|
|
260
264
|
self.max_steps = MAX_STEPS_PER_TURN
|
|
261
265
|
self.turn_status = "" # how the last request ended: success, max_steps
|
|
262
266
|
self.response = None # the streaming response, so another thread can abort it
|
|
@@ -649,6 +653,8 @@ class Agent:
|
|
|
649
653
|
msg = chunk.get("message", {})
|
|
650
654
|
if msg.get("thinking"):
|
|
651
655
|
thinking += msg["thinking"]
|
|
656
|
+
if self.on_delta:
|
|
657
|
+
self.on_delta("thinking", msg["thinking"])
|
|
652
658
|
progress.update("Thinking", msg["thinking"])
|
|
653
659
|
if self.settings.show_thinking:
|
|
654
660
|
stop_spinner() # raw text is printed as it streams
|
|
@@ -659,6 +665,8 @@ class Agent:
|
|
|
659
665
|
self.console.print("\n")
|
|
660
666
|
printed_thinking = False
|
|
661
667
|
content += msg["content"]
|
|
668
|
+
if self.on_delta:
|
|
669
|
+
self.on_delta("text", msg["content"])
|
|
662
670
|
progress.update("Writing", msg["content"])
|
|
663
671
|
# Finished markdown blocks print above the spinner, which keeps running meanwhile.
|
|
664
672
|
start_spinner()
|
|
@@ -667,6 +675,8 @@ class Agent:
|
|
|
667
675
|
tool_calls.extend(msg["tool_calls"])
|
|
668
676
|
if chunk.get("done"):
|
|
669
677
|
final = chunk
|
|
678
|
+
if self.cancel is not None and self.cancel.is_set():
|
|
679
|
+
raise KeyboardInterrupt # cancelled while the answer streamed: its end never came
|
|
670
680
|
except KeyboardInterrupt:
|
|
671
681
|
stop_spinner()
|
|
672
682
|
md.flush()
|
|
@@ -833,6 +843,7 @@ class Agent:
|
|
|
833
843
|
raise
|
|
834
844
|
for i, call in enumerate(calls):
|
|
835
845
|
name, args = call.get("function", {}).get("name", ""), call_arguments(call)
|
|
846
|
+
self.current_call = call.get("id", "")
|
|
836
847
|
if self.on_tool:
|
|
837
848
|
self.on_tool(name, args)
|
|
838
849
|
if name not in ("bash", "todo_write", "web_search", "web_fetch", "agent"): # those print their own
|
lcode/cli.py
CHANGED
|
@@ -774,6 +774,11 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
|
|
|
774
774
|
from lcode.mcp.commands import build_parser as mcp_parser
|
|
775
775
|
|
|
776
776
|
subs["mcp"] = mcp_parser()
|
|
777
|
+
subs["acp"] = argparse.ArgumentParser(
|
|
778
|
+
prog="lcode acp",
|
|
779
|
+
description="Run as the agent inside an editor, over the Agent Client Protocol on stdin and stdout "
|
|
780
|
+
"(Zed and other ACP editors start it for you; see the docs).",
|
|
781
|
+
)
|
|
777
782
|
subs["action"] = argparse.ArgumentParser(
|
|
778
783
|
prog="lcode action",
|
|
779
784
|
description="Run as a GitHub Action on a self-hosted runner: answer @lcode in issues and pull requests, "
|
|
@@ -782,6 +787,12 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
|
|
|
782
787
|
return subs
|
|
783
788
|
|
|
784
789
|
|
|
790
|
+
def cmd_acp(args) -> None:
|
|
791
|
+
from lcode import acp
|
|
792
|
+
|
|
793
|
+
sys.exit(acp.main())
|
|
794
|
+
|
|
795
|
+
|
|
785
796
|
def cmd_action(args) -> None:
|
|
786
797
|
from lcode import action
|
|
787
798
|
|
|
@@ -851,6 +862,7 @@ COMMANDS = {
|
|
|
851
862
|
"mcp": cmd_mcp,
|
|
852
863
|
"index": cmd_index,
|
|
853
864
|
"action": cmd_action,
|
|
865
|
+
"acp": cmd_acp,
|
|
854
866
|
}
|
|
855
867
|
|
|
856
868
|
|
lcode/permissions.py
CHANGED
|
@@ -76,7 +76,8 @@ class Permissions:
|
|
|
76
76
|
self.always: set[str] = set()
|
|
77
77
|
self.rules = Rules() # allow and deny lists from the settings (lcode.hooks)
|
|
78
78
|
self.on_prompt = None # called with the title before lcode asks the user (notification hooks)
|
|
79
|
-
|
|
79
|
+
# Decides instead of asking (lcode.api, lcode acp): True, False, or a message saying why not.
|
|
80
|
+
self.approve: Callable[[dict], bool | str] | None = None
|
|
80
81
|
|
|
81
82
|
def rule(self, kind: str, target: str) -> tuple[bool, str] | None:
|
|
82
83
|
"""A rule's verdict on an action: (allowed, message for the model), or None when no rule matches."""
|
|
@@ -109,9 +110,10 @@ class Permissions:
|
|
|
109
110
|
target = target or key.split(":", 1)[-1]
|
|
110
111
|
if kind == "bash":
|
|
111
112
|
target = LEADING_CD.sub("", target) # the command itself, without `cd <folder> &&`
|
|
112
|
-
|
|
113
|
+
verdict = self.approve({"kind": kind, "title": title, "target": target, "key": key})
|
|
114
|
+
if verdict is True:
|
|
113
115
|
return True, ""
|
|
114
|
-
return False, NOT_ASKED
|
|
116
|
+
return False, verdict if isinstance(verdict, str) and verdict else NOT_ASKED
|
|
115
117
|
if self.on_prompt:
|
|
116
118
|
self.on_prompt(title)
|
|
117
119
|
self.console.print(Panel(body, title=title, title_align="left", border_style="yellow"))
|
lcode/planning.py
CHANGED
|
@@ -92,6 +92,11 @@ def present(agent: Agent, title: str, plan: str) -> str:
|
|
|
92
92
|
plan = plan.strip()
|
|
93
93
|
if not plan:
|
|
94
94
|
return "Error: the plan is empty. Write the steps, the files, the risks and how you'll verify the work."
|
|
95
|
+
if agent.plan_review is not None: # an editor shows the plan (lcode acp)
|
|
96
|
+
choice = agent.plan_review(title, plan)
|
|
97
|
+
if choice in ("ask", "auto-edit"):
|
|
98
|
+
return approve(agent, title, plan, choice, False)
|
|
99
|
+
return choice or "The user didn't approve the plan. Ask them what they'd like to change."
|
|
95
100
|
edited = False
|
|
96
101
|
while True:
|
|
97
102
|
c.print(Panel(Markdown(plan), title=f"Plan: {escape(title)}", title_align="left", border_style="cyan"))
|
lcode/tools.py
CHANGED
|
@@ -15,6 +15,7 @@ import signal
|
|
|
15
15
|
import subprocess
|
|
16
16
|
import threading
|
|
17
17
|
import time
|
|
18
|
+
from collections.abc import Callable
|
|
18
19
|
from pathlib import Path
|
|
19
20
|
from typing import TYPE_CHECKING
|
|
20
21
|
|
|
@@ -366,6 +367,9 @@ class Toolbox:
|
|
|
366
367
|
self.agent = agent
|
|
367
368
|
self.read_mtimes: dict[str, float] = {}
|
|
368
369
|
self.todos: list[dict] = []
|
|
370
|
+
# An editor's buffers (lcode acp): read unsaved changes and write through the editor.
|
|
371
|
+
self.reader: Callable[[Path], str | None] | None = None
|
|
372
|
+
self.writer: Callable[[Path, str], None] | None = None
|
|
369
373
|
|
|
370
374
|
@property
|
|
371
375
|
def console(self):
|
|
@@ -611,7 +615,7 @@ class Toolbox:
|
|
|
611
615
|
raise ToolError(f"{path} is an image; look at it with view_image")
|
|
612
616
|
if is_binary(p):
|
|
613
617
|
raise ToolError(f"{path} is a binary file ({p.stat().st_size} bytes)")
|
|
614
|
-
lines =
|
|
618
|
+
lines = self.read_text(p).splitlines()
|
|
615
619
|
offset, limit = max(1, int(offset or 1)), max(1, int(limit or 2000))
|
|
616
620
|
chunk = lines[offset - 1 : offset - 1 + limit]
|
|
617
621
|
self.read_mtimes[str(p)] = p.stat().st_mtime
|
|
@@ -704,9 +708,20 @@ class Toolbox:
|
|
|
704
708
|
diff = diff[:12_000] + "\n... (diff truncated for display)\n"
|
|
705
709
|
return Syntax(diff or "(no changes)", "diff", theme="monokai", word_wrap=True)
|
|
706
710
|
|
|
711
|
+
def read_text(self, p: Path) -> str:
|
|
712
|
+
"""A file's text: the editor's buffer when lcode runs inside one, else the file on disk."""
|
|
713
|
+
if self.reader is not None:
|
|
714
|
+
text = self.reader(p)
|
|
715
|
+
if text is not None:
|
|
716
|
+
return text
|
|
717
|
+
return p.read_text(errors="replace")
|
|
718
|
+
|
|
707
719
|
def _write(self, p: Path, content: str) -> None:
|
|
708
720
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
709
|
-
|
|
721
|
+
if self.writer is not None:
|
|
722
|
+
self.writer(p, content) # the editor updates its buffer (and the file)
|
|
723
|
+
if not p.exists() or p.read_text(errors="replace") != content:
|
|
724
|
+
p.write_text(content) # the file on disk must match, for checkpoints and the tools
|
|
710
725
|
self.read_mtimes[str(p)] = p.stat().st_mtime
|
|
711
726
|
|
|
712
727
|
def t_write_file(self, path: str, content: str) -> str:
|
|
@@ -716,7 +731,7 @@ class Toolbox:
|
|
|
716
731
|
if p.is_dir():
|
|
717
732
|
raise ToolError(f"{path} is a directory")
|
|
718
733
|
self._check_fresh(p)
|
|
719
|
-
body = self._diff(p,
|
|
734
|
+
body = self._diff(p, self.read_text(p), content)
|
|
720
735
|
else:
|
|
721
736
|
lines = content.splitlines()
|
|
722
737
|
preview = "\n".join(lines[:60]) + (f"\n... ({len(lines) - 60} more lines)" if len(lines) > 60 else "")
|
|
@@ -725,7 +740,7 @@ class Toolbox:
|
|
|
725
740
|
ok, feedback = self.agent.perms.request("edit", "edit", f"{verb} {self.rel(p)}", body, self.project_rel(p))
|
|
726
741
|
if not ok:
|
|
727
742
|
return feedback
|
|
728
|
-
before =
|
|
743
|
+
before = self.read_text(p) if exists else None
|
|
729
744
|
self.agent.checkpoint()
|
|
730
745
|
self._write(p, content)
|
|
731
746
|
n = len(content.splitlines())
|
|
@@ -741,7 +756,7 @@ class Toolbox:
|
|
|
741
756
|
raise ToolError("old_string and new_string are identical")
|
|
742
757
|
if not old_string:
|
|
743
758
|
raise ToolError("old_string is empty; use write_file to create or overwrite files")
|
|
744
|
-
text =
|
|
759
|
+
text = self.read_text(p)
|
|
745
760
|
count = text.count(old_string)
|
|
746
761
|
if count == 0:
|
|
747
762
|
new_text = fuzzy_replace(text, old_string, new_string)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.19.0
|
|
4
4
|
Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
|
|
5
5
|
Project-URL: Homepage, https://nasser1941.github.io/lcode/
|
|
6
6
|
Project-URL: Documentation, https://nasser1941.github.io/lcode/
|
|
@@ -104,6 +104,8 @@ through [Ollama](https://ollama.com), and when it needs current information it c
|
|
|
104
104
|
- **Git workflow.** `/commit` writes messages in your repository's style, `/review` checks the
|
|
105
105
|
changes with `file:line` findings, `/pr` opens the pull request, each after you approve it, and
|
|
106
106
|
`lcode --worktree` runs parallel sessions on one repository.
|
|
107
|
+
- **In your editor.** Zed, JetBrains IDEs, Neovim and other editors that speak the Agent Client
|
|
108
|
+
Protocol run lcode with `lcode acp`, with diffs and approvals in the editor.
|
|
107
109
|
- **Automation.** `lcode -p --output json` for scripts and CI, a Python API (`from lcode import
|
|
108
110
|
Session`), and a GitHub Action that answers `@lcode` in issues and pull requests on your own
|
|
109
111
|
self-hosted runner.
|
|
@@ -214,7 +216,6 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
|
|
|
214
216
|
|
|
215
217
|
See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
|
|
216
218
|
|
|
217
|
-
- [Editor integration through the Agent Client Protocol](https://github.com/nasser1941/lcode/issues/49)
|
|
218
219
|
- [Long-running work: finish notifications and background commands](https://github.com/nasser1941/lcode/issues/51)
|
|
219
220
|
- More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
|
|
220
221
|
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
lcode/__init__.py,sha256=
|
|
1
|
+
lcode/__init__.py,sha256=7yjbvkgWF_Vh6y72kETMa3SVkh4RP81rLJzvo11gIV8,473
|
|
2
2
|
lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
|
|
3
|
+
lcode/acp.py,sha256=M_CdTVqRSmLn33QD56Le4238Vz1ZxffnHCfjmajSv34,28940
|
|
3
4
|
lcode/action.py,sha256=1r3abjheYFYQ-EUArvotls8pLmE3YjNMGHAy_iF0Z70,16198
|
|
4
|
-
lcode/agent.py,sha256=
|
|
5
|
+
lcode/agent.py,sha256=pB7rDZIbWiRrc8Ye4rA_LzHVqJ36yoTUN6nFsUeg428,49664
|
|
5
6
|
lcode/api.py,sha256=kXlzXhGwkNdW9sWpxgOvL9h5pPzhzKRLXKrL1-eN3XY,15110
|
|
6
7
|
lcode/backends.py,sha256=dtJHrC6ys73bRDg64z0tQz1PavmeAkiODZu1fOO9ExQ,20635
|
|
7
8
|
lcode/bench.py,sha256=XVdk0hDxmRcjgROOCgwwOsExcUhIxgh0B2vnxtGnA3o,40742
|
|
8
9
|
lcode/bench_backend.py,sha256=X2fo-zRX92Xp3KMxSsFlWioc2vRupBTtowj5kLME8mw,14287
|
|
9
10
|
lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
|
|
10
11
|
lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
|
|
11
|
-
lcode/cli.py,sha256=
|
|
12
|
+
lcode/cli.py,sha256=zo2QTHrdxVaJwIjCEOsfR99byPqeCkKHE5p_Xh8kfb8,39471
|
|
12
13
|
lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
|
|
13
14
|
lcode/config.py,sha256=nFljzjHTFaCTTbagycagbh2X_PpLtsUs-yRODiy4QcU,10065
|
|
14
15
|
lcode/context.py,sha256=qJymLE3eYeaF6qVOzlUwDURqlky__8cskl_6vXeU3AA,10701
|
|
@@ -22,15 +23,15 @@ lcode/lsp.py,sha256=NtSW7xapv7fg2HAQLuYK1nwIqT7nG0LK1hiTpOmla_4,26086
|
|
|
22
23
|
lcode/memory.py,sha256=WZEHTp-lVxAwS-yyLQR2xB3TMZ9sejmX2Ivg7T-tl3w,21949
|
|
23
24
|
lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
|
|
24
25
|
lcode/ollama.py,sha256=u5tnvL28cfaFS4GhmiEsHhTQe6w3Ng_Tej05UUzLQTQ,7715
|
|
25
|
-
lcode/permissions.py,sha256=
|
|
26
|
-
lcode/planning.py,sha256=
|
|
26
|
+
lcode/permissions.py,sha256=i04YLtqYCxwApgawomPp4d35uk9HAJtpKY__gfw8j9s,6347
|
|
27
|
+
lcode/planning.py,sha256=mGXMU-khHyGHE8IHxxrsQc-FFOzbdRa4MWHMU19UR7o,6439
|
|
27
28
|
lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
|
|
28
29
|
lcode/repl.py,sha256=zJ2hj1c-tx35Sms8V-1W4XsyfFZ9omu9bZgGpEgBYi8,47655
|
|
29
30
|
lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
|
|
30
31
|
lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
|
|
31
32
|
lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
|
|
32
33
|
lcode/subagents.py,sha256=S_ZBpqnKFxRKhe-lE-I8SHc3yMOhe0zW-SD2K-Gwn28,28832
|
|
33
|
-
lcode/tools.py,sha256=
|
|
34
|
+
lcode/tools.py,sha256=Y4XmQsoLIA32S255DnCwOwebGPgQay1k7H_A9R5DINA,43145
|
|
34
35
|
lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
|
|
35
36
|
lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
|
|
36
37
|
lcode/mcp/__init__.py,sha256=XujtAmRoXW9S0uq1XPf_CAeCkHh_UvA8FCmoAKP9j1Y,171
|
|
@@ -48,8 +49,8 @@ lcode/mcp/stdio.py,sha256=kC6KHwzMMDjBr-yCZLmyVKS7PZXU299WpZdYRoX5eRQ,6292
|
|
|
48
49
|
lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
|
|
49
50
|
lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
|
|
50
51
|
lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
|
|
51
|
-
lcode_cli-0.
|
|
52
|
-
lcode_cli-0.
|
|
53
|
-
lcode_cli-0.
|
|
54
|
-
lcode_cli-0.
|
|
55
|
-
lcode_cli-0.
|
|
52
|
+
lcode_cli-0.19.0.dist-info/METADATA,sha256=cUJGqerCzNU65yzxUbn48STtIOP3fjvVbItArdo1vJ4,12642
|
|
53
|
+
lcode_cli-0.19.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
54
|
+
lcode_cli-0.19.0.dist-info/entry_points.txt,sha256=pMz2Byj2QRyt-ZZQYZvmWUtmKPCeE7f8iCvu-sjZPIo,133
|
|
55
|
+
lcode_cli-0.19.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
|
|
56
|
+
lcode_cli-0.19.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|