zettcode-recap 0.1.0__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.
@@ -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,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,6 @@
1
+ zettcode_recap/__init__.py,sha256=jA6tWVF0h24AYOPY5tURrxFfMeQUvzGjYYHHCOQRROc,14166
2
+ zettcode_recap-0.1.0.dist-info/METADATA,sha256=mciroRrQNLGj48y_lHPJRvoOTN72-40osG7iFmJ02R4,3860
3
+ zettcode_recap-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
4
+ zettcode_recap-0.1.0.dist-info/entry_points.txt,sha256=Y1xGtFB3SUPUqcA1HdD2kvoN6RiBaKyhgqw0hyoy2LA,54
5
+ zettcode_recap-0.1.0.dist-info/licenses/LICENSE,sha256=O9b87-u8V1aSp6xkr9qnbHZIJAWNV6m04GEQjycLLiw,1069
6
+ zettcode_recap-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [zettcode.plugins]
2
+ recap = zettcode_recap:RecapPlugin
@@ -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.