zettcode-recap 0.1.0__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.
@@ -0,0 +1,34 @@
1
+ **/.venv/
2
+ **/__pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .smoke/
8
+ .coverage
9
+ **/.pytest_cache/
10
+ **/.ruff_cache/
11
+ .zett-tool-output/
12
+ .env
13
+ .DS_Store
14
+
15
+ # viztracer writes its trace here by default.
16
+ /result.json
17
+
18
+ # The docs site: dependencies, build output, and VitePress' cache.
19
+ node_modules/
20
+ docs/.vitepress/dist/
21
+ docs/.vitepress/cache/
22
+ docs/.vitepress/.temp/
23
+
24
+ # The TypeScript client is built from its sources; only dist/ is published.
25
+ clients/typescript/dist/
26
+
27
+ # The gateway's page is built from clients/gateway into the package when a distribution is made (`make gateway-ui`);
28
+ # the build output is not kept in the repository. pyproject's `artifacts` puts it in the wheel all the same.
29
+ zettcode/src/zettcode/gateway/static/
30
+
31
+ # The palette gallery is generated by `make palettes`: every widget painted in
32
+ # every palette, written by docs/scripts/palette_gallery.py. Regenerate it
33
+ # rather than committing a 2 MB copy that goes stale the moment a colour moves.
34
+ docs/internal/palettes.html
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chang-LeHung
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.5
2
+ Name: zettcode-recap
3
+ Version: 0.1.0
4
+ Summary: A ZettCode plugin: /recap sums up where the session is, and does so on its own when you step away
5
+ Project-URL: Repository, https://github.com/Chang-LeHung/zettcode/tree/main/plugins/zettcode-recap
6
+ Project-URL: Issues, https://github.com/Chang-LeHung/zettcode/issues
7
+ Author: Chang-LeHung
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: plugin,productivity,recap,summary,zettcode
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Programming Language :: Python :: 3.15
19
+ Classifier: Topic :: Software Development :: User Interfaces
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: zettcode<0.4,>=0.3
23
+ Description-Content-Type: text/markdown
24
+
25
+ # zettcode-recap
26
+
27
+ A ZettCode plugin. It adds `/recap`, and writes one on its own when you step away.
28
+
29
+ A recap is one sentence about where the session is: the goal, what is done, and the next thing to do. It is for a
30
+ reader coming back to a long conversation — the model's own context is not touched, and neither the question nor the
31
+ answer is stored as part of the conversation.
32
+
33
+ - **`/recap`** writes one now, whatever the clock says. It needs a request to have finished. While the model answers,
34
+ the pinned row says `Recap … writing`, and Ctrl-C or Escape stops it; the answer follows as a quoted message.
35
+ - **On its own**, once no new turn has started for long enough and the conversation is worth summarising: at least 3
36
+ messages you typed, and at least 2 since the last recap. The delay is `min(after_seconds, 0.8 × cache_ttl_seconds)`,
37
+ which is 180 seconds by default (the provider's prompt cache is likely cold by then, so waiting longer only costs
38
+ more). Any new turn cancels a recap that is waiting or being written: it would be news about a conversation that
39
+ has moved on. It is the reader's own activity that is measured, not the window — somebody reading the last answer
40
+ is still there, and somebody who switched to another application after a turn is as idle as one reading.
41
+
42
+ The summary is requested with `cache="read"`: the conversation's warm prompt-cache prefix is reused, and the recap's
43
+ own words are not written into the cache. It runs with no tools, a 60-second deadline, and a prompt that asks for
44
+ under 40 words and no markdown.
45
+
46
+ ## Settings
47
+
48
+ Each setting has a default and can be overridden in `config.toml`; your value wins:
49
+
50
+ ```toml
51
+ [plugins.recap]
52
+ after_seconds = 180 # how long no new turn may start
53
+ cache_ttl_seconds = 300 # the provider's prompt-cache lifetime; the delay is 0.8 of it
54
+ min_messages = 3 # messages you typed before a recap is worth writing
55
+ min_since_recap = 2 # and messages since the last one
56
+ ```
57
+
58
+ A value of the wrong type is refused when the plugin loads, naming the table and the key.
59
+
60
+ The automatic recap needs nothing of the terminal but turns; the focus hook the shell offers is not used. A recap
61
+ waiting when the program exits ends with it.
62
+
63
+ ## Install
64
+
65
+ From a checkout of this repository:
66
+
67
+ uv tool install --force --with ./plugins/zettcode-recap zettcode
68
+
69
+ or, with pip in the environment ZettCode runs in:
70
+
71
+ pip install ./plugins/zettcode-recap
72
+
73
+ There is no `zettcode[recap]` extra yet: the plugin has not been released, and an extra that names a package no index
74
+ carries would break `pip install "zettcode[recap]"`. The release workflow is in place (`recap-v*`), and the extra
75
+ lands with the first release.
76
+
77
+ It runs on ZettCode 0.3 and later (`zettcode>=0.3,<0.4`), whose terminal group has the turn hooks this plugin is
78
+ built on.
@@ -0,0 +1,54 @@
1
+ # zettcode-recap
2
+
3
+ A ZettCode plugin. It adds `/recap`, and writes one on its own when you step away.
4
+
5
+ A recap is one sentence about where the session is: the goal, what is done, and the next thing to do. It is for a
6
+ reader coming back to a long conversation — the model's own context is not touched, and neither the question nor the
7
+ answer is stored as part of the conversation.
8
+
9
+ - **`/recap`** writes one now, whatever the clock says. It needs a request to have finished. While the model answers,
10
+ the pinned row says `Recap … writing`, and Ctrl-C or Escape stops it; the answer follows as a quoted message.
11
+ - **On its own**, once no new turn has started for long enough and the conversation is worth summarising: at least 3
12
+ messages you typed, and at least 2 since the last recap. The delay is `min(after_seconds, 0.8 × cache_ttl_seconds)`,
13
+ which is 180 seconds by default (the provider's prompt cache is likely cold by then, so waiting longer only costs
14
+ more). Any new turn cancels a recap that is waiting or being written: it would be news about a conversation that
15
+ has moved on. It is the reader's own activity that is measured, not the window — somebody reading the last answer
16
+ is still there, and somebody who switched to another application after a turn is as idle as one reading.
17
+
18
+ The summary is requested with `cache="read"`: the conversation's warm prompt-cache prefix is reused, and the recap's
19
+ own words are not written into the cache. It runs with no tools, a 60-second deadline, and a prompt that asks for
20
+ under 40 words and no markdown.
21
+
22
+ ## Settings
23
+
24
+ Each setting has a default and can be overridden in `config.toml`; your value wins:
25
+
26
+ ```toml
27
+ [plugins.recap]
28
+ after_seconds = 180 # how long no new turn may start
29
+ cache_ttl_seconds = 300 # the provider's prompt-cache lifetime; the delay is 0.8 of it
30
+ min_messages = 3 # messages you typed before a recap is worth writing
31
+ min_since_recap = 2 # and messages since the last one
32
+ ```
33
+
34
+ A value of the wrong type is refused when the plugin loads, naming the table and the key.
35
+
36
+ The automatic recap needs nothing of the terminal but turns; the focus hook the shell offers is not used. A recap
37
+ waiting when the program exits ends with it.
38
+
39
+ ## Install
40
+
41
+ From a checkout of this repository:
42
+
43
+ uv tool install --force --with ./plugins/zettcode-recap zettcode
44
+
45
+ or, with pip in the environment ZettCode runs in:
46
+
47
+ pip install ./plugins/zettcode-recap
48
+
49
+ There is no `zettcode[recap]` extra yet: the plugin has not been released, and an extra that names a package no index
50
+ carries would break `pip install "zettcode[recap]"`. The release workflow is in place (`recap-v*`), and the extra
51
+ lands with the first release.
52
+
53
+ It runs on ZettCode 0.3 and later (`zettcode>=0.3,<0.4`), whose terminal group has the turn hooks this plugin is
54
+ built on.
@@ -0,0 +1,63 @@
1
+ [project]
2
+ name = "zettcode-recap"
3
+ description = "A ZettCode plugin: /recap sums up where the session is, and does so on its own when you step away"
4
+ readme = "README.md"
5
+ requires-python = ">=3.10"
6
+ dynamic = ["version"]
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{name = "Chang-LeHung"}]
10
+ keywords = ["zettcode", "plugin", "recap", "summary", "productivity"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Environment :: Console",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Programming Language :: Python :: 3.14",
19
+ "Programming Language :: Python :: 3.15",
20
+ "Topic :: Software Development :: User Interfaces",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = [
24
+ # The plugin API: `PluginContext`, `TuiPlugin`, and the terminal group's focus and turn-end hooks, which the 0.3
25
+ # line added.
26
+ "zettcode>=0.3,<0.4",
27
+ ]
28
+
29
+ [project.urls]
30
+ Repository = "https://github.com/Chang-LeHung/zettcode/tree/main/plugins/zettcode-recap"
31
+ Issues = "https://github.com/Chang-LeHung/zettcode/issues"
32
+
33
+ [project.entry-points."zettcode.plugins"]
34
+ recap = "zettcode_recap:RecapPlugin"
35
+
36
+ [build-system]
37
+ requires = ["hatchling"]
38
+ build-backend = "hatchling.build"
39
+
40
+ [tool.hatch.version]
41
+ path = "src/zettcode_recap/__init__.py"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/zettcode_recap"]
45
+
46
+ [tool.hatch.build.targets.sdist]
47
+ include = ["/src", "/tests", "/README.md", "/LICENSE", "/pyproject.toml"]
48
+
49
+ [dependency-groups]
50
+ dev = ["pytest", "pytest-asyncio"]
51
+
52
+ [tool.ruff]
53
+ line-length = 120
54
+ target-version = "py310"
55
+ src = ["src", "tests"]
56
+
57
+ [tool.ruff.lint]
58
+ select = ["E", "F", "I", "UP", "B"]
59
+ ignore = ["E501"]
60
+
61
+ [tool.pytest.ini_options]
62
+ testpaths = ["tests"]
63
+ asyncio_mode = "auto"
@@ -0,0 +1,301 @@
1
+ """``/recap``: a short account of what this session is doing, for a reader who stepped away.
2
+
3
+ The plugin watches the turns of the conversation: a turn ending arms a wait, and a turn starting cancels it. When the
4
+ reader has not started a new turn for long enough, it asks the session's model for one small summary, reusing the
5
+ conversation's warm prompt cache without writing its own content into it (``cache="read"``). ``/recap`` asks for one
6
+ at once.
7
+
8
+ The wait is about the reader's own activity, not about the window: somebody reading the last answer is still there,
9
+ and somebody who switched to another application after a turn is as idle as one reading. So the clock is "since the
10
+ last turn ended", and any new turn — typed, from another session, or started by a command — cancels it.
11
+
12
+ It never joins the conversation: the question and the answer are a side question, so the next turn does not read
13
+ them, and it is shown as a markdown message whose text is a blockquote — the quote the renderer already draws, with
14
+ nothing of its own in the transcript. Nothing about the model's own context changes.
15
+
16
+ The delay is ``min(after_seconds, 0.8 × cache_ttl_seconds)``, defaulting to ``min(180, 0.8 × 300)`` = 180 seconds:
17
+ after that long away, a provider's cached prefix is likely gone, so waiting longer would buy a colder, dearer
18
+ summary. All four numbers are written in the plugin's own table, and the reader's value wins:
19
+
20
+ [plugins.recap]
21
+ after_seconds = 180 # how long no new turn may start
22
+ cache_ttl_seconds = 300 # the provider's prompt-cache lifetime, which the delay is a fraction of
23
+ min_messages = 3 # a conversation worth summarising
24
+ min_since_recap = 2 # and worth summarising again
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ from collections.abc import Coroutine
31
+ from contextlib import suppress
32
+ from typing import TYPE_CHECKING
33
+
34
+ from zettcode.app.commands import CommandContext, CommandResult
35
+ from zettcode.app.plugins import HOST_LINK, HostLink, TuiPlugin
36
+ from zettcode.engine.plugins.context import PluginContext
37
+
38
+ if TYPE_CHECKING: # pragma: no cover - annotations only
39
+ from collections.abc import Mapping
40
+ from typing import Any
41
+
42
+ from zettcode.app.commands import CommandHost
43
+
44
+ #: The one place the version is written; ``pyproject.toml`` reads it from here.
45
+ __version__ = "0.1.0"
46
+
47
+ __all__ = ["RecapPlugin", "__version__"]
48
+
49
+ #: What the model reads. Short by design: a recap is a line to get back to work with, not a summary of the work.
50
+ PROMPT = (
51
+ "The user stepped away and is coming back. Recap in under 40 words, 1-2 plain sentences, no markdown. "
52
+ "Lead with the overall goal and current task, then the one next action. Skip root-cause narrative, fix "
53
+ "internals, secondary to-dos, and em-dash tangents."
54
+ )
55
+
56
+ #: How long one recap may take before it is dropped; one that arrives after the reader is working again is noise.
57
+ TIMEOUT_SECONDS = 60.0
58
+
59
+ #: The defaults, which a ``[plugins.recap]`` table overrides. See the module docstring for what they mean.
60
+ DEFAULTS: dict[str, object] = {
61
+ "after_seconds": 180,
62
+ "cache_ttl_seconds": 300,
63
+ "min_messages": 3,
64
+ "min_since_recap": 2,
65
+ }
66
+
67
+ #: What a recap line starts with: the label inside the quote the message renderer draws.
68
+ PREFIX = "Recap"
69
+
70
+ #: The character a working row trails off with (the transcript's own ellipsis).
71
+ ELLIPSIS = "\u2026"
72
+
73
+
74
+ def quote(text: str) -> str:
75
+ """Return the recap as a Markdown blockquote, one marker per line so a wrapped answer stays inside it."""
76
+ return "\n".join(
77
+ f"> **{PREFIX}** \u00b7 {line}" if at == 0 else f"> {line}" for at, line in enumerate(text.split("\n"))
78
+ )
79
+
80
+
81
+ def _number(given: Mapping[str, object], key: str, *, least: float) -> float:
82
+ """Return one declared setting as a number, refusing one that could never be meant."""
83
+ value = float(given[key]) # type: ignore[arg-type] # `declare` checked it against the default's type
84
+ if value < least:
85
+ raise ValueError(f"[plugins.recap] {key} must be at least {least:g}, not {value:g}")
86
+ return value
87
+
88
+
89
+ class RecapPlugin(TuiPlugin):
90
+ """Summarise the session when the reader has been away, and on ``/recap``."""
91
+
92
+ name = "recap"
93
+ description = "summarise where the session is when you step away"
94
+
95
+ def __init__(self) -> None:
96
+ """Start with nothing counted and no recap on its way."""
97
+ self._link: HostLink | None = None
98
+ #: Bumped by every turn start: a wait armed for an older one is for a conversation that has moved on.
99
+ self._generation = 0
100
+ self._turns = 0
101
+ self._since_recap = 0
102
+ self._seeded = False
103
+ self._session = ""
104
+ self._timer: asyncio.Task[None] | None = None
105
+ self._asking: asyncio.Task[None] | None = None
106
+ self.after_seconds = 180.0
107
+ self.cache_ttl_seconds = 300.0
108
+ self.min_messages = 3
109
+ self.min_since_recap = 2
110
+
111
+ def activate(self, ctx: PluginContext) -> None:
112
+ """Read the settings, take the shell link, and register the command and the two hooks."""
113
+ given = ctx.settings.declare(**DEFAULTS)
114
+ self.after_seconds = _number(given, "after_seconds", least=0)
115
+ self.cache_ttl_seconds = _number(given, "cache_ttl_seconds", least=0)
116
+ self.min_messages = int(_number(given, "min_messages", least=1))
117
+ self.min_since_recap = int(_number(given, "min_since_recap", least=1))
118
+ self._link = ctx.find(HOST_LINK, HostLink)
119
+ if ctx.tui is None:
120
+ return
121
+ # `/recap` asks the shell's own side question, which takes seconds, so it returns the work to do and the shell
122
+ # shows what it is doing until it is answered; Ctrl-C and Escape stop it.
123
+ ctx.tui.register_command("recap", "summarise where this session is", self.command, when_busy="refuse")
124
+ ctx.tui.register_turn_start_handler(self.turn_started)
125
+ ctx.tui.register_turn_end_handler(self.turn_ended)
126
+
127
+ # -- the delay ---------------------------------------------------------------------------------
128
+ @property
129
+ def delay(self) -> float:
130
+ """Return how long the conversation must be quiet — no new turn — before a recap is made."""
131
+ return min(self.after_seconds, 0.8 * self.cache_ttl_seconds)
132
+
133
+ def due(self) -> bool:
134
+ """Return whether the conversation is long enough, and fresh enough, to be worth a recap."""
135
+ return self._turns >= self.min_messages and self._since_recap >= self.min_since_recap
136
+
137
+ # -- what the shell reports --------------------------------------------------------------------
138
+ def turn_started(self) -> None:
139
+ """The reader is asking again: whatever recap was waiting or being written is about a conversation that
140
+ has moved on, so it stops here."""
141
+ self._generation += 1
142
+ self._cancel()
143
+
144
+ def turn_ended(self, completed: bool) -> None:
145
+ """Count the turn, and start the idle wait.
146
+
147
+ Called on the shell's event loop: the counting happens here, and only what has to wait — the stored history,
148
+ the clock, the model — becomes a task.
149
+ """
150
+ host = self._host()
151
+ if host is None:
152
+ return
153
+ # The counts describe the conversation, not the plugin: `/new` or another session leaves them behind.
154
+ if host.client.session.id != self._session:
155
+ self._session = host.client.session.id
156
+ self._turns = 0
157
+ self._since_recap = 0
158
+ self._seeded = False
159
+ self._cancel()
160
+ self._turns += 1
161
+ self._since_recap += 1
162
+ if not self._seeded:
163
+ self._seeded = True
164
+ self._spawn(self._seed())
165
+ if self.due():
166
+ self._schedule()
167
+
168
+ def _schedule(self) -> None:
169
+ """Wait :attr:`delay` seconds, then recap if no turn has started since."""
170
+ if self._timer is not None and not self._timer.done():
171
+ return
172
+ self._timer = self._spawn(self._wait_then_recap(self._generation))
173
+
174
+ def _cancel(self) -> None:
175
+ """Drop a waiting recap and stop one being written: a turn started or the session changed, so the wait and
176
+ any recap are about a conversation that has moved on."""
177
+ for task in (self._timer, self._asking):
178
+ if task is not None and not task.done():
179
+ task.cancel()
180
+ self._timer = None
181
+ self._asking = None
182
+
183
+ async def _wait_then_recap(self, generation: int) -> None:
184
+ """Sleep for the delay, then make the recap nobody asked for."""
185
+ try:
186
+ await asyncio.sleep(self.delay)
187
+ except asyncio.CancelledError:
188
+ return
189
+ if generation != self._generation or not self.due():
190
+ return
191
+ with suppress(Exception): # the timer has no reader to tell; the next turn may try again
192
+ text = await self._recap(cancellable=True)
193
+ if text:
194
+ self._show(text)
195
+
196
+ # -- the summary -------------------------------------------------------------------------------
197
+ async def _seed(self) -> None:
198
+ """Count what a resumed conversation already had, so ``min_messages`` means the session and not this run."""
199
+ host = self._host()
200
+ if host is None:
201
+ return
202
+ session = host.client.session.id
203
+ typed = 0
204
+ with suppress(Exception): # a session another process holds, or a store that cannot be read
205
+ history = await host.client.session.history()
206
+ typed = sum(1 for message in history if message.role == "user" and message.relay is None)
207
+ if session != host.client.session.id or session != self._session:
208
+ return # the reader left that conversation while its history was being read
209
+ self._turns = max(self._turns, typed)
210
+ # A resumed conversation can be worth a recap from its first new turn: the turn end that started this read
211
+ # saw too few messages to arm anything, so it is armed here, once the stored ones are counted.
212
+ if self.due() and (self._timer is None or self._timer.done()):
213
+ self._schedule()
214
+
215
+ async def _ask(self) -> str:
216
+ """Ask the model for the summary, as the shell's own side question.
217
+
218
+ Raises:
219
+ RuntimeError: The shell is not built yet.
220
+ Exception: Whatever the ask raised — a run already going, the session locked, the provider failing.
221
+ """
222
+ host = self._host()
223
+ if host is None:
224
+ raise RuntimeError("The shell is not ready yet")
225
+ return await host.client.session.ask(PROMPT, cache="read", timeout=TIMEOUT_SECONDS)
226
+
227
+ async def _recap(self, *, cancellable: bool) -> str:
228
+ """Ask for the summary and return it, saying nothing itself.
229
+
230
+ Args:
231
+ cancellable: Whether a new turn may stop it. True for the recap nobody asked for, which would be news
232
+ about a conversation that has moved on; false for ``/recap``, which was asked for and is answered
233
+ whatever the conversation does next.
234
+
235
+ Raises:
236
+ Exception: What the session's ``ask`` raised — refused because a run is going, the session locked by
237
+ another process, the provider failing — for a caller with a reader to tell.
238
+ """
239
+ if self._host() is None:
240
+ return ""
241
+ asking = asyncio.current_task() if cancellable else None
242
+ if cancellable:
243
+ self._asking = asking
244
+ try:
245
+ answer = await self._ask()
246
+ finally:
247
+ if cancellable and self._asking is asking:
248
+ self._asking = None
249
+ text = " ".join(answer.split()).strip()
250
+ if text:
251
+ self._since_recap = 0
252
+ return text
253
+
254
+ async def command(self, context: CommandContext) -> CommandResult:
255
+ """``/recap``: summarise now, whatever the clock and the turn count say.
256
+
257
+ The answer takes a model call, so the handler hands the waiting to the shell: the pinned row says what is
258
+ happening, and the reader can stop it while it works.
259
+ """
260
+ if self._host() is None:
261
+ raise ValueError("The shell is not ready yet; try again in a moment")
262
+ return CommandResult(intro=f"{PREFIX} {ELLIPSIS} writing", run=self._write())
263
+
264
+ async def _write(self) -> CommandResult:
265
+ """Ask for the summary and answer with it, as the rest of ``/recap``."""
266
+ try:
267
+ text = await self._recap(cancellable=False)
268
+ except asyncio.CancelledError:
269
+ raise
270
+ except Exception as error:
271
+ raise ValueError(f"Could not write a recap: {error}") from error
272
+ if not text:
273
+ return CommandResult(notification="Nothing to recap yet")
274
+ return CommandResult(message=quote(text), notification=f"{PREFIX} ready")
275
+
276
+ # -- helpers -----------------------------------------------------------------------------------
277
+ def _show(self, text: str) -> None:
278
+ """Say it in the conversation as a message — the row a command's answer uses — and ask for the frame."""
279
+ host = self._host()
280
+ if host is None:
281
+ return
282
+ host.transcript.markdown(quote(text))
283
+ # A recap arrives between keys, so nothing else would ask for the frame that shows it.
284
+ host.request_layout()
285
+
286
+ def _host(self) -> CommandHost | None:
287
+ """Return the shell, or ``None`` before it is built (and in a program that has none)."""
288
+ return self._link.host if self._link is not None else None
289
+
290
+ @staticmethod
291
+ def _spawn(coroutine: Coroutine[Any, Any, None]) -> asyncio.Task[None]:
292
+ """Run one piece of waiting work as a task on the loop the shell already runs on."""
293
+ task = asyncio.get_running_loop().create_task(coroutine)
294
+ task.add_done_callback(RecapPlugin._swallow)
295
+ return task
296
+
297
+ @staticmethod
298
+ def _swallow(task: asyncio.Task[None]) -> None:
299
+ """A background failure is the plugin's own business: the shell keeps running, and a later turn may retry."""
300
+ with suppress(asyncio.CancelledError, Exception):
301
+ task.exception()
@@ -0,0 +1,357 @@
1
+ """The recap plugin, driven by the shell's two hooks and its own command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+ from zettcode.app.commands import CommandContext
10
+ from zettcode.app.plugins import HOST_LINK, HostLink
11
+ from zettcode.app.plugins.loader import terminal_context
12
+ from zettcode.config import ModelConfig, ZettCodeConfig
13
+
14
+ from zettcode_recap import PROMPT, RecapPlugin
15
+
16
+ SESSION = "s-1"
17
+
18
+
19
+ class FakeSession:
20
+ """What the shell's SDK client offers a recap: one side question, or a refusal."""
21
+
22
+ def __init__(self, answer: str = "Fixing the parser; next, run the tests.") -> None:
23
+ self.id = SESSION
24
+ self.answer = answer
25
+ self.asked: list[tuple[str, float | None, str]] = []
26
+ self.locked = False
27
+ self.refuse: Exception | None = None
28
+ #: When set, ``ask`` waits for it, so a test can hold a recap in flight.
29
+ self.gate: asyncio.Event | None = None
30
+ #: When set, ``history`` waits for it, so a test can leave a seed reading while the session changes.
31
+ self.history_gate: asyncio.Event | None = None
32
+ self.history_messages: list[object] = []
33
+
34
+ async def ask(self, question: str, *, on_event=None, timeout=None, cache="read") -> str:
35
+ self.asked.append((question, timeout, cache))
36
+ if self.gate is not None:
37
+ await self.gate.wait()
38
+ if self.refuse is not None:
39
+ raise self.refuse
40
+ return self.answer
41
+
42
+ async def history(self):
43
+ # The conversation is the one that was asked for when the call was made, even if the shell has moved on by
44
+ # the time the store answers; only the one the test filled in has messages.
45
+ asked_for = self.id
46
+ if self.history_gate is not None:
47
+ await self.history_gate.wait()
48
+ return self.history_messages if asked_for == SESSION else []
49
+
50
+
51
+ class FakeTranscript:
52
+ def __init__(self) -> None:
53
+ self.notices: list[str] = []
54
+ self.messages: list[str] = []
55
+
56
+ def notice(self, text: str) -> None:
57
+ self.notices.append(text)
58
+
59
+ def markdown(self, text: str) -> None:
60
+ self.messages.append(text)
61
+
62
+
63
+ class FakeUi:
64
+ def markdown(self, text: str) -> None: ...
65
+
66
+ def notice(self, text: str) -> None: ...
67
+
68
+ def error(self, text: str) -> None: ...
69
+
70
+ def notify(self, text: str, *, level: str = "info") -> None: ...
71
+
72
+
73
+ class FakeHost:
74
+ def __init__(self, session: FakeSession | None = None) -> None:
75
+ self.client = type("Client", (), {"session": session or FakeSession()})()
76
+ self.transcript = FakeTranscript()
77
+ self.redraws = 0
78
+
79
+ def request_layout(self) -> None:
80
+ self.redraws += 1
81
+
82
+
83
+ def build(tmp_path: Path, settings: dict[str, object] | None = None, host: FakeHost | None = None):
84
+ """Activate the plugin against a config, with a shell attached when the test wants one."""
85
+ config = ZettCodeConfig(
86
+ workspace=tmp_path,
87
+ models=(ModelConfig(model="m", token="t"),),
88
+ plugin_settings={"recap": settings or {}},
89
+ )
90
+ link = HostLink()
91
+ ctx = terminal_context(config, {HOST_LINK: link}, name="recap")
92
+ plugin = RecapPlugin()
93
+ plugin.activate(ctx)
94
+ if host is not None:
95
+ link.attach(host) # type: ignore[arg-type]
96
+ return plugin, ctx, link
97
+
98
+
99
+ async def settled(predicate, *, ticks: int = 200) -> None:
100
+ """Let the plugin's background tasks run until the condition holds, or fail the test."""
101
+ for _ in range(ticks):
102
+ if predicate():
103
+ return
104
+ await asyncio.sleep(0)
105
+ raise AssertionError("the plugin did not get where the test waited for")
106
+
107
+
108
+ async def test_coming_back_does_not_cancel_a_recap_the_reader_asked_for(tmp_path):
109
+ """/recap is answered whatever the window does; only the unsolicited one is dropped when the reader returns."""
110
+ session = FakeSession()
111
+ session.gate = asyncio.Event()
112
+ plugin, ctx, _link = build(tmp_path, {"after_seconds": 0}, host=FakeHost(session))
113
+ (command,) = ctx.tui.registered_commands
114
+
115
+ pending = await command.handler(CommandContext(argument="", ui=FakeUi()))
116
+ asked = asyncio.ensure_future(pending.run)
117
+ await settled(lambda: bool(session.asked))
118
+ plugin.turn_started()
119
+ session.gate.set()
120
+ result = await asyncio.wait_for(asked, 1.0)
121
+
122
+ assert result.notification == "Recap ready", "the command finished instead of being cancelled"
123
+ plugin._cancel()
124
+
125
+
126
+ async def test_the_unsolicited_recap_is_dropped_when_a_turn_starts(tmp_path):
127
+ session = FakeSession()
128
+ session.gate = asyncio.Event()
129
+ plugin, _ctx, _link = build(tmp_path, {"after_seconds": 0}, host=FakeHost(session))
130
+ host = plugin._host()
131
+ assert host is not None
132
+
133
+ for _ in range(3):
134
+ plugin.turn_ended(True)
135
+ await settled(lambda: bool(session.asked))
136
+ plugin.turn_started()
137
+ session.gate.set()
138
+ await asyncio.sleep(0)
139
+ await asyncio.sleep(0)
140
+
141
+ assert host.transcript.messages == [], "nothing is shown for a conversation that has moved on"
142
+
143
+
144
+ async def test_a_command_does_not_forget_the_automatic_recap_that_is_running(tmp_path):
145
+ """Only the unsolicited recap is cancellable, so `/recap` must not take over — or drop — its handle."""
146
+ session = FakeSession()
147
+ session.gate = asyncio.Event()
148
+ plugin, ctx, _link = build(tmp_path, host=FakeHost(session))
149
+ (command,) = ctx.tui.registered_commands
150
+ automatic = asyncio.ensure_future(asyncio.sleep(3600))
151
+ plugin._asking = automatic
152
+
153
+ pending = await command.handler(CommandContext(argument="", ui=FakeUi()))
154
+ asked = asyncio.ensure_future(pending.run)
155
+ await settled(lambda: bool(session.asked))
156
+ assert plugin._asking is automatic, "the automatic recap can still be cancelled while a command's runs"
157
+
158
+ session.gate.set()
159
+ await asyncio.wait_for(asked, 1.0)
160
+ automatic.cancel()
161
+ plugin._cancel()
162
+
163
+
164
+ async def test_a_seed_read_before_a_session_change_does_not_count_for_the_new_one(tmp_path):
165
+ """The reader can leave a conversation while its history is being read; those messages are not the new one's."""
166
+ session = FakeSession()
167
+ session.history_gate = asyncio.Event()
168
+ session.history_messages = [type("Message", (), {"role": "user", "relay": None})()] * 10
169
+ plugin, _ctx, _link = build(tmp_path, host=FakeHost(session))
170
+
171
+ plugin.turn_ended(True)
172
+ await asyncio.sleep(0) # let the first seed ask the store, which is where it will be held
173
+ session.id = "s-2"
174
+ plugin.turn_ended(True)
175
+ session.history_gate.set()
176
+ await asyncio.sleep(0)
177
+ await asyncio.sleep(0)
178
+
179
+ assert plugin._turns == 1, "the old conversation's ten messages were dropped"
180
+ plugin._cancel()
181
+
182
+
183
+ async def test_a_new_session_starts_its_own_count(tmp_path):
184
+ """`/new` is a new conversation: the old one's three messages must not make the new one worth a recap."""
185
+ session = FakeSession()
186
+ plugin, _ctx, _link = build(tmp_path, host=FakeHost(session))
187
+
188
+ for _ in range(3):
189
+ plugin.turn_ended(True)
190
+ assert plugin._turns == 3
191
+
192
+ session.id = "s-2"
193
+ plugin.turn_ended(True)
194
+
195
+ assert plugin._turns == 1
196
+ assert plugin.due() is False
197
+ plugin._cancel()
198
+
199
+
200
+ async def test_a_resumed_conversation_is_measured_by_its_stored_messages(tmp_path):
201
+ """One new turn in a long session can be worth a recap the moment the history is read, even if nobody looks away again."""
202
+ session = FakeSession()
203
+ session.history_messages = [
204
+ type("Message", (), {"role": "user", "relay": None})(),
205
+ type("Message", (), {"role": "assistant", "relay": None})(),
206
+ ] * 10
207
+ plugin, _ctx, _link = build(tmp_path, {"after_seconds": 0, "min_since_recap": 1}, host=FakeHost(session))
208
+
209
+ plugin.turn_ended(True)
210
+ await settled(lambda: bool(session.asked))
211
+
212
+ assert plugin._turns == 10, "the stored user messages count"
213
+ assert session.asked, "and the recap that count makes worthwhile was written"
214
+ plugin._cancel()
215
+
216
+
217
+ def test_the_command_is_offered_and_both_hooks_are_taken(tmp_path):
218
+ """`/recap` says words, so it is a plain command; the two hooks are the terminal's."""
219
+ _plugin, ctx, _link = build(tmp_path)
220
+
221
+ assert ctx.tui is not None
222
+ assert [command.name for command in ctx.tui.registered_commands] == ["/recap"]
223
+ assert ctx.commands.commands == ()
224
+ assert ctx.tui.focus_handlers == (), "the wait is about turns, not the window"
225
+ assert len(ctx.tui.turn_start_handlers) == 1 and len(ctx.tui.turn_end_handlers) == 1
226
+
227
+
228
+ def test_the_delay_is_the_shorter_of_its_own_seconds_and_the_cache_lifetime(tmp_path):
229
+ plugin, _ctx, _link = build(tmp_path)
230
+
231
+ assert plugin.delay == 180, "min(180, 0.8 × 300)"
232
+
233
+ plugin.cache_ttl_seconds = 100
234
+ assert plugin.delay == 80, "a short-lived cache is not worth waiting longer for"
235
+
236
+
237
+ async def test_a_conversation_that_is_too_short_arms_no_wait(tmp_path):
238
+ session = FakeSession()
239
+ plugin, _ctx, _link = build(tmp_path, host=FakeHost(session))
240
+
241
+ for _ in range(2):
242
+ plugin.turn_ended(True)
243
+ await asyncio.sleep(0)
244
+
245
+ assert plugin._timer is None and session.asked == []
246
+ plugin._cancel()
247
+
248
+
249
+ async def test_a_reader_who_stayed_away_long_enough_gets_a_recap(tmp_path):
250
+ session = FakeSession()
251
+ plugin, _ctx, _link = build(tmp_path, {"after_seconds": 0}, host=FakeHost(session))
252
+ host = plugin._host()
253
+ assert host is not None
254
+
255
+ for _ in range(3):
256
+ plugin.turn_ended(True)
257
+ await settled(lambda: bool(session.asked))
258
+
259
+ assert session.asked == [(PROMPT, 60.0, "read")], "one side question, read-only, with a deadline"
260
+ assert host.transcript.messages == ["> **Recap** \u00b7 Fixing the parser; next, run the tests."]
261
+ assert host.redraws == 1, "the frame that shows it is asked for"
262
+ plugin._cancel()
263
+
264
+
265
+ async def test_a_new_turn_drops_the_recap_that_was_waiting(tmp_path):
266
+ """The reader asking again is the one thing that makes a recap about the past: it is dropped, not shown late."""
267
+ session = FakeSession()
268
+ plugin, _ctx, _link = build(tmp_path, host=FakeHost(session))
269
+
270
+ for _ in range(3):
271
+ plugin.turn_ended(True)
272
+ assert plugin._timer is not None
273
+ plugin.turn_started()
274
+ await settled(lambda: plugin._timer is None)
275
+
276
+ assert session.asked == []
277
+ plugin._cancel()
278
+
279
+
280
+ async def test_two_recaps_are_not_made_from_the_same_two_turns(tmp_path):
281
+ plugin, _ctx, _link = build(tmp_path, host=FakeHost())
282
+ plugin._focused = False
283
+
284
+ for _ in range(3):
285
+ plugin.turn_ended(True)
286
+ plugin._schedule()
287
+ plugin._since_recap = 0 # as if a recap had just been made
288
+
289
+ assert plugin.due() is False, "two more turns are needed before the next one"
290
+ plugin._cancel()
291
+
292
+
293
+ async def test_the_command_summarises_now_and_shows_the_answer(tmp_path):
294
+ session = FakeSession()
295
+ plugin, ctx, _link = build(tmp_path, host=FakeHost(session))
296
+ (command,) = ctx.tui.registered_commands
297
+
298
+ pending = await command.handler(CommandContext(argument="", ui=FakeUi()))
299
+ result = await pending.run
300
+
301
+ assert result.notification == "Recap ready"
302
+ assert result.message == "> **Recap** \u00b7 Fixing the parser; next, run the tests.", "words are the answer"
303
+ assert session.asked[0][2] == "read"
304
+
305
+
306
+ async def test_the_command_tells_the_reader_why_there_is_no_recap(tmp_path):
307
+ session = FakeSession()
308
+ session.refuse = RuntimeError("the provider refused")
309
+ plugin, ctx, _link = build(tmp_path, host=FakeHost(session))
310
+ (command,) = ctx.tui.registered_commands
311
+
312
+ pending = await command.handler(CommandContext(argument="", ui=FakeUi()))
313
+ with pytest.raises(ValueError, match="Could not write a recap: the provider refused"):
314
+ await pending.run
315
+
316
+
317
+ async def test_a_command_before_the_shell_exists_says_so_instead_of_crashing(tmp_path):
318
+ _plugin, ctx, _link = build(tmp_path)
319
+ (command,) = ctx.tui.registered_commands
320
+
321
+ with pytest.raises(ValueError, match="not ready yet"):
322
+ await command.handler(CommandContext(argument="", ui=FakeUi()))
323
+
324
+
325
+ async def test_the_settings_of_the_table_are_the_ones_the_recap_uses(tmp_path):
326
+ plugin, _ctx, _link = build(
327
+ tmp_path,
328
+ {"after_seconds": 30, "cache_ttl_seconds": 600, "min_messages": 5, "min_since_recap": 1},
329
+ host=FakeHost(),
330
+ )
331
+
332
+ assert plugin.after_seconds == 30 and plugin.cache_ttl_seconds == 600
333
+ assert plugin.delay == 30, "min(30, 480)"
334
+ assert (plugin.min_messages, plugin.min_since_recap) == (5, 1)
335
+
336
+ for _ in range(4):
337
+ plugin.turn_ended(True)
338
+ assert plugin.due() is False, "the table asked for five messages"
339
+ plugin.turn_ended(True)
340
+ assert plugin.due() is True
341
+ plugin._cancel()
342
+
343
+
344
+ def test_a_setting_that_could_never_be_meant_is_refused_when_the_plugin_activates(tmp_path):
345
+ with pytest.raises(ValueError, match=r"\[plugins\.recap\] after_seconds must be at least 0"):
346
+ build(tmp_path, {"after_seconds": -1})
347
+
348
+
349
+ def test_the_plugin_also_activates_without_a_screen(tmp_path):
350
+ """Every process loads every plugin; one that only draws into a terminal must leave the others alone."""
351
+ config = ZettCodeConfig(workspace=tmp_path, models=(ModelConfig(model="m", token="t"),))
352
+ from zettcode.engine.plugins.context import PluginContext
353
+
354
+ plugin = RecapPlugin()
355
+ plugin.activate(PluginContext(config, {}))
356
+
357
+ assert plugin._link is None