zettcode-sdk 0.4.1__tar.gz → 0.4.2__tar.gz
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_sdk-0.4.1 → zettcode_sdk-0.4.2}/.gitignore +5 -0
- {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/PKG-INFO +14 -2
- {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/pyproject.toml +20 -16
- {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/daemon/process.py +27 -9
- {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/daemon/run.py +48 -5
- {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/daemon/state.py +25 -0
- zettcode_sdk-0.4.2/src/zettcode/mentions.py +120 -0
- {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/builtins.py +62 -310
- {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/client.py +246 -26
- zettcode_sdk-0.4.2/src/zettcode/sdk/commandline.py +74 -0
- zettcode_sdk-0.4.2/src/zettcode/sdk/commands.py +21 -0
- zettcode_sdk-0.4.2/src/zettcode/sdk/relay.py +50 -0
- {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/session.py +20 -26
- {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/translation.py +33 -4
- {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/types.py +7 -2
- zettcode_sdk-0.4.1/src/zettcode/mentions.py +0 -320
- zettcode_sdk-0.4.1/src/zettcode/sdk/commands.py +0 -9
- zettcode_sdk-0.4.1/src/zettcode/sdk/extensions/mcp.py +0 -276
- zettcode_sdk-0.4.1/src/zettcode/sdk/extensions/messaging.py +0 -281
- zettcode_sdk-0.4.1/src/zettcode/sdk/messenger.py +0 -299
- {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/daemon/__init__.py +0 -0
- {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/daemon/__main__.py +0 -0
- {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/daemon/cli.py +0 -0
- {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/daemon/companions.py +0 -0
- {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/doctor.py +0 -0
- {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/__init__.py +0 -0
- {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/daemons.py +0 -0
- {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/errors.py +0 -0
- {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/events.py +0 -0
- {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/extensions/__init__.py +0 -0
- {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/handlers.py +0 -0
- {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/models.py +0 -0
- {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/py.typed +0 -0
- {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/table.py +0 -0
|
@@ -35,3 +35,8 @@ docs/internal/palettes.html
|
|
|
35
35
|
|
|
36
36
|
# The release order is generated from the pyproject.toml files (`make order`); it is not kept in git.
|
|
37
37
|
packages/order.txt
|
|
38
|
+
|
|
39
|
+
# The desktop and phone apps build the gateway's page into their own folders, and electron-builder writes its
|
|
40
|
+
# installers under release/.
|
|
41
|
+
clients/desktop/renderer/
|
|
42
|
+
clients/desktop/release/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: zettcode-sdk
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: The documented, stable Python surface over ZettCode: sessions, runs, models, daemons
|
|
5
5
|
Project-URL: Repository, https://github.com/Chang-LeHung/zettcode
|
|
6
6
|
Author: Chang-LeHung
|
|
@@ -16,6 +16,18 @@ Classifier: Programming Language :: Python :: 3.15
|
|
|
16
16
|
Classifier: Typing :: Typed
|
|
17
17
|
Requires-Python: >=3.10
|
|
18
18
|
Requires-Dist: httpx<1,>=0.27
|
|
19
|
-
Requires-Dist: zett-agent<0.3,>=0.2.
|
|
19
|
+
Requires-Dist: zett-agent<0.3,>=0.2.10
|
|
20
|
+
Requires-Dist: zettcode-ask-user
|
|
20
21
|
Requires-Dist: zettcode-broker
|
|
21
22
|
Requires-Dist: zettcode-engine
|
|
23
|
+
Requires-Dist: zettcode-files
|
|
24
|
+
Requires-Dist: zettcode-guidance
|
|
25
|
+
Requires-Dist: zettcode-instructions
|
|
26
|
+
Requires-Dist: zettcode-jit-harness
|
|
27
|
+
Requires-Dist: zettcode-mcp
|
|
28
|
+
Requires-Dist: zettcode-memory
|
|
29
|
+
Requires-Dist: zettcode-messaging
|
|
30
|
+
Requires-Dist: zettcode-skills
|
|
31
|
+
Requires-Dist: zettcode-todos
|
|
32
|
+
Requires-Dist: zettcode-tool-output
|
|
33
|
+
Requires-Dist: zettcode-truncation
|
|
@@ -19,33 +19,37 @@ classifiers = [
|
|
|
19
19
|
dependencies = [
|
|
20
20
|
"zettcode-engine",
|
|
21
21
|
"zettcode-broker",
|
|
22
|
-
|
|
22
|
+
# Parts of the agent that live in distributions of their own, found through the same `zettcode.plugin` group as
|
|
23
|
+
# the ones published here. An SDK client offers skills and MCP servers and runs the same agent, so it installs them
|
|
24
|
+
# all; `require_agent_parts` refuses a start that lacks one.
|
|
25
|
+
"zettcode-skills",
|
|
26
|
+
"zettcode-messaging",
|
|
27
|
+
"zettcode-mcp",
|
|
28
|
+
"zettcode-files",
|
|
29
|
+
"zettcode-jit-harness",
|
|
30
|
+
"zettcode-ask-user",
|
|
31
|
+
"zettcode-instructions",
|
|
32
|
+
"zettcode-memory",
|
|
33
|
+
"zettcode-todos",
|
|
34
|
+
"zettcode-tool-output",
|
|
35
|
+
"zettcode-truncation",
|
|
36
|
+
"zettcode-guidance",
|
|
37
|
+
"zett-agent>=0.2.10,<0.3",
|
|
23
38
|
"httpx>=0.27,<1",
|
|
24
39
|
]
|
|
25
40
|
|
|
26
|
-
# The plugins that compose the agent. A part of the program, not a plugin: the `zettcode
|
|
27
|
-
# plugin release line looks for. They are activated by the rank `builtins.py` gives them, then by their `deps`.
|
|
28
|
-
[project.entry-points."zettcode.
|
|
29
|
-
skills = "zettcode.sdk.builtins:SkillResources"
|
|
30
|
-
files = "zettcode.sdk.builtins:FileResources"
|
|
41
|
+
# The plugins that compose the agent. A part of the program, not a plugin: the `[tool.zettcode] plugin = true`
|
|
42
|
+
# marker is what the plugin release line looks for. They are activated by the rank `builtins.py` gives them, then by their `deps`.
|
|
43
|
+
[project.entry-points."zettcode.plugin"]
|
|
31
44
|
coding = "zettcode.sdk.builtins:CodingTools"
|
|
32
|
-
|
|
33
|
-
long-output = "zettcode.sdk.builtins:LongOutput"
|
|
45
|
+
commandline = "zettcode.sdk.builtins:CommandLine"
|
|
34
46
|
capabilities = "zettcode.sdk.builtins:Capabilities"
|
|
35
47
|
subagents = "zettcode.sdk.builtins:Subagents"
|
|
36
|
-
messaging = "zettcode.sdk.builtins:Messaging"
|
|
37
48
|
shell-permission = "zettcode.sdk.builtins:ShellPermission"
|
|
38
|
-
harness = "zettcode.sdk.builtins:Harness"
|
|
39
49
|
session-log = "zettcode.sdk.builtins:SessionLog"
|
|
40
|
-
todos = "zettcode.sdk.builtins:TodoPlan"
|
|
41
50
|
usage-tracking = "zettcode.sdk.builtins:UsageTracking"
|
|
42
51
|
context-tracking = "zettcode.sdk.builtins:ContextTracking"
|
|
43
52
|
side-questions = "zettcode.sdk.builtins:SideQuestionBoundary"
|
|
44
|
-
tool-guidelines = "zettcode.sdk.builtins:ToolGuidance"
|
|
45
|
-
ask-user = "zettcode.sdk.builtins:AskUser"
|
|
46
|
-
project-instructions = "zettcode.sdk.builtins:ProjectInstructions"
|
|
47
|
-
skill-tool = "zettcode.sdk.builtins:SkillTool"
|
|
48
|
-
mcp = "zettcode.sdk.builtins:McpTools"
|
|
49
53
|
self-compaction = "zettcode.sdk.builtins:SelfCompaction"
|
|
50
54
|
compaction = "zettcode.sdk.builtins:Compaction"
|
|
51
55
|
|
|
@@ -186,7 +186,9 @@ class Daemons:
|
|
|
186
186
|
|
|
187
187
|
By default the record is removed too (``kill``); with ``keep`` it stays, as a dead one, so the daemon is
|
|
188
188
|
still listed and can be started again or deleted. A dead record is only removed (or, with ``keep``, left):
|
|
189
|
-
the process it described is gone already.
|
|
189
|
+
the process it described is gone already. The one record ``keep`` does not keep is a folder with no readable
|
|
190
|
+
state: it names no session and no workspace, so there is nothing to start again, and it is removed. Whether
|
|
191
|
+
it is ``working`` is read from the state file, which can be a
|
|
190
192
|
moment old, so a daemon that has just changed turn may be judged by the one before; ``--force`` is the answer to
|
|
191
193
|
that, not a retry. The session is never touched — it is a conversation like any
|
|
192
194
|
other, and ``zettcode resume <id>`` opens it afterwards.
|
|
@@ -201,6 +203,7 @@ class Daemons:
|
|
|
201
203
|
return "removed: it never recorded what it was"
|
|
202
204
|
if not record.alive:
|
|
203
205
|
if keep:
|
|
206
|
+
record.end_beat()
|
|
204
207
|
return "was already gone; its record was kept"
|
|
205
208
|
record.remove()
|
|
206
209
|
return "was already gone; its record was removed"
|
|
@@ -218,22 +221,37 @@ class Daemons:
|
|
|
218
221
|
deadline = time.monotonic() + grace
|
|
219
222
|
while time.monotonic() < deadline:
|
|
220
223
|
# The daemon removes its own record on the way out (unless asked to keep it), so the folder going is the
|
|
221
|
-
# proof it stopped — a pid can outlive the process it named, as a zombie its parent has not reaped.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
224
|
+
# proof it stopped — a pid can outlive the process it named, as a zombie its parent has not reaped. A
|
|
225
|
+
# record that was asked to stay has no folder to go, and its proof is the heartbeat the daemon takes away.
|
|
226
|
+
gone = not record.folder.exists()
|
|
227
|
+
if gone or not pid_runs(pid) or (keep and not record.beating()):
|
|
228
|
+
if not keep:
|
|
229
|
+
record.remove()
|
|
230
|
+
return "stopped"
|
|
231
|
+
if gone:
|
|
232
|
+
return "stopped; its record was removed"
|
|
233
|
+
# The process may have left without reaching its own last act (killed from elsewhere, an error while
|
|
234
|
+
# the client closed), and then the heartbeat is still fresh: take it away, so the record reads dead by
|
|
235
|
+
# the rule and not by a pid the system can hand to another process within the beat's thirty seconds.
|
|
236
|
+
record.end_beat()
|
|
237
|
+
return "stopped; its record was kept"
|
|
227
238
|
time.sleep(POLL_SECONDS)
|
|
228
239
|
_signal(pid, getattr(signal, "SIGKILL", signal.SIGTERM))
|
|
229
|
-
if
|
|
240
|
+
if keep:
|
|
241
|
+
record.end_beat() # killed, it cannot take its own heartbeat away
|
|
242
|
+
else:
|
|
230
243
|
record.remove()
|
|
231
244
|
return "did not stop in time, so it was killed"
|
|
232
245
|
|
|
233
246
|
@staticmethod
|
|
234
247
|
def _gone(record: Record, keep: bool, said: str) -> str:
|
|
235
|
-
"""Finish a stop whose process was already gone: keep the record or remove it.
|
|
248
|
+
"""Finish a stop whose process was already gone: keep the record or remove it.
|
|
249
|
+
|
|
250
|
+
A kept record loses its heartbeat like on the other two ways out, so it reads as dead by the same rule and not
|
|
251
|
+
by the pid alone, which a busy machine can reuse before the beat has gone stale.
|
|
252
|
+
"""
|
|
236
253
|
if keep:
|
|
254
|
+
record.end_beat()
|
|
237
255
|
return f"{said}; its record was kept"
|
|
238
256
|
record.remove()
|
|
239
257
|
return f"{said}; its record was removed"
|
|
@@ -24,7 +24,7 @@ from pathlib import Path
|
|
|
24
24
|
from typing import TYPE_CHECKING, Any
|
|
25
25
|
|
|
26
26
|
from zettcode.error_text import may_pass
|
|
27
|
-
from zettcode.
|
|
27
|
+
from zettcode.messaging.relayed import batch_size
|
|
28
28
|
from zettcode.sdk.types import DIRECT_EMPTY, DIRECT_FAILED, IncomingMessage, Receipt
|
|
29
29
|
|
|
30
30
|
from .state import Record, Registry, State
|
|
@@ -98,7 +98,7 @@ class Inbox:
|
|
|
98
98
|
def done(self) -> IncomingMessage | None:
|
|
99
99
|
"""Close the turn that ended: return the next message to run, or ``None`` when the daemon is idle again.
|
|
100
100
|
|
|
101
|
-
The messages that waited come back as one, in order (see :func:`~zettcode.
|
|
101
|
+
The messages that waited come back as one, in order (see :func:`~zettcode.messaging.relayed.batch_size`), so a daemon that
|
|
102
102
|
was busy answers them in a single turn instead of one turn each; a chat window's message is always its own.
|
|
103
103
|
"""
|
|
104
104
|
if self._waiting:
|
|
@@ -160,7 +160,11 @@ class Daemon:
|
|
|
160
160
|
"""Take a message from the broker: refused at once when it carries pictures this model cannot read.
|
|
161
161
|
|
|
162
162
|
The sender is told then and there, in words it can act on, rather than after a turn that was bound to fail.
|
|
163
|
+
A daemon that has been asked to stop takes nothing: the turn would run while the client is being closed, and
|
|
164
|
+
its first state write would put a heartbeat back after the stop took it away.
|
|
163
165
|
"""
|
|
166
|
+
if self.stopping.is_set():
|
|
167
|
+
return Receipt(False, "this daemon is stopping")
|
|
164
168
|
if message.images:
|
|
165
169
|
try:
|
|
166
170
|
model = self.client.model
|
|
@@ -196,12 +200,30 @@ class Daemon:
|
|
|
196
200
|
# answers. A stop that asked to keep it leaves it, as a dead one the reader can start again or delete.
|
|
197
201
|
if not self._kept():
|
|
198
202
|
self.record.remove()
|
|
203
|
+
else:
|
|
204
|
+
# Nothing removes the folder now, so the daemon says it is gone the one way that does not depend on
|
|
205
|
+
# its pid: it takes its heartbeat away, and the record reads as dead from here on.
|
|
206
|
+
self.record.end_beat()
|
|
199
207
|
return EXIT_OK
|
|
200
208
|
|
|
201
209
|
def _kept(self) -> bool:
|
|
202
210
|
"""Return whether the stop that is ending this process asked for the record to stay."""
|
|
203
|
-
|
|
204
|
-
|
|
211
|
+
return self._asked_to_keep(State.load(self.record.folder))
|
|
212
|
+
|
|
213
|
+
def _asked_to_keep(self, current: State | None) -> bool:
|
|
214
|
+
"""Return whether ``current`` carries the ask to keep the record that was made of *this* process.
|
|
215
|
+
|
|
216
|
+
A stop writes ``keep`` into the state before it signals, and every later write of this process carries it, so
|
|
217
|
+
the daemon's last write cannot put it back: whatever status a turn that was already starting wrote over the
|
|
218
|
+
stop's, the field survives it. What may not be believed is a field left in the folder by an earlier start of
|
|
219
|
+
the same session (a daemon started again on a kept record finds it there, and would leave a record behind on
|
|
220
|
+
Ctrl-C that it used to remove). The token is this start's own and the pid is this process's: the token alone
|
|
221
|
+
names the state this process wrote, and the pid alone could be a stopped daemon's pid the system gave to the
|
|
222
|
+
new one, so both have to say so.
|
|
223
|
+
"""
|
|
224
|
+
return bool(
|
|
225
|
+
current is not None and current.keep and current.token == self.options.token and current.pid == os.getpid()
|
|
226
|
+
)
|
|
205
227
|
|
|
206
228
|
# -- what it answers ---------------------------------------------------------------------------
|
|
207
229
|
def _start(self, message: IncomingMessage) -> None:
|
|
@@ -209,7 +231,13 @@ class Daemon:
|
|
|
209
231
|
asyncio.ensure_future(self._answer(message))
|
|
210
232
|
|
|
211
233
|
async def _answer(self, message: IncomingMessage) -> None:
|
|
212
|
-
"""Run one turn, then the next waiting message, and go idle when there is none.
|
|
234
|
+
"""Run one turn, then the next waiting message, and go idle when there is none.
|
|
235
|
+
|
|
236
|
+
A message queued before the stop is not answered after it: the process is leaving, and the turn would run
|
|
237
|
+
while the client is being closed.
|
|
238
|
+
"""
|
|
239
|
+
if self.stopping.is_set():
|
|
240
|
+
return
|
|
213
241
|
try:
|
|
214
242
|
await self._apply_requests()
|
|
215
243
|
await self.mark("working")
|
|
@@ -399,7 +427,13 @@ class Daemon:
|
|
|
399
427
|
|
|
400
428
|
The heartbeat is touched here as well as on its own clock: the first state a reader can see is written with
|
|
401
429
|
proof of life beside it, so a daemon is never listed as dead between its start and its first beat.
|
|
430
|
+
|
|
431
|
+
Once the process has been asked to stop, the only state it writes is ``stopping``: whatever a task that was
|
|
432
|
+
already running says after that would also beat, and a heartbeat written after the daemon took its own away
|
|
433
|
+
reads as a daemon that is still there.
|
|
402
434
|
"""
|
|
435
|
+
if self.stopping.is_set() and status != "stopping":
|
|
436
|
+
return
|
|
403
437
|
self.record.beat()
|
|
404
438
|
sent, made, context = self._spend()
|
|
405
439
|
state = State(
|
|
@@ -431,6 +465,10 @@ class Daemon:
|
|
|
431
465
|
requested_model=current.requested_model if current is not None else None,
|
|
432
466
|
requested_effort=current.requested_effort if current is not None else None,
|
|
433
467
|
requested_error=current.requested_error if current is not None else None,
|
|
468
|
+
# A stop that wants the record kept writes the field before it signals, and this write is the
|
|
469
|
+
# daemon's last: built afresh, the state would say "do not keep" and the daemon would remove
|
|
470
|
+
# the record it was asked to leave. What counts as this process's ask is `_asked_to_keep`.
|
|
471
|
+
keep=self._asked_to_keep(current),
|
|
434
472
|
),
|
|
435
473
|
wait=0,
|
|
436
474
|
)
|
|
@@ -619,6 +657,11 @@ async def serve(options: Options) -> int:
|
|
|
619
657
|
await client.sessions.open(options.session)
|
|
620
658
|
except (ConfigurationError, SessionNotFoundError, SessionLockedError) as error:
|
|
621
659
|
return _failed(str(error))
|
|
660
|
+
if client.session.locked:
|
|
661
|
+
# `open` does not refuse a session another process holds: it selects it, to be read and not
|
|
662
|
+
# written. That is a fine thing for a reader and no thing at all for a daemon, whose every turn
|
|
663
|
+
# would fail on the first write while the daemon sat there listed as idle.
|
|
664
|
+
return _failed("this session is open in another ZettCode process, so a daemon cannot write to it")
|
|
622
665
|
if options.title:
|
|
623
666
|
# The session this process holds, so the name is written where the daemon and `/resume` both read it.
|
|
624
667
|
try:
|
|
@@ -182,6 +182,15 @@ class Record:
|
|
|
182
182
|
Reading a record reads its state once, at construction, and whether the process is there: a command that lists
|
|
183
183
|
ten daemons asks the disk ten times, and nothing between two reads of one record can change the answer without
|
|
184
184
|
the file changing too.
|
|
185
|
+
|
|
186
|
+
**When a record reads as alive.** Only while its pid answers *and* its heartbeat is fresh; either alone is not
|
|
187
|
+
proof (a pid is reused, and a zombie its parent has not reaped still answers signal zero). A record that is left
|
|
188
|
+
behind on purpose (a stop that asked to keep it) has no folder going away to say the process has stopped, so the
|
|
189
|
+
process says it itself: :meth:`end_beat` is the last thing it writes, and **nothing may beat after it** — which is
|
|
190
|
+
why ``Daemon.mark`` refuses every state but ``stopping`` once the daemon is stopping, ``take`` and ``_answer``
|
|
191
|
+
refuse work, and the stop paths in :mod:`.process` that find the process already gone take the beat away too.
|
|
192
|
+
A new writer of this folder that touches the heartbeat has to respect that, or a stopped daemon reads as running
|
|
193
|
+
for the thirty seconds a beat stays fresh (invariant H29).
|
|
185
194
|
"""
|
|
186
195
|
|
|
187
196
|
def __init__(self, folder: Path) -> None:
|
|
@@ -270,6 +279,22 @@ class Record:
|
|
|
270
279
|
except OSError:
|
|
271
280
|
pass
|
|
272
281
|
|
|
282
|
+
def end_beat(self) -> None:
|
|
283
|
+
"""Take the heartbeat away: the daemon's last word when it leaves its record behind.
|
|
284
|
+
|
|
285
|
+
A record that stays has no folder going away to say the process has stopped, and a pid is no proof either —
|
|
286
|
+
a zombie its parent has not reaped still answers signal zero. No heartbeat is: the record then reads as dead
|
|
287
|
+
at once, instead of after the thirty seconds a stale beat takes.
|
|
288
|
+
"""
|
|
289
|
+
try:
|
|
290
|
+
(self.folder / BEAT_FILE).unlink()
|
|
291
|
+
except OSError:
|
|
292
|
+
pass
|
|
293
|
+
|
|
294
|
+
def beating(self) -> bool:
|
|
295
|
+
"""Return whether a heartbeat file is there to be read, whatever its age."""
|
|
296
|
+
return (self.folder / BEAT_FILE).exists()
|
|
297
|
+
|
|
273
298
|
def log(self, text: str) -> None:
|
|
274
299
|
"""Append one line to the daemon's log; a log that cannot be written is not worth failing a daemon over."""
|
|
275
300
|
stamp = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
@@ -0,0 +1,120 @@
|
|
|
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 (other sessions here; files in ``zettcode-files``, skills in ``zettcode-skills``) and both
|
|
5
|
+
lists 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
|
+
from collections.abc import Callable, Sequence
|
|
14
|
+
|
|
15
|
+
from zettcode.broker.text import folder_name
|
|
16
|
+
from zettcode.mention import Mention, MentionProvider, MentionRegistry
|
|
17
|
+
from zettcode.wire import RosterPeer
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Mention",
|
|
21
|
+
"MentionProvider",
|
|
22
|
+
"MentionRegistry",
|
|
23
|
+
"RosterPeer",
|
|
24
|
+
"SessionMentions",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SessionMentions(MentionProvider):
|
|
29
|
+
"""The other ZettCode sessions that are running, addressed by the name they are listed under.
|
|
30
|
+
|
|
31
|
+
A session named anywhere in a draft is a reference like any other: the model reads ``session "name"``
|
|
32
|
+
and is told how to reach that session, and decides for itself whether the request calls for a message.
|
|
33
|
+
The shell sends nothing itself (see :mod:`zettcode.app.ui.messaging`).
|
|
34
|
+
|
|
35
|
+
Candidates are read from what the connection last reported — a list in memory — and never from
|
|
36
|
+
the broker: completion runs on every keystroke after an ``@``.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
kind = "session"
|
|
40
|
+
#: A session is the most specific thing a name can mean, ahead of a skill and a file.
|
|
41
|
+
precedence = -20
|
|
42
|
+
|
|
43
|
+
def __init__(self, peers: Callable[[], Sequence[RosterPeer]]) -> None:
|
|
44
|
+
"""Offer whichever sessions ``peers`` returns when asked.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
peers: The other sessions as of now; called for every query, so it must not block.
|
|
48
|
+
"""
|
|
49
|
+
self._peers = peers
|
|
50
|
+
|
|
51
|
+
def candidates(self, query: str) -> Sequence[Mention]:
|
|
52
|
+
"""List the sessions ``query`` could mean, the likeliest first.
|
|
53
|
+
|
|
54
|
+
A reader looks for a session by what they remember of it, which is its name, what it was about,
|
|
55
|
+
the folder it works in, or the start of its id — so any of those matches, with a session whose
|
|
56
|
+
*name* starts with the query before one that only mentions it elsewhere. The token offered is
|
|
57
|
+
always the name, because that is what can be sent to.
|
|
58
|
+
"""
|
|
59
|
+
lowered = query.lower()
|
|
60
|
+
ranked: list[tuple[int, int, RosterPeer]] = []
|
|
61
|
+
for order, peer in enumerate(self._peers()):
|
|
62
|
+
if peer.handle.lower().startswith(lowered):
|
|
63
|
+
ranked.append((0, order, peer))
|
|
64
|
+
elif (
|
|
65
|
+
lowered in peer.handle.lower() or lowered in peer.title.lower() or lowered in self._folder(peer).lower()
|
|
66
|
+
):
|
|
67
|
+
ranked.append((1, order, peer))
|
|
68
|
+
elif len(lowered) >= 4 and peer.session_id.lower().startswith(lowered):
|
|
69
|
+
ranked.append((2, order, peer))
|
|
70
|
+
return tuple(
|
|
71
|
+
Mention(token=f"@{peer.handle}", kind=self.kind, name=peer.handle, description=self._describe(peer))
|
|
72
|
+
for _, _, peer in sorted(ranked, key=lambda item: item[:2])
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def _folder(peer: RosterPeer) -> str:
|
|
77
|
+
"""Return the last part of the directory the session works in."""
|
|
78
|
+
return folder_name(peer.workspace)
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def _describe(cls, peer: RosterPeer) -> str:
|
|
82
|
+
"""Return the menu's line for a session: what it is about, whether it is working, where it is."""
|
|
83
|
+
parts = [peer.title or "untitled"]
|
|
84
|
+
if peer.status != "idle":
|
|
85
|
+
parts.append(peer.status)
|
|
86
|
+
folder = cls._folder(peer)
|
|
87
|
+
if folder:
|
|
88
|
+
parts.append(folder)
|
|
89
|
+
return " \u00b7 ".join(parts)
|
|
90
|
+
|
|
91
|
+
def rename(self, name: str) -> str | None:
|
|
92
|
+
"""Put ``session "work"`` where the reader typed ``@work``: the model has no use for the ``@``."""
|
|
93
|
+
return f'session "{name}"' if self._find(name) is not None else None
|
|
94
|
+
|
|
95
|
+
def _find(self, name: str) -> RosterPeer | None:
|
|
96
|
+
"""Return the session called ``name`` among those running now."""
|
|
97
|
+
return next((peer for peer in self._peers() if peer.handle == name), None)
|
|
98
|
+
|
|
99
|
+
def expand(self, name: str) -> str | None:
|
|
100
|
+
"""Tell the model what the reader's ``@`` meant and how to reach that session.
|
|
101
|
+
|
|
102
|
+
Nothing is sent from here: the model is told which session the words mean and how to reach it, and
|
|
103
|
+
decides for itself whether the request calls for a message. A draft that begins with the name is no
|
|
104
|
+
different from one that names the session inside a sentence.
|
|
105
|
+
"""
|
|
106
|
+
peer = self._find(name)
|
|
107
|
+
if peer is None:
|
|
108
|
+
return None
|
|
109
|
+
where = f" in {peer.workspace}" if peer.workspace else ""
|
|
110
|
+
about = f' It is titled "{peer.title}".' if peer.title else ""
|
|
111
|
+
return (
|
|
112
|
+
f'In the message above, `session "{name}"` is what the user wrote as @{name}. In ZettCode an @ before a '
|
|
113
|
+
f"name means another ZettCode session that is running right now, not a file or a skill. This one works{where}.{about}\n"
|
|
114
|
+
f"When the user's message is addressed to that session — the @ begins the message — what follows the name "
|
|
115
|
+
f"is the message to pass on. When the session is only mentioned, decide whether the request is for it. "
|
|
116
|
+
f'To send, call the send_message tool: send_message(to="{peer.session_id}", message=...). Write the '
|
|
117
|
+
f"message so the other session can act on it without having seen this conversation. If the request is only "
|
|
118
|
+
f"about the session (what it is doing, where it is), list_sessions answers that. Do not send anything the "
|
|
119
|
+
f"user did not ask you to pass on."
|
|
120
|
+
)
|