letscode 0.5.0__py3-none-any.whl → 0.6.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.
letscode/agent/loop.py CHANGED
@@ -108,7 +108,9 @@ async def agent_loop( # noqa: PLR0913, C901 # central turn state machine — b
108
108
  ``env`` defaults to a fresh :class:`~letscode.agent.execution_env.LocalEnv`
109
109
  so callers that don't care about the abstraction keep working.
110
110
  """
111
- registry = _build_tool_registry(state.tools)
111
+ registry = ToolRegistry()
112
+ for tool_impl in state.tools:
113
+ registry.add(tool_impl)
112
114
  resolved_env: ExecutionEnv = env if env is not None else LocalEnv()
113
115
 
114
116
  state.messages.extend(inputs)
@@ -630,13 +632,6 @@ async def _close_aborted_run( # noqa: RUF029 # async generator: consumed via `
630
632
  yield AgentEnd(messages=_snapshot(state.messages), error="aborted")
631
633
 
632
634
 
633
- def _build_tool_registry(tools: list[Tool]) -> ToolRegistry:
634
- registry = ToolRegistry()
635
- for tool_impl in tools:
636
- registry.add(tool_impl)
637
- return registry
638
-
639
-
640
635
  def _assemble_tool_calls(
641
636
  names: dict[str, str],
642
637
  args_strs: dict[str, str],
letscode/agent/tools.py CHANGED
@@ -26,10 +26,11 @@ from pydantic import BaseModel, Field
26
26
  from letscode.llm.types import (
27
27
  ContentPart, # noqa: TC001 # Pydantic resolves at runtime
28
28
  )
29
+ from letscode.plugins.registries import Registry
29
30
 
30
31
  if TYPE_CHECKING:
31
32
  import asyncio
32
- from collections.abc import Awaitable, Callable, Iterator
33
+ from collections.abc import Awaitable, Callable
33
34
  from pathlib import Path
34
35
 
35
36
  from letscode.agent.execution_env import ExecutionEnv
@@ -164,13 +165,6 @@ def tool(
164
165
 
165
166
 
166
167
  def _callable_name(fn: Callable[..., object]) -> str:
167
- """Best-effort name for any callable.
168
-
169
- ``Callable`` is broader than "function with ``__name__``" — a
170
- ``functools.partial``, bound-method-on-an-instance, or callable
171
- class instance may not have it. Falls back to ``repr`` so error
172
- messages stay informative.
173
- """
174
168
  return getattr(fn, "__name__", repr(fn))
175
169
 
176
170
 
@@ -210,36 +204,5 @@ def _extract_params_model(
210
204
  # ---------------------------------------------------------------------------
211
205
 
212
206
 
213
- class ToolRegistry:
207
+ class ToolRegistry(Registry["Tool"]):
214
208
  """Mutable, name-keyed collection of registered tools."""
215
-
216
- def __init__(self) -> None:
217
- self._tools: dict[str, Tool] = {}
218
-
219
- def add(self, tool: Tool, *, replace: bool = False) -> None:
220
- """Register ``tool`` under its ``name``.
221
-
222
- Raises ``ValueError`` on duplicate names unless ``replace=True``.
223
- """
224
- if tool.name in self._tools and not replace:
225
- msg = (
226
- f"Tool {tool.name!r} already registered. Pass replace=True to override."
227
- )
228
- raise ValueError(msg)
229
- self._tools[tool.name] = tool
230
-
231
- def get(self, name: str) -> Tool:
232
- """Look up a tool by name. Raises ``KeyError`` if absent."""
233
- if name not in self._tools:
234
- msg = f"Tool {name!r} not registered"
235
- raise KeyError(msg)
236
- return self._tools[name]
237
-
238
- def __contains__(self, name: object) -> bool:
239
- return name in self._tools
240
-
241
- def __iter__(self) -> Iterator[Tool]:
242
- return iter(self._tools.values())
243
-
244
- def __len__(self) -> int:
245
- return len(self._tools)
letscode/cli/rpc.py CHANGED
@@ -14,7 +14,7 @@ import contextlib
14
14
  import json
15
15
  import sys
16
16
  from importlib.metadata import PackageNotFoundError, version
17
- from typing import TYPE_CHECKING, Any, ClassVar
17
+ from typing import TYPE_CHECKING, Any, ClassVar, Self
18
18
 
19
19
  from letscode.agent.agent import Agent
20
20
  from letscode.agent.events import MessageEnd
@@ -23,7 +23,7 @@ from letscode.llm.client import LLMClient
23
23
  from letscode.session.store import save_message
24
24
 
25
25
  if TYPE_CHECKING:
26
- from collections.abc import Awaitable, Callable, Sequence
26
+ from collections.abc import Callable, Coroutine, Sequence
27
27
  from pathlib import Path
28
28
  from typing import TextIO
29
29
 
@@ -154,7 +154,7 @@ class _RpcSession:
154
154
  if handler is None:
155
155
  self._error(cid, f"unknown command: {name!r}")
156
156
  return False
157
- return await handler(self, cmd, cid)
157
+ return await handler(self, cmd, cid) # type: ignore[arg-type] # mypy: Self vs _RpcSession in the table
158
158
 
159
159
  async def _h_hello(self, cmd: dict[str, Any], cid: str | None) -> bool:
160
160
  proto = cmd.get("protocol")
@@ -218,7 +218,9 @@ class _RpcSession:
218
218
  return True
219
219
 
220
220
  _HANDLERS: ClassVar[
221
- dict[str, Callable[[_RpcSession, dict[str, Any], str | None], Awaitable[bool]]]
221
+ dict[
222
+ str, Callable[[Self, dict[str, Any], str | None], Coroutine[Any, Any, bool]]
223
+ ]
222
224
  ] = {
223
225
  "hello": _h_hello,
224
226
  "prompt": _h_prompt,
@@ -14,7 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  import logging
16
16
  from dataclasses import dataclass
17
- from typing import TYPE_CHECKING
17
+ from typing import TYPE_CHECKING, Protocol
18
18
 
19
19
  from letscode.skills.discovery import iter_skill_files
20
20
  from letscode.skills.loader import Skill, parse_skill
@@ -30,6 +30,54 @@ if TYPE_CHECKING:
30
30
 
31
31
  _logger = logging.getLogger(__name__)
32
32
 
33
+ # ---------------------------------------------------------------------------
34
+ # Generic name-keyed registry
35
+ # ---------------------------------------------------------------------------
36
+
37
+
38
+ class _HasName(Protocol):
39
+ """Structural bound: any item with a ``name: str`` attribute."""
40
+
41
+ name: str
42
+
43
+
44
+ class Registry[T: _HasName]:
45
+ """Name-keyed collection of registered items sharing a ``.name`` attribute.
46
+
47
+ Concrete registries (:class:`CommandRegistry`, :class:`ToolRegistry`,
48
+ :class:`FrontendRegistry`, :class:`SkillRegistry`) subclass this
49
+ to get their own type for ``isinstance`` checks and imports.
50
+ """
51
+
52
+ def __init__(self) -> None:
53
+ self._items: dict[str, T] = {}
54
+
55
+ def add(self, item: T, *, replace: bool = False) -> None:
56
+ """Register ``item`` under its ``name``.
57
+
58
+ Raises ``ValueError`` on duplicate names unless ``replace=True``.
59
+ """
60
+ if item.name in self._items and not replace:
61
+ msg = f"{item.name!r} already registered. Pass replace=True to override."
62
+ raise ValueError(msg)
63
+ self._items[item.name] = item
64
+
65
+ def get(self, name: str) -> T:
66
+ """Look up an item by name. Raises ``KeyError`` if absent."""
67
+ if name not in self._items:
68
+ msg = f"{name!r} not registered"
69
+ raise KeyError(msg)
70
+ return self._items[name]
71
+
72
+ def __contains__(self, name: object) -> bool:
73
+ return name in self._items
74
+
75
+ def __iter__(self) -> Iterator[T]:
76
+ return iter(self._items.values())
77
+
78
+ def __len__(self) -> int:
79
+ return len(self._items)
80
+
33
81
 
34
82
  # ---------------------------------------------------------------------------
35
83
  # Value types
@@ -77,6 +125,7 @@ __all__ = [
77
125
  "CommandRegistry",
78
126
  "FrontendFactory",
79
127
  "FrontendRegistry",
128
+ "Registry",
80
129
  "Skill",
81
130
  "SkillRegistry",
82
131
  ]
@@ -87,38 +136,11 @@ __all__ = [
87
136
  # ---------------------------------------------------------------------------
88
137
 
89
138
 
90
- class CommandRegistry:
139
+ class CommandRegistry(Registry[Command]):
91
140
  """Name-keyed registry of slash commands."""
92
141
 
93
- def __init__(self) -> None:
94
- self._items: dict[str, Command] = {}
95
-
96
- def add(self, command: Command, *, replace: bool = False) -> None:
97
- if command.name in self._items and not replace:
98
- msg = (
99
- f"Command {command.name!r} already registered. "
100
- "Pass replace=True to override."
101
- )
102
- raise ValueError(msg)
103
- self._items[command.name] = command
104
142
 
105
- def get(self, name: str) -> Command:
106
- if name not in self._items:
107
- msg = f"Command {name!r} not registered"
108
- raise KeyError(msg)
109
- return self._items[name]
110
-
111
- def __contains__(self, name: object) -> bool:
112
- return name in self._items
113
-
114
- def __iter__(self) -> Iterator[Command]:
115
- return iter(self._items.values())
116
-
117
- def __len__(self) -> int:
118
- return len(self._items)
119
-
120
-
121
- class SkillRegistry:
143
+ class SkillRegistry(Registry[Skill]):
122
144
  """Name-keyed registry of skills plus a list of source directories.
123
145
 
124
146
  Plugins typically call :meth:`add_source` to register a directory
@@ -127,18 +149,9 @@ class SkillRegistry:
127
149
  """
128
150
 
129
151
  def __init__(self) -> None:
130
- self._items: dict[str, Skill] = {}
152
+ super().__init__()
131
153
  self._sources: list[Path] = []
132
154
 
133
- def add(self, skill: Skill, *, replace: bool = False) -> None:
134
- if skill.name in self._items and not replace:
135
- msg = (
136
- f"Skill {skill.name!r} already registered. "
137
- "Pass replace=True to override."
138
- )
139
- raise ValueError(msg)
140
- self._items[skill.name] = skill
141
-
142
155
  def add_source(self, path: Path) -> None:
143
156
  """Register a directory the loader should scan for ``SKILL.md``."""
144
157
  if path not in self._sources:
@@ -192,51 +205,9 @@ class SkillRegistry:
192
205
  """Read-only view of registered source directories (insertion order)."""
193
206
  return tuple(self._sources)
194
207
 
195
- def get(self, name: str) -> Skill:
196
- if name not in self._items:
197
- msg = f"Skill {name!r} not registered"
198
- raise KeyError(msg)
199
- return self._items[name]
200
-
201
- def __contains__(self, name: object) -> bool:
202
- return name in self._items
203
-
204
- def __iter__(self) -> Iterator[Skill]:
205
- return iter(self._items.values())
206
-
207
- def __len__(self) -> int:
208
- return len(self._items)
209
-
210
208
 
211
- class FrontendRegistry:
209
+ class FrontendRegistry(Registry[FrontendFactory]):
212
210
  """Name-keyed registry of frontend factories.
213
211
 
214
212
  The CLI's ``--frontend NAME`` flag (added in T20) selects from here.
215
213
  """
216
-
217
- def __init__(self) -> None:
218
- self._items: dict[str, FrontendFactory] = {}
219
-
220
- def add(self, frontend: FrontendFactory, *, replace: bool = False) -> None:
221
- if frontend.name in self._items and not replace:
222
- msg = (
223
- f"Frontend {frontend.name!r} already registered. "
224
- "Pass replace=True to override."
225
- )
226
- raise ValueError(msg)
227
- self._items[frontend.name] = frontend
228
-
229
- def get(self, name: str) -> FrontendFactory:
230
- if name not in self._items:
231
- msg = f"Frontend {name!r} not registered"
232
- raise KeyError(msg)
233
- return self._items[name]
234
-
235
- def __contains__(self, name: object) -> bool:
236
- return name in self._items
237
-
238
- def __iter__(self) -> Iterator[FrontendFactory]:
239
- return iter(self._items.values())
240
-
241
- def __len__(self) -> int:
242
- return len(self._items)
@@ -1,9 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: letscode
3
- Version: 0.5.0
3
+ Version: 0.6.1
4
4
  Summary: A minimal coding agent for the terminal.
5
- Project-URL: Homepage, https://git.sr.ht/~sfermigier/letscode
6
- Project-URL: Source Code, https://git.sr.ht/~sfermigier/letscode
7
5
  Author-email: Stefane Fermigier <sf@abilian.com>
8
6
  Requires-Python: >=3.12
9
7
  Requires-Dist: aiofiles>=24.0
@@ -13,6 +11,7 @@ Requires-Dist: openai>=1.0
13
11
  Requires-Dist: pluggy>=1.5
14
12
  Requires-Dist: prompt-toolkit>=3.0
15
13
  Requires-Dist: pydantic>=2.0
14
+ Requires-Dist: pyyaml>=6.0
16
15
  Requires-Dist: rich>=13.0
17
16
  Description-Content-Type: text/markdown
18
17
 
@@ -20,10 +19,12 @@ Description-Content-Type: text/markdown
20
19
 
21
20
  A minimal, OpenAI-compatible coding agent for the terminal — written in Python. Point it at any OpenAI-API-compatible endpoint (Ollama, Fireworks, OpenRouter, vLLM, llama.cpp's `llama-server`, …) and get a streaming agent loop with the four tools that cover 95% of coding sessions — `read`, `write`, `edit`, `bash` — plus skills, slash commands, and a plugin system you can extend.
22
21
 
23
- **Status:** v0.5, alpha.
22
+ **Status:** v0.6, alpha.
24
23
 
25
24
  The full docs live in [`docs/`](docs/) and build via [Zensical](https://zensical.org) — run `make docs-serve` for a live preview, or `make docs` for a static build.
26
25
 
26
+ Or go to <https://letscode.hop3.abilian.com> for the online doc.
27
+
27
28
  ## What it does
28
29
 
29
30
  - **Streaming agent loop** with parallel tool execution.
@@ -6,13 +6,13 @@ letscode/agent/events.py,sha256=FC0ZjvBF4NofxG57O-6Hc_XruqwurRLBF-sUzUd0P7g,3926
6
6
  letscode/agent/execution_env.py,sha256=QCFOeDi-mxRvgLKG_Zm--v7Ns1FbESi7birlLVaNWgI,11438
7
7
  letscode/agent/hooks.py,sha256=5XnFYZbM3rBHlnLBYjylM58j588_11Tsms1o4LPXMx8,1479
8
8
  letscode/agent/hookspecs.py,sha256=LwAjGdJXdbQmx5fEW-XLWt8BKry-e39ekZSvJmprmkY,7929
9
- letscode/agent/loop.py,sha256=X3Dkluqrq-58rIUj4YshGkvlkrJ5OE5f6ADTYO3syKY,24202
9
+ letscode/agent/loop.py,sha256=2XyUADNDdfabilvqG4uH1DrMvOQ22Gcp7lKbyldiWNQ,24076
10
10
  letscode/agent/messages.py,sha256=ceBMb8k_uJDskqCCbZuKlRWmyL2Pwuc_LlYt7iaMn6c,3290
11
11
  letscode/agent/state.py,sha256=O4cUWs6nu51tGIZ93qCRNyIhpfWETT3IKAtqUV5MvEI,2052
12
- letscode/agent/tools.py,sha256=gTKLXfmIPqeN0ev6JiisJQSYEzqVe6H89uRndVEIS-8,8200
12
+ letscode/agent/tools.py,sha256=B5Q3nhkv2-VRYWVSsXm7WOY1NDFT0RtWjGrfmiSExtQ,6953
13
13
  letscode/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
14
  letscode/cli/app.py,sha256=sfDaTO_6vNOHTvIYzR6U5MA_BQQbd-ELTpZZ1OkoCPY,21159
15
- letscode/cli/rpc.py,sha256=JYO2CZrlypY1Wbrc-8ODWYOD1dq1pqjqBlMlIncQEJ0,10877
15
+ letscode/cli/rpc.py,sha256=N8h1vW8Ira-GkIORsxHZY4XWOr4iXD4MnT0ZZG5KfSw,10976
16
16
  letscode/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
17
  letscode/commands/builtin.py,sha256=2-Kqu0l-eW6b3-NYRjAqKrb5iz5d2dmS53qRAVH_XRU,12615
18
18
  letscode/commands/dispatch.py,sha256=sxr3z5NlErG4W5fwdTVIjTEK9gzID3NitwHRDHKHDcs,4080
@@ -34,7 +34,7 @@ letscode/llm/types.py,sha256=yjPyZzZ983vbRTOWTD5hexTuHCNeWMVJtwzvRpAvfAg,1377
34
34
  letscode/plugins/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
35
35
  letscode/plugins/builtin.py,sha256=JRIlv2fD3i0rliKSgzTEXI37my_HcVffkmsZfXGdPDc,4552
36
36
  letscode/plugins/manager.py,sha256=u2hgbt5nIW8_MKeofy-XmPyDAN0bcrtrVcWDIC9jFO4,2760
37
- letscode/plugins/registries.py,sha256=pQ8tc0gmSjIGrmUeVdmvLxgfM6vEl604Ubd5Z0MsFvI,8367
37
+ letscode/plugins/registries.py,sha256=S9igLcStCbHCGeg6d4IPN-j9z5J3K0ssNS90-dh8G-k,7496
38
38
  letscode/session/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
39
39
  letscode/session/format.py,sha256=XIqQ7Z4yRzNghoXgI2V7J6uyKXL4oLIZmHg8rSM7CAM,2714
40
40
  letscode/session/store.py,sha256=m0llMEq6qhT4vMMRy2kjgpGD4YdXX_7hwalPf4OdnH4,6732
@@ -48,7 +48,7 @@ letscode/tools/builtin.py,sha256=bx-nPUwESAHTam4ts13BXgJYoZbx8KBC8estBe6YuqM,653
48
48
  letscode/tools/edit.py,sha256=QyCeouEco9NsO7aAZao3xzQcYTi4um8RCpHXawtuJHU,4319
49
49
  letscode/tools/read.py,sha256=TJ4GFBu-AahNu7fByAB1x9mFW3O-j_ikh3usF3znHQk,3129
50
50
  letscode/tools/write.py,sha256=JSBoKqwZ6OZ0Ut34__WiCqVzwREx3vxYOGsRNpClZm8,2445
51
- letscode-0.5.0.dist-info/METADATA,sha256=q-Ep5Lgm9-8eVrw7swZ7llCLPwgUxlrXOTJ-Sh-fz2E,11968
52
- letscode-0.5.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
53
- letscode-0.5.0.dist-info/entry_points.txt,sha256=7wTui6fYLJ-_V7rwBMFloXcRbc7rYTPkcvVwZ7SV9p8,51
54
- letscode-0.5.0.dist-info/RECORD,,
51
+ letscode-0.6.1.dist-info/METADATA,sha256=Ui51BAgKqJy7M7pHqYTxomORl-UW3PRxnB4bHZxkYVQ,11934
52
+ letscode-0.6.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
53
+ letscode-0.6.1.dist-info/entry_points.txt,sha256=7wTui6fYLJ-_V7rwBMFloXcRbc7rYTPkcvVwZ7SV9p8,51
54
+ letscode-0.6.1.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.29.0
2
+ Generator: hatchling 1.30.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any