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,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.
|