lcode-cli 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lcode/__init__.py +3 -0
- lcode/__main__.py +3 -0
- lcode/agent.py +376 -0
- lcode/catalog.py +114 -0
- lcode/cli.py +437 -0
- lcode/config.py +135 -0
- lcode/hardware.py +84 -0
- lcode/models.toml +128 -0
- lcode/ollama.py +163 -0
- lcode/permissions.py +90 -0
- lcode/render.py +43 -0
- lcode/repl.py +281 -0
- lcode/tools.py +533 -0
- lcode_cli-0.1.1.dist-info/METADATA +181 -0
- lcode_cli-0.1.1.dist-info/RECORD +18 -0
- lcode_cli-0.1.1.dist-info/WHEEL +4 -0
- lcode_cli-0.1.1.dist-info/entry_points.txt +2 -0
- lcode_cli-0.1.1.dist-info/licenses/LICENSE +22 -0
lcode/repl.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""The interactive prompt: input handling, slash commands and the status bar."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import threading
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from prompt_toolkit import PromptSession
|
|
12
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
13
|
+
from prompt_toolkit.formatted_text import HTML
|
|
14
|
+
from prompt_toolkit.history import FileHistory
|
|
15
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
16
|
+
from rich.console import Group
|
|
17
|
+
from rich.panel import Panel
|
|
18
|
+
from rich.rule import Rule
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
|
|
21
|
+
from lcode import __version__, catalog
|
|
22
|
+
from lcode.agent import INIT_PROMPT, Agent
|
|
23
|
+
from lcode.catalog import MIN_USEFUL_CONTEXT
|
|
24
|
+
from lcode.config import PERMISSION_MODES, STATE_DIR, ConfigError, format_tokens, parse_context
|
|
25
|
+
from lcode.hardware import Hardware
|
|
26
|
+
from lcode.ollama import OllamaError
|
|
27
|
+
from lcode.tools import IGNORE_DIRS
|
|
28
|
+
|
|
29
|
+
COMMANDS = {
|
|
30
|
+
"/help": "Show this help",
|
|
31
|
+
"/init": "Analyze the repo and write an AGENTS.md guide (loaded at every start)",
|
|
32
|
+
"/clear": "Start a fresh conversation",
|
|
33
|
+
"/compact": "Summarize the conversation to free context (optional: what to focus on)",
|
|
34
|
+
"/context": "Show context-window usage",
|
|
35
|
+
"/ctx": "Show or change the context window, e.g. /ctx 128k",
|
|
36
|
+
"/model": "Show or switch model, e.g. /model qwen3.5-9b",
|
|
37
|
+
"/models": "List models and how they fit this machine",
|
|
38
|
+
"/think": "Toggle model reasoning on/off",
|
|
39
|
+
"/verbose": "Toggle showing the model's reasoning text",
|
|
40
|
+
"/mode": "Permission mode: ask | auto-edit | yolo (Shift+Tab cycles)",
|
|
41
|
+
"/cd": "Change the working directory",
|
|
42
|
+
"/todos": "Show the current todo list",
|
|
43
|
+
"/exit": "Quit",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class InputCompleter(Completer):
|
|
48
|
+
"""Completes slash commands and @file mentions."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, agent: Agent):
|
|
51
|
+
self.agent = agent
|
|
52
|
+
|
|
53
|
+
def get_completions(self, document, complete_event):
|
|
54
|
+
text = document.text_before_cursor
|
|
55
|
+
if text.startswith("/") and " " not in text:
|
|
56
|
+
for cmd, desc in COMMANDS.items():
|
|
57
|
+
if cmd.startswith(text):
|
|
58
|
+
yield Completion(cmd, start_position=-len(text), display_meta=desc)
|
|
59
|
+
return
|
|
60
|
+
m = re.search(r"(?:^|\s)@([\w./~\-]*)$", text)
|
|
61
|
+
if not m:
|
|
62
|
+
return
|
|
63
|
+
fragment = m.group(1)
|
|
64
|
+
base = self.agent.cwd / os.path.expanduser(os.path.dirname(fragment) or ".")
|
|
65
|
+
prefix = os.path.basename(fragment)
|
|
66
|
+
try:
|
|
67
|
+
entries = sorted(base.iterdir())
|
|
68
|
+
except OSError:
|
|
69
|
+
return
|
|
70
|
+
for entry in entries:
|
|
71
|
+
hidden = entry.name.startswith(".") and not prefix.startswith(".")
|
|
72
|
+
if entry.name.startswith(prefix) and entry.name not in IGNORE_DIRS and not hidden:
|
|
73
|
+
completion = os.path.join(os.path.dirname(fragment), entry.name) + ("/" if entry.is_dir() else "")
|
|
74
|
+
yield Completion(completion, start_position=-len(fragment))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_session(agent: Agent) -> PromptSession:
|
|
78
|
+
kb = KeyBindings()
|
|
79
|
+
|
|
80
|
+
@kb.add("enter")
|
|
81
|
+
def _submit(event):
|
|
82
|
+
buf = event.current_buffer
|
|
83
|
+
if buf.complete_state and buf.complete_state.current_completion:
|
|
84
|
+
buf.apply_completion(buf.complete_state.current_completion)
|
|
85
|
+
elif buf.text.endswith("\\"):
|
|
86
|
+
buf.delete_before_cursor(1)
|
|
87
|
+
buf.insert_text("\n")
|
|
88
|
+
else:
|
|
89
|
+
buf.validate_and_handle()
|
|
90
|
+
|
|
91
|
+
@kb.add("escape", "enter")
|
|
92
|
+
def _newline(event):
|
|
93
|
+
event.current_buffer.insert_text("\n")
|
|
94
|
+
|
|
95
|
+
@kb.add("s-tab")
|
|
96
|
+
def _cycle_mode(event):
|
|
97
|
+
agent.perms.cycle()
|
|
98
|
+
event.app.invalidate()
|
|
99
|
+
|
|
100
|
+
def toolbar():
|
|
101
|
+
s = agent.settings
|
|
102
|
+
pct = 100 * agent.ctx_used / s.context
|
|
103
|
+
color = {"ask": "ansigreen", "auto-edit": "ansiyellow", "yolo": "ansired"}[agent.perms.mode]
|
|
104
|
+
return HTML(
|
|
105
|
+
f" <b>{html.escape(s.model)}</b> · ctx {format_tokens(agent.ctx_used)}/{format_tokens(s.context)} "
|
|
106
|
+
f"({pct:.0f}%) · mode <{color}>{agent.perms.mode}</{color}> (shift+tab) · "
|
|
107
|
+
f"think {'on' if s.think else 'off'} · {html.escape(agent.cwd.name)}/"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
return PromptSession(
|
|
112
|
+
history=FileHistory(str(STATE_DIR / "history")),
|
|
113
|
+
key_bindings=kb,
|
|
114
|
+
multiline=True,
|
|
115
|
+
completer=InputCompleter(agent),
|
|
116
|
+
complete_while_typing=True,
|
|
117
|
+
bottom_toolbar=toolbar,
|
|
118
|
+
prompt_continuation=" ",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def banner(agent: Agent) -> None:
|
|
123
|
+
s = agent.settings
|
|
124
|
+
agent.console.print(
|
|
125
|
+
Panel(
|
|
126
|
+
Group(
|
|
127
|
+
Text.from_markup(f"[bold cyan]lcode[/] [dim]v{__version__}[/] — local coding agent\n"),
|
|
128
|
+
Text.from_markup(
|
|
129
|
+
f"[dim]model[/] {s.model}\n"
|
|
130
|
+
f"[dim]context[/] {format_tokens(s.context)} tokens\n"
|
|
131
|
+
f"[dim]cwd[/] {agent.cwd}\n"
|
|
132
|
+
f"[dim]mode[/] {agent.perms.mode} [dim](Shift+Tab to cycle)[/]\n\n"
|
|
133
|
+
"[dim]/help for commands · @file to attach · Esc+Enter for a newline[/]"
|
|
134
|
+
),
|
|
135
|
+
),
|
|
136
|
+
border_style="cyan",
|
|
137
|
+
expand=False,
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def run_safely(agent: Agent, text: str) -> None:
|
|
143
|
+
try:
|
|
144
|
+
agent.run_turn(text)
|
|
145
|
+
except KeyboardInterrupt:
|
|
146
|
+
pass
|
|
147
|
+
except OllamaError as e:
|
|
148
|
+
agent.console.print(f"[red]{e}[/]")
|
|
149
|
+
finally:
|
|
150
|
+
agent.save()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
|
|
154
|
+
"""Run a slash command. Returns False when the REPL should exit."""
|
|
155
|
+
from lcode.cli import NotInstalled, print_models, resolve_model
|
|
156
|
+
|
|
157
|
+
cmd, _, arg = line.partition(" ")
|
|
158
|
+
arg = arg.strip()
|
|
159
|
+
c, s = agent.console, agent.settings
|
|
160
|
+
if cmd in ("/exit", "/quit", "/q"):
|
|
161
|
+
return False
|
|
162
|
+
if cmd == "/help":
|
|
163
|
+
rows = "\n".join(f" [cyan]{k:<10}[/] {v}" for k, v in COMMANDS.items())
|
|
164
|
+
keys = (
|
|
165
|
+
"Enter = send · Esc+Enter or trailing \\ = newline · Ctrl+C = interrupt · Ctrl+D = quit · "
|
|
166
|
+
"@path = attach a file · Shift+Tab = cycle permission mode"
|
|
167
|
+
)
|
|
168
|
+
c.print(Panel(f"{rows}\n\n [dim]{keys}[/]", title="lcode commands", border_style="cyan"))
|
|
169
|
+
elif cmd == "/clear":
|
|
170
|
+
agent.reset()
|
|
171
|
+
agent.session_id = agent.new_session_id()
|
|
172
|
+
c.print("[green]Conversation cleared.[/]")
|
|
173
|
+
elif cmd == "/compact":
|
|
174
|
+
agent.compact(arg)
|
|
175
|
+
elif cmd == "/context":
|
|
176
|
+
c.print(
|
|
177
|
+
f"Context: {format_tokens(agent.ctx_used)} of {format_tokens(s.context)} tokens "
|
|
178
|
+
f"({100 * agent.ctx_used / s.context:.1f}%) in {len(agent.messages)} messages; "
|
|
179
|
+
"auto-compacts at 85%."
|
|
180
|
+
)
|
|
181
|
+
elif cmd == "/ctx":
|
|
182
|
+
if not arg:
|
|
183
|
+
spec = catalog.find(s.model)
|
|
184
|
+
extra = f" · model maximum {format_tokens(spec.max_context)}" if spec else ""
|
|
185
|
+
c.print(f"Context window: {format_tokens(s.context)} tokens{extra}. Change it with /ctx 128k")
|
|
186
|
+
else:
|
|
187
|
+
try:
|
|
188
|
+
c.print(agent.set_context(parse_context(arg)))
|
|
189
|
+
except ConfigError as e:
|
|
190
|
+
c.print(f"[red]{e}[/]")
|
|
191
|
+
spec = catalog.find(s.model)
|
|
192
|
+
if spec and spec.memory_gib(s.context) > hardware.budget_gib:
|
|
193
|
+
c.print(
|
|
194
|
+
f"[yellow]~{spec.memory_gib(s.context):.0f} GB needed but ~{hardware.budget_gib:.0f} GB "
|
|
195
|
+
"available; this may run out of memory.[/]"
|
|
196
|
+
)
|
|
197
|
+
elif cmd == "/models":
|
|
198
|
+
print_models(agent.ollama, hardware, s.model)
|
|
199
|
+
elif cmd == "/model":
|
|
200
|
+
if not arg:
|
|
201
|
+
c.print(f"Model: {s.model}. Switch with /model <key or Ollama tag>; list with /models.")
|
|
202
|
+
else:
|
|
203
|
+
try:
|
|
204
|
+
name, spec = resolve_model(agent.ollama, arg)
|
|
205
|
+
except (NotInstalled, OllamaError) as e:
|
|
206
|
+
c.print(f"[red]{e}[/]")
|
|
207
|
+
return True
|
|
208
|
+
s.model = name
|
|
209
|
+
s.num_batch = spec.num_batch if spec and name == spec.local_name else None
|
|
210
|
+
if spec: # size the context for the new model: largest window that fits this machine
|
|
211
|
+
s.context = spec.fit(hardware)[0] or MIN_USEFUL_CONTEXT
|
|
212
|
+
agent.messages[0]["content"] = agent.system_prompt()
|
|
213
|
+
c.print(f"[green]Switched to {name}[/] (context {format_tokens(s.context)}).")
|
|
214
|
+
elif cmd == "/think":
|
|
215
|
+
s.think = (arg == "on") if arg in ("on", "off") else not s.think
|
|
216
|
+
c.print(f"Reasoning {'on' if s.think else 'off'}.")
|
|
217
|
+
elif cmd == "/verbose":
|
|
218
|
+
s.show_thinking = not s.show_thinking
|
|
219
|
+
c.print(f"Showing reasoning text: {'on' if s.show_thinking else 'off'}.")
|
|
220
|
+
elif cmd == "/mode":
|
|
221
|
+
if arg in PERMISSION_MODES:
|
|
222
|
+
agent.perms.mode = arg
|
|
223
|
+
elif arg:
|
|
224
|
+
c.print(f"[red]Unknown mode; choose from {', '.join(PERMISSION_MODES)}[/]")
|
|
225
|
+
c.print(f"Permission mode: {agent.perms.mode}")
|
|
226
|
+
elif cmd == "/cd":
|
|
227
|
+
p = Path(os.path.expanduser(arg or "~"))
|
|
228
|
+
p = (p if p.is_absolute() else agent.cwd / p).resolve()
|
|
229
|
+
if p.is_dir():
|
|
230
|
+
agent.cwd = p
|
|
231
|
+
agent.messages[0]["content"] = agent.system_prompt()
|
|
232
|
+
c.print(f"Working directory: {p}")
|
|
233
|
+
else:
|
|
234
|
+
c.print(f"[red]Not a directory: {p}[/]")
|
|
235
|
+
elif cmd == "/todos":
|
|
236
|
+
agent.tools.show_todos()
|
|
237
|
+
elif cmd == "/init":
|
|
238
|
+
run_safely(agent, INIT_PROMPT)
|
|
239
|
+
else:
|
|
240
|
+
c.print(f"[red]Unknown command {cmd}.[/] Type /help.")
|
|
241
|
+
return True
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def repl(agent: Agent, prompt: str | None, resume: bool, hardware: Hardware) -> None:
|
|
245
|
+
if resume and agent.load_latest():
|
|
246
|
+
agent.console.print(f"[green]Resumed session {agent.session_id} ({len(agent.messages)} messages).[/]")
|
|
247
|
+
if prompt:
|
|
248
|
+
run_safely(agent, prompt)
|
|
249
|
+
return
|
|
250
|
+
preload(agent)
|
|
251
|
+
banner(agent)
|
|
252
|
+
session = build_session(agent)
|
|
253
|
+
while True:
|
|
254
|
+
try:
|
|
255
|
+
agent.console.print()
|
|
256
|
+
line = session.prompt(HTML("<ansicyan><b>❯ </b></ansicyan>")).strip()
|
|
257
|
+
except KeyboardInterrupt:
|
|
258
|
+
continue
|
|
259
|
+
except EOFError:
|
|
260
|
+
break
|
|
261
|
+
if not line:
|
|
262
|
+
continue
|
|
263
|
+
if line.startswith("/") and not line.startswith("//"):
|
|
264
|
+
if not handle_command(agent, line, hardware):
|
|
265
|
+
break
|
|
266
|
+
continue
|
|
267
|
+
agent.console.print(Rule(style="dim"))
|
|
268
|
+
run_safely(agent, line)
|
|
269
|
+
agent.console.print("[dim]Bye.[/]")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def preload(agent: Agent) -> None:
|
|
273
|
+
"""Start loading the model in the background so the first answer comes sooner."""
|
|
274
|
+
|
|
275
|
+
def load() -> None:
|
|
276
|
+
try:
|
|
277
|
+
agent.ollama.load(agent.settings.model, agent.options(), agent.settings.keep_alive)
|
|
278
|
+
except OllamaError:
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
threading.Thread(target=load, daemon=True).start()
|