zettcode-sdk 0.3.4__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.
zettcode/mentions.py ADDED
@@ -0,0 +1,320 @@
1
+ """``@`` references: resources a draft can pull into one message.
2
+
3
+ A mention is a token like ``@code-review`` typed in the composer. A provider
4
+ owns one kind of resource — the builtin one is skills — and both lists
5
+ candidates for the completion menu and expands a token into the text the model
6
+ should see. The draft the user typed stays the message that is stored and
7
+ shown; the expansion is carried as a message attribute, so a session loaded
8
+ later restores the original text instead of the injected resource.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from collections.abc import Callable, Sequence
15
+ from heapq import nsmallest
16
+ from pathlib import Path
17
+ from time import monotonic
18
+ from typing import TypeAlias
19
+
20
+ from zett_agent.extensions.skill import SkillExtension
21
+
22
+ from zettcode.broker.text import folder_name
23
+ from zettcode.config import ZettCodeConfig
24
+ from zettcode.mention import Mention, MentionProvider, MentionRegistry
25
+ from zettcode.relay import Peer
26
+
27
+ __all__ = [
28
+ "LISTING_SECONDS",
29
+ "MAX_CANDIDATES",
30
+ "MAX_FILES",
31
+ "SKIP_DIRECTORIES",
32
+ "FileMentions",
33
+ "Mention",
34
+ "MentionProvider",
35
+ "MentionRegistry",
36
+ "SessionMentions",
37
+ "SkillMentions",
38
+ ]
39
+
40
+
41
+ class SkillMentions(MentionProvider):
42
+ """Skills discovered from the configured roots, named for the model to load."""
43
+
44
+ kind = "skill"
45
+
46
+ def __init__(self, config: ZettCodeConfig) -> None:
47
+ """Discover skills once, the same way the agent's skill extension does."""
48
+ self._skills = {skill.name: skill for skill in SkillExtension(config.skill_search_roots()).skills}
49
+
50
+ def candidates(self, query: str) -> Sequence[Mention]:
51
+ """List the discovered skills whose name starts with ``query``."""
52
+ return tuple(
53
+ Mention(token=f"@{skill.name}", kind=self.kind, name=skill.name, description=skill.description)
54
+ for skill in self._skills.values()
55
+ if skill.name.startswith(query)
56
+ )
57
+
58
+ def expand(self, name: str) -> str | None:
59
+ """Name the skill and leave loading its instructions to the model.
60
+
61
+ The system prompt already catalogs every skill, and ``read_skill`` loads
62
+ the file, so the message only has to say which one the user meant;
63
+ inlining the body would spend context on instructions that may not be
64
+ needed.
65
+ """
66
+ skill = self._skills.get(name)
67
+ if skill is None:
68
+ return None
69
+ return (
70
+ f'Use the "{skill.name}" skill: call read_skill(name="{skill.name}") '
71
+ "if its instructions are useful and not already in context."
72
+ )
73
+
74
+
75
+ class SessionMentions(MentionProvider):
76
+ """The other ZettCode sessions that are running, addressed by the name they are listed under.
77
+
78
+ A session named anywhere in a draft is a reference like any other: the model reads ``session "name"``
79
+ and is told how to reach that session, and decides for itself whether the request calls for a message.
80
+ The shell sends nothing itself (see :mod:`zettcode.app.ui.messaging`).
81
+
82
+ Candidates are read from what the connection last reported — a list in memory — and never from
83
+ the broker: completion runs on every keystroke after an ``@``.
84
+ """
85
+
86
+ kind = "session"
87
+
88
+ def __init__(self, peers: Callable[[], Sequence[Peer]]) -> None:
89
+ """Offer whichever sessions ``peers`` returns when asked.
90
+
91
+ Args:
92
+ peers: The other sessions as of now; called for every query, so it must not block.
93
+ """
94
+ self._peers = peers
95
+
96
+ def candidates(self, query: str) -> Sequence[Mention]:
97
+ """List the sessions ``query`` could mean, the likeliest first.
98
+
99
+ A reader looks for a session by what they remember of it, which is its name, what it was about,
100
+ the folder it works in, or the start of its id — so any of those matches, with a session whose
101
+ *name* starts with the query before one that only mentions it elsewhere. The token offered is
102
+ always the name, because that is what can be sent to.
103
+ """
104
+ lowered = query.lower()
105
+ ranked: list[tuple[int, int, Peer]] = []
106
+ for order, peer in enumerate(self._peers()):
107
+ if peer.handle.lower().startswith(lowered):
108
+ ranked.append((0, order, peer))
109
+ elif (
110
+ lowered in peer.handle.lower() or lowered in peer.title.lower() or lowered in self._folder(peer).lower()
111
+ ):
112
+ ranked.append((1, order, peer))
113
+ elif len(lowered) >= 4 and peer.session_id.lower().startswith(lowered):
114
+ ranked.append((2, order, peer))
115
+ return tuple(
116
+ Mention(token=f"@{peer.handle}", kind=self.kind, name=peer.handle, description=self._describe(peer))
117
+ for _, _, peer in sorted(ranked, key=lambda item: item[:2])
118
+ )
119
+
120
+ @staticmethod
121
+ def _folder(peer: Peer) -> str:
122
+ """Return the last part of the directory the session works in."""
123
+ return folder_name(peer.workspace)
124
+
125
+ @classmethod
126
+ def _describe(cls, peer: Peer) -> str:
127
+ """Return the menu's line for a session: what it is about, whether it is working, where it is."""
128
+ parts = [peer.title or "untitled"]
129
+ if peer.status != "idle":
130
+ parts.append(peer.status)
131
+ folder = cls._folder(peer)
132
+ if folder:
133
+ parts.append(folder)
134
+ return " \u00b7 ".join(parts)
135
+
136
+ def rename(self, name: str) -> str | None:
137
+ """Put ``session "work"`` where the reader typed ``@work``: the model has no use for the ``@``."""
138
+ return f'session "{name}"' if self._find(name) is not None else None
139
+
140
+ def _find(self, name: str) -> Peer | None:
141
+ """Return the session called ``name`` among those running now."""
142
+ return next((peer for peer in self._peers() if peer.handle == name), None)
143
+
144
+ def expand(self, name: str) -> str | None:
145
+ """Tell the model what the reader's ``@`` meant and how to reach that session.
146
+
147
+ Nothing is sent from here: the model is told which session the words mean and how to reach it, and
148
+ decides for itself whether the request calls for a message. A draft that begins with the name is no
149
+ different from one that names the session inside a sentence.
150
+ """
151
+ peer = self._find(name)
152
+ if peer is None:
153
+ return None
154
+ where = f" in {peer.workspace}" if peer.workspace else ""
155
+ about = f' It is titled "{peer.title}".' if peer.title else ""
156
+ return (
157
+ f'In the message above, `session "{name}"` is what the user wrote as @{name}. In ZettCode an @ before a '
158
+ f"name means another ZettCode session that is running right now, not a file or a skill. This one works{where}.{about}\n"
159
+ f"When the user's message is addressed to that session — the @ begins the message — what follows the name "
160
+ f"is the message to pass on. When the session is only mentioned, decide whether the request is for it. "
161
+ f'To send, call the send_message tool: send_message(to="{peer.session_id}", message=...). Write the '
162
+ f"message so the other session can act on it without having seen this conversation. If the request is only "
163
+ f"about the session (what it is doing, where it is), list_sessions answers that. Do not send anything the "
164
+ f"user did not ask you to pass on."
165
+ )
166
+
167
+
168
+ #: Directories ``@`` never offers files from: version control, the dependency
169
+ #: trees, and the caches. They are where a repository keeps things nobody wants
170
+ #: to reference in a message, and walking them is most of what a listing costs.
171
+ SKIP_DIRECTORIES = frozenset(
172
+ {
173
+ ".git",
174
+ ".hg",
175
+ ".svn",
176
+ ".venv",
177
+ "venv",
178
+ "node_modules",
179
+ "__pycache__",
180
+ ".mypy_cache",
181
+ ".pytest_cache",
182
+ ".ruff_cache",
183
+ ".tox",
184
+ "site-packages",
185
+ "dist",
186
+ "build",
187
+ "target",
188
+ }
189
+ )
190
+
191
+ #: Files offered in one listing. A repository larger than this is listed up to
192
+ #: here rather than not at all, and the walk stops as soon as the cap is reached.
193
+ MAX_FILES = 4000
194
+
195
+ #: Candidates returned for one query. The menu shows a handful; the rest would be
196
+ #: scrolled past, and building them on every keystroke is the cost this bounds.
197
+ MAX_CANDIDATES = 40
198
+
199
+ #: How long a listing is reused. Long enough that typing a path walks the tree
200
+ #: once instead of once a keystroke, short enough that a file made in the
201
+ #: meantime shows up in the menu.
202
+ LISTING_SECONDS = 15.0
203
+
204
+ #: One listed file: its path, the path lowercased, and its name lowercased. Built
205
+ #: once per walk, because the menu is filtered again on every keystroke and a
206
+ #: listing of thousands would otherwise be lowercased and split every time.
207
+ ListedFile: TypeAlias = tuple[str, str, str]
208
+
209
+
210
+ class FileMentions(MentionProvider):
211
+ """Files in the workspace, so a draft can point at one by path.
212
+
213
+ The listing is not the completeness of ``ls``: it is what a reader is likely to
214
+ type after an ``@``, so the version control and dependency directories are
215
+ skipped and there is a cap. What a token expands to is a sentence naming the
216
+ path, not the file's contents: the model has ``read_file``, and inlining a
217
+ file nobody needs would spend context on it.
218
+ """
219
+
220
+ kind = "file"
221
+
222
+ def __init__(self, workspace: Path, *, clock: Callable[[], float] = monotonic) -> None:
223
+ """List ``workspace`` when something first asks, and remember it for a while.
224
+
225
+ Args:
226
+ workspace: Directory to walk; the paths offered are relative to it.
227
+ clock: Time source, injectable so a test can age the listing.
228
+ """
229
+ self.workspace = workspace
230
+ self._clock = clock
231
+ self._files: tuple[ListedFile, ...] = ()
232
+ self._listed_at: float | None = None
233
+
234
+ def _listing(self) -> tuple[ListedFile, ...]:
235
+ """Return the workspace's files, walking it again when the listing is stale.
236
+
237
+ The walk happens on the first keystroke after an ``@``, so it is bounded three
238
+ ways: the directories nobody references are never entered, the walk stops at
239
+ :data:`MAX_FILES`, and the result is reused for :data:`LISTING_SECONDS`.
240
+ """
241
+ now = self._clock()
242
+ if self._listed_at is not None and now - self._listed_at < LISTING_SECONDS:
243
+ return self._files
244
+ found: list[ListedFile] = []
245
+ for root, directories, names in os.walk(self.workspace):
246
+ directories[:] = sorted(
247
+ name for name in directories if name not in SKIP_DIRECTORIES and not name.startswith(".")
248
+ )
249
+ base = Path(root)
250
+ for name in sorted(names):
251
+ path = (base / name).relative_to(self.workspace).as_posix()
252
+ found.append((path, path.lower(), name.lower()))
253
+ if len(found) >= MAX_FILES:
254
+ break
255
+ if len(found) >= MAX_FILES:
256
+ break
257
+ self._files = tuple(found)
258
+ self._listed_at = now
259
+ return self._files
260
+
261
+ def candidates(self, query: str) -> Sequence[Mention]:
262
+ """Offer the files whose path matches ``query``, the likeliest ones first.
263
+
264
+ A path that starts with what was typed, or whose file name does, comes
265
+ before one that merely contains it: ``@cli`` should offer
266
+ ``cli.py`` before ``src/zettcode/tui/render/cli_helper.py``.
267
+ """
268
+ lowered = query.lower()
269
+ ranked: list[tuple[int, int, str]] = []
270
+ for path, path_lower, name_lower in self._listing():
271
+ if path_lower.startswith(lowered) or name_lower.startswith(lowered):
272
+ rank = 0
273
+ elif lowered in path_lower:
274
+ rank = 1
275
+ else:
276
+ continue
277
+ ranked.append((rank, len(path), path))
278
+ # Only the rows that will be shown are ordered: a one-letter query can match a
279
+ # listing of thousands in hundreds of places, and sorting all of those is work
280
+ # the menu never shows.
281
+ return tuple(
282
+ Mention(token=f"@{path}", kind=self.kind, name=path, description=described)
283
+ for _, _, path in nsmallest(MAX_CANDIDATES, ranked)
284
+ if (described := self._describe(path)) is not None
285
+ )
286
+
287
+ def _describe(self, path: str) -> str | None:
288
+ """Return the menu's one line for a file: how big it is, or ``None`` if it is gone.
289
+
290
+ Asking the file also drops one that was deleted since the listing, which is
291
+ the one way a cached listing goes wrong while it is being typed into.
292
+ """
293
+ try:
294
+ size = (self.workspace / path).stat().st_size
295
+ except OSError:
296
+ return None
297
+ if size < 1024:
298
+ return f"{size} B"
299
+ if size < 1024 * 1024:
300
+ return f"{size / 1024:.1f} kB"
301
+ return f"{size / (1024 * 1024):.1f} MB"
302
+
303
+ def expand(self, name: str) -> str | None:
304
+ """Name the path for the model, which reads it with a tool if it matters.
305
+
306
+ A directory is named as one, because the tool to reach for is not
307
+ ``read_file`` and saying so is cheaper than the model finding out.
308
+ """
309
+ target = self.workspace / name
310
+ if target.is_dir():
311
+ return (
312
+ f'The reader pointed at the directory "{name}" in the workspace; '
313
+ "list or search it with the file tools when it matters."
314
+ )
315
+ if not target.is_file():
316
+ return None
317
+ return (
318
+ f'The reader pointed at the file "{name}" in the workspace; '
319
+ "read it before answering when its contents matter."
320
+ )
@@ -0,0 +1,195 @@
1
+ """The ZettCode SDK: the coding agent, driven from code.
2
+
3
+ Everything the terminal application does with the agent is available here without a terminal: send a prompt and read
4
+ the result, stream the events of a run, answer shell approvals and questions the model asks, switch model, reasoning
5
+ effort and permission level, keep several conversations, resume an interrupted request, compact, measure the
6
+ context, export, and read the history. It runs the same agent, tools, plugins, MCP servers, skills, project
7
+ instructions and session store as the terminal.
8
+
9
+ One prompt, no ceremony::
10
+
11
+ import zettcode.sdk as zc
12
+
13
+ result = await zc.prompt("summarize the README", workspace=".", permission="deny")
14
+ print(result.text)
15
+
16
+ A conversation, with progress and a rule for shell commands::
17
+
18
+ async with zc.Client(".", permission="safe") as client:
19
+ async with client.prompt("make the tests pass", on_approval=zc.approve_commands("pytest")) as run:
20
+ async for event in run:
21
+ match event:
22
+ case zc.TextDelta(text=text):
23
+ print(text, end="")
24
+ case zc.ToolStarted(name=name):
25
+ print(f"\\n[{name}]")
26
+ print(run.result.usage.total_tokens, "tokens")
27
+ followup = await client.prompt("and explain what you changed") # awaiting it gives the result
28
+
29
+ Importing this package costs nothing: the names below load their module the first time they are used, and the
30
+ agent itself is loaded when a :class:`Client` is made. Only what a program reaches for every day is listed here; the
31
+ rest is in the submodules, which are just as light to import:
32
+
33
+ * :mod:`zettcode.sdk.events` — the other events (``Steered``, ``ModelStopped``, ``Notice``, ``UsageUpdated``,
34
+ ``CompactionStarted``/``CompactionFinished``, ``RawEvent``) and ``AnyEvent`` for annotations.
35
+ * :mod:`zettcode.sdk.types` — ``ModelInfo``, ``SessionInfo``, ``Message``, ``ToolRecord``, ``ContextReport``,
36
+ ``Compaction``, ``ResumeInfo`` and the other values the methods return.
37
+ * :mod:`zettcode.sdk.errors` — every error; they all derive from :class:`ZettCodeError`.
38
+ * :mod:`zettcode.sdk.handlers` — ``ApprovalHandler``/``QuestionHandler`` for annotations.
39
+
40
+ Nothing here starts a terminal, joins the broker that lets terminal sessions message each other, or checks for a
41
+ release.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import importlib
47
+ from typing import TYPE_CHECKING, Any
48
+
49
+ if TYPE_CHECKING: # pragma: no cover - for type checkers and editors; at runtime the names load on first use
50
+ from .client import Client, prompt, prompt_sync
51
+ from .errors import ConfigurationError, RunError, ZettCodeError
52
+ from .events import (
53
+ ApprovalRequested,
54
+ Finished,
55
+ QuestionAsked,
56
+ ReasoningDelta,
57
+ TextDelta,
58
+ ToolFinished,
59
+ ToolStarted,
60
+ )
61
+ from .handlers import approve_all, approve_commands, deny_all
62
+ from .models import add_model, list_models, remove_model, update_model
63
+ from .session import RunStream, Session
64
+ from .types import (
65
+ CacheMode,
66
+ DayTotals,
67
+ Image,
68
+ IncomingMessage,
69
+ MentionInfo,
70
+ ModelCheck,
71
+ ModelTotals,
72
+ PluginEntry,
73
+ Receipt,
74
+ RunResult,
75
+ SessionStatsInfo,
76
+ SessionTotals,
77
+ StoredSession,
78
+ TaskInfo,
79
+ Totals,
80
+ Usage,
81
+ WorkspaceUsage,
82
+ )
83
+
84
+ #: Where each public name lives. The package resolves a name by importing only that module.
85
+ _EXPORTS: dict[str, str] = {
86
+ "Client": ".client",
87
+ "prompt": ".client",
88
+ "prompt_sync": ".client",
89
+ "Session": ".session",
90
+ "RunStream": ".session",
91
+ "RunResult": ".types",
92
+ "TurnCommandResult": ".commands",
93
+ "TextCommandResult": ".commands",
94
+ "PlainCommandContext": ".commands",
95
+ "PlainCommand": ".commands",
96
+ "Usage": ".types",
97
+ "Image": ".types",
98
+ "IncomingMessage": ".types",
99
+ "Receipt": ".types",
100
+ "MentionInfo": ".types",
101
+ "PluginEntry": ".types",
102
+ "CacheMode": ".types",
103
+ "DayTotals": ".types",
104
+ "ModelCheck": ".types",
105
+ "ModelTotals": ".types",
106
+ "SessionStatsInfo": ".types",
107
+ "SessionTotals": ".types",
108
+ "StoredSession": ".types",
109
+ "TaskInfo": ".types",
110
+ "Totals": ".types",
111
+ "WorkspaceUsage": ".types",
112
+ "TextDelta": ".events",
113
+ "ReasoningDelta": ".events",
114
+ "ToolStarted": ".events",
115
+ "ServerToolStarted": ".events",
116
+ "ServerToolFinished": ".events",
117
+ "ToolFinished": ".events",
118
+ "ApprovalRequested": ".events",
119
+ "QuestionAsked": ".events",
120
+ "Finished": ".events",
121
+ "approve_all": ".handlers",
122
+ "approve_commands": ".handlers",
123
+ "deny_all": ".handlers",
124
+ "ZettCodeError": ".errors",
125
+ "ConfigurationError": ".errors",
126
+ "RunError": ".errors",
127
+ "add_model": ".models",
128
+ "list_models": ".models",
129
+ "remove_model": ".models",
130
+ "update_model": ".models",
131
+ }
132
+
133
+ #: Kept literal so linters and editors can read it; a test checks it names exactly the keys of ``_EXPORTS``.
134
+ __all__ = [
135
+ "ApprovalRequested",
136
+ "CacheMode",
137
+ "Client",
138
+ "ConfigurationError",
139
+ "DayTotals",
140
+ "Finished",
141
+ "Image",
142
+ "IncomingMessage",
143
+ "MentionInfo",
144
+ "PluginEntry",
145
+ "ModelCheck",
146
+ "ModelTotals",
147
+ "QuestionAsked",
148
+ "ReasoningDelta",
149
+ "Receipt",
150
+ "RunError",
151
+ "RunResult",
152
+ "TurnCommandResult",
153
+ "TextCommandResult",
154
+ "PlainCommandContext",
155
+ "PlainCommand",
156
+ "RunStream",
157
+ "ServerToolFinished",
158
+ "ServerToolStarted",
159
+ "Session",
160
+ "SessionStatsInfo",
161
+ "SessionTotals",
162
+ "StoredSession",
163
+ "TaskInfo",
164
+ "TextDelta",
165
+ "ToolFinished",
166
+ "ToolStarted",
167
+ "Totals",
168
+ "Usage",
169
+ "WorkspaceUsage",
170
+ "ZettCodeError",
171
+ "add_model",
172
+ "approve_all",
173
+ "approve_commands",
174
+ "deny_all",
175
+ "list_models",
176
+ "prompt",
177
+ "prompt_sync",
178
+ "remove_model",
179
+ "update_model",
180
+ ]
181
+
182
+
183
+ def __getattr__(name: str) -> Any:
184
+ """Load a public name's module on first use, and keep the name so the lookup happens once."""
185
+ module = _EXPORTS.get(name)
186
+ if module is None:
187
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
188
+ value = getattr(importlib.import_module(module, __name__), name)
189
+ globals()[name] = value
190
+ return value
191
+
192
+
193
+ def __dir__() -> list[str]:
194
+ """List the public names, so completion shows them without loading anything."""
195
+ return sorted({*globals(), *_EXPORTS})