zettcode-memory 0.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zettcode/memory/__init__.py +13 -0
- zettcode/memory/extension.py +264 -0
- zettcode/memory/plugin.py +52 -0
- zettcode/memory/py.typed +0 -0
- zettcode_memory-0.4.1.dist-info/METADATA +19 -0
- zettcode_memory-0.4.1.dist-info/RECORD +8 -0
- zettcode_memory-0.4.1.dist-info/WHEEL +4 -0
- zettcode_memory-0.4.1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""The agent's workspace memory and its guide to background daemons.
|
|
2
|
+
|
|
3
|
+
The personal memory is ``~/memory/personal``; the project memory is one folder per Git root under
|
|
4
|
+
``~/.zettcode/memory``, shared by its worktrees. Each holds a ``MEMORY.md`` index and the topic files the model
|
|
5
|
+
writes. :class:`~zettcode.memory.extension.MemoryExtension` puts both indexes and the maintenance rules in every
|
|
6
|
+
request's system messages; :class:`~zettcode.memory.extension.DaemonGuideExtension` tells the model how to start and
|
|
7
|
+
reach a background agent when it has a shell and the messaging tool. The plugin `memory` registers the first and
|
|
8
|
+
`daemon-guide` the second, both published through :mod:`zettcode.memory.plugin` under the ``zettcode.plugin`` group.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
__all__: tuple[str, ...] = ()
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""The two memories the model starts a request with, and the guide to background agents.
|
|
2
|
+
|
|
3
|
+
Personal memory belongs to the reader and lives at ``~/memory/personal``; project memory belongs to the repository
|
|
4
|
+
(every worktree shares it) and lives under ``~/.zettcode/memory``. Each has a ``MEMORY.md`` index, which is the only
|
|
5
|
+
part included in a request; topic files are read on demand with the ordinary file tools.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shlex
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from zett_agent.agent import AgentRunContext
|
|
16
|
+
from zett_agent.extensions.base import AgentExtension
|
|
17
|
+
from zett_agent.messages import SystemMessage
|
|
18
|
+
|
|
19
|
+
from zettcode.engine.harness_paths import workspace_directory_name
|
|
20
|
+
|
|
21
|
+
#: The global personal memory, shared by every workspace.
|
|
22
|
+
PERSONAL_MEMORY_ROOT = Path.home() / "memory" / "personal"
|
|
23
|
+
|
|
24
|
+
#: Where project memories live, one folder per repository.
|
|
25
|
+
PROJECT_MEMORY_ROOT = Path.home() / ".zettcode" / "memory"
|
|
26
|
+
|
|
27
|
+
#: The index inside a memory folder. Only this file goes into the request.
|
|
28
|
+
MEMORY_INDEX = "MEMORY.md"
|
|
29
|
+
|
|
30
|
+
#: How much of one index the request carries. Lines catch the ordinary case; bytes catch a few enormous lines.
|
|
31
|
+
MAX_ENTRYPOINT_LINES = 200
|
|
32
|
+
MAX_ENTRYPOINT_BYTES = 25_000
|
|
33
|
+
|
|
34
|
+
#: The most a pointer line in the index takes. The index is a table of contents, never the memory itself.
|
|
35
|
+
MAX_INDEX_LINE_CHARS = 150
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def canonical_git_root(workspace: Path) -> Path:
|
|
39
|
+
"""Return the repository a workspace belongs to, following a worktree's ``.git`` file to its common directory."""
|
|
40
|
+
start = workspace.resolve()
|
|
41
|
+
for candidate in (start, *start.parents):
|
|
42
|
+
marker = candidate / ".git"
|
|
43
|
+
if marker.is_dir():
|
|
44
|
+
return candidate
|
|
45
|
+
if not marker.is_file():
|
|
46
|
+
continue
|
|
47
|
+
try:
|
|
48
|
+
text = marker.read_text(encoding="utf-8").strip()
|
|
49
|
+
except (OSError, UnicodeError):
|
|
50
|
+
return candidate
|
|
51
|
+
if not text.startswith("gitdir:"):
|
|
52
|
+
return candidate
|
|
53
|
+
git_dir = Path(text.removeprefix("gitdir:").strip()).expanduser()
|
|
54
|
+
if not git_dir.is_absolute():
|
|
55
|
+
git_dir = (candidate / git_dir).resolve()
|
|
56
|
+
try:
|
|
57
|
+
common = (git_dir / "commondir").read_text(encoding="utf-8").strip()
|
|
58
|
+
except (OSError, UnicodeError):
|
|
59
|
+
return candidate
|
|
60
|
+
return (git_dir / common).resolve().parent
|
|
61
|
+
return start
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def project_memory_directory(root: Path, workspace: Path) -> Path:
|
|
65
|
+
"""Return the folder for one repository's memory, shared by its worktrees."""
|
|
66
|
+
return root / workspace_directory_name(canonical_git_root(workspace))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _index_text(directory: Path) -> tuple[str, str | None, str | None]:
|
|
70
|
+
"""Return ``(index text, which file, warning)`` from a memory folder, or empty when it has no index."""
|
|
71
|
+
path = directory / MEMORY_INDEX
|
|
72
|
+
if not path.is_file():
|
|
73
|
+
return "", None, None
|
|
74
|
+
try:
|
|
75
|
+
text = path.read_text(encoding="utf-8").strip()
|
|
76
|
+
except (OSError, UnicodeError):
|
|
77
|
+
return "", MEMORY_INDEX, "the index cannot be read"
|
|
78
|
+
if not text:
|
|
79
|
+
return "", MEMORY_INDEX, None
|
|
80
|
+
return _bounded(text, MEMORY_INDEX)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _bounded(text: str, name: str) -> tuple[str, str, str | None]:
|
|
84
|
+
"""Cut an index at the line and byte limits, saying which limit made the cut."""
|
|
85
|
+
warnings: list[str] = []
|
|
86
|
+
lines = text.splitlines()
|
|
87
|
+
if len(lines) > MAX_ENTRYPOINT_LINES:
|
|
88
|
+
lines = lines[:MAX_ENTRYPOINT_LINES]
|
|
89
|
+
warnings.append(f"{MAX_ENTRYPOINT_LINES} lines")
|
|
90
|
+
text = "\n".join(lines)
|
|
91
|
+
if len(text.encode("utf-8")) > MAX_ENTRYPOINT_BYTES:
|
|
92
|
+
cut = text.encode("utf-8")[:MAX_ENTRYPOINT_BYTES].decode("utf-8", "ignore")
|
|
93
|
+
text = cut.rsplit("\n", 1)[0] if "\n" in cut else cut
|
|
94
|
+
warnings.append(f"{MAX_ENTRYPOINT_BYTES} bytes")
|
|
95
|
+
if not warnings:
|
|
96
|
+
return text, name, None
|
|
97
|
+
return text, name, "the index was cut at " + " and ".join(warnings)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
_INTRO = """# Memory
|
|
101
|
+
|
|
102
|
+
Two memories, both outside the project and both loaded only through their `MEMORY.md` index:
|
|
103
|
+
|
|
104
|
+
- Personal: `{personal}` — the reader across workspaces: role, technical background, collaboration preferences,
|
|
105
|
+
current focus and learning.
|
|
106
|
+
- Project: `{project}` — this repository, shared by its worktrees: context code and git do not carry, decisions,
|
|
107
|
+
external references.
|
|
108
|
+
|
|
109
|
+
Read a topic file with your normal file tools when its index line is relevant; there is no memory tool.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
_RULES = """## Keeping memory
|
|
113
|
+
|
|
114
|
+
A topic file starts with frontmatter, then the note:
|
|
115
|
+
|
|
116
|
+
```markdown
|
|
117
|
+
---
|
|
118
|
+
name: Tests use a real database
|
|
119
|
+
description: retrieval key, not a human summary
|
|
120
|
+
type: user | feedback | project | reference
|
|
121
|
+
---
|
|
122
|
+
The note. **Why:** … **How to apply:** …
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
- `user`: the reader's role, background, preferences, current focus and learning.
|
|
126
|
+
- `feedback`: corrections *and* confirmations; only recording corrections makes you cautious and drifts from what
|
|
127
|
+
the reader already approved.
|
|
128
|
+
- `project`: context code and git cannot show.
|
|
129
|
+
- `reference`: where an external thing lives.
|
|
130
|
+
|
|
131
|
+
Write the topic file, then add one index line: `- [Title](file.md) — when to read it`, at most {line_chars}
|
|
132
|
+
characters. Never put the note in the index. Indexes are loaded up to {lines} lines and {bytes} bytes; rewrite rather
|
|
133
|
+
than let them grow. Write absolute dates (`Thursday` becomes `2026-03-05`). Correct or delete a wrong memory, merge
|
|
134
|
+
duplicates and prune stale entries; do not leave old and new versions behind.
|
|
135
|
+
|
|
136
|
+
Do not store code patterns, paths inside this project, project structure, git history, debugging recipes, `AGENTS.md`
|
|
137
|
+
content, current task state or logs — even if asked. Keep the surprising, non-obvious part instead.
|
|
138
|
+
|
|
139
|
+
Before relying on a memory, check that the path, function or flag still exists. If the reader says to ignore memory,
|
|
140
|
+
treat both indexes as empty: do not apply, quote, compare or mention them.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
_MISSING = """No `{index}` yet. Create it when there is a first memory worth keeping, with a short `# Memory index`
|
|
144
|
+
line and one pointer per topic file.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _python_command() -> str:
|
|
149
|
+
"""Return the running interpreter, quoted for this platform's shell."""
|
|
150
|
+
executable = sys.executable or "python"
|
|
151
|
+
if os.name == "nt":
|
|
152
|
+
return f'"{executable}"' if " " in executable else executable
|
|
153
|
+
return shlex.quote(executable)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
_DAEMON_GUIDE = """# Background agents (daemons)
|
|
157
|
+
|
|
158
|
+
Start one with the interpreter running this session:
|
|
159
|
+
|
|
160
|
+
{python} -m zettcode daemon spawn --name <label> --title <title> --permission safe
|
|
161
|
+
|
|
162
|
+
It prints a session id. `{python} -m zettcode daemon list` lists daemons; `log <id>` shows one; `stop <id>` stops it
|
|
163
|
+
and keeps the record; `kill <id>` stops it and removes the record. Talk to it only with
|
|
164
|
+
`send_message(to="<session-id>", ...)`; answers arrive later. A daemon has nobody to ask: `spawn` defaults to
|
|
165
|
+
`--permission full`, so pass `safe` for a sandbox. Use it for work that should outlive this turn; keep work this turn
|
|
166
|
+
needs here. Tell the reader what you started.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class MemoryExtension(AgentExtension):
|
|
171
|
+
"""Put both memory indexes and the rules for keeping them in the model's system messages.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
project_root: The root of project memories; ``~/.zettcode/memory`` by default.
|
|
175
|
+
personal_root: The global personal memory folder; ``~/memory/personal`` by default.
|
|
176
|
+
workspace: The directory the project memory belongs to; the process working directory by default.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
#: After project instructions and before the daemon guide.
|
|
180
|
+
priority = 11
|
|
181
|
+
|
|
182
|
+
def __init__(
|
|
183
|
+
self,
|
|
184
|
+
*,
|
|
185
|
+
project_root: Path | None = None,
|
|
186
|
+
personal_root: Path | None = None,
|
|
187
|
+
workspace: Path | None = None,
|
|
188
|
+
) -> None:
|
|
189
|
+
"""Keep the roots and the workspace; the folders are derived when a request needs them."""
|
|
190
|
+
self.project_root = PROJECT_MEMORY_ROOT if project_root is None else Path(project_root).expanduser()
|
|
191
|
+
self.personal_root = PERSONAL_MEMORY_ROOT if personal_root is None else Path(personal_root).expanduser()
|
|
192
|
+
self.workspace = None if workspace is None else Path(workspace)
|
|
193
|
+
|
|
194
|
+
def personal_directory(self) -> Path:
|
|
195
|
+
"""Return the global personal memory folder."""
|
|
196
|
+
return self.personal_root
|
|
197
|
+
|
|
198
|
+
def project_directory(self) -> Path:
|
|
199
|
+
"""Return this repository's memory folder, shared with its worktrees."""
|
|
200
|
+
workspace = Path.cwd() if self.workspace is None else self.workspace
|
|
201
|
+
return project_memory_directory(self.project_root, workspace)
|
|
202
|
+
|
|
203
|
+
def instructions(self) -> str:
|
|
204
|
+
"""Return the two indexes and the rules, as one system message body."""
|
|
205
|
+
personal, project = self.personal_directory(), self.project_directory()
|
|
206
|
+
parts = [
|
|
207
|
+
_INTRO.format(personal=personal, project=project),
|
|
208
|
+
_RULES.format(line_chars=MAX_INDEX_LINE_CHARS, lines=MAX_ENTRYPOINT_LINES, bytes=MAX_ENTRYPOINT_BYTES),
|
|
209
|
+
]
|
|
210
|
+
for title, directory in (("Personal", personal), ("Project", project)):
|
|
211
|
+
text, name, warning = _index_text(directory)
|
|
212
|
+
if not text:
|
|
213
|
+
if warning:
|
|
214
|
+
parts.append(
|
|
215
|
+
f"## {title} index ({name})\n\n> {warning}. It is there but could not be read; "
|
|
216
|
+
f"rewrite `{MEMORY_INDEX}` when there is something to keep."
|
|
217
|
+
)
|
|
218
|
+
else:
|
|
219
|
+
parts.append(f"## {title} index\n\n" + _MISSING.format(index=MEMORY_INDEX))
|
|
220
|
+
continue
|
|
221
|
+
heading = f"## {title} index ({name})"
|
|
222
|
+
if warning:
|
|
223
|
+
heading += f"\n\n> {warning}; rewrite the index"
|
|
224
|
+
parts.append(f"{heading}\n\n{text}")
|
|
225
|
+
return "\n\n".join(parts) + "\n"
|
|
226
|
+
|
|
227
|
+
async def on_state(self, context: AgentRunContext) -> None:
|
|
228
|
+
"""Place the memory after the instructions already in the request."""
|
|
229
|
+
content = self.instructions()
|
|
230
|
+
if not content:
|
|
231
|
+
return
|
|
232
|
+
instructions = [message for message in context.state.messages if isinstance(message, SystemMessage)]
|
|
233
|
+
context.add_message(SystemMessage(content=content), index=len(instructions))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class DaemonGuideExtension(AgentExtension):
|
|
237
|
+
"""Tell the model it may start a background agent, and how to reach the one it started.
|
|
238
|
+
|
|
239
|
+
It is offered only when the request has both a shell and ``send_message``: without the shell it cannot start a
|
|
240
|
+
daemon, and without the messaging tool it could not talk to one.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
#: After the memory, so the two system messages have a stable order.
|
|
244
|
+
priority = 12
|
|
245
|
+
|
|
246
|
+
async def on_state(self, context: AgentRunContext) -> None:
|
|
247
|
+
"""Place the guide after the instructions already in the request."""
|
|
248
|
+
if "bash" not in context.tools or "send_message" not in context.tools:
|
|
249
|
+
return
|
|
250
|
+
instructions = [message for message in context.state.messages if isinstance(message, SystemMessage)]
|
|
251
|
+
context.add_message(
|
|
252
|
+
SystemMessage(content=_DAEMON_GUIDE.format(python=_python_command())), index=len(instructions)
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
__all__ = [
|
|
257
|
+
"DaemonGuideExtension",
|
|
258
|
+
"MAX_ENTRYPOINT_BYTES",
|
|
259
|
+
"MAX_ENTRYPOINT_LINES",
|
|
260
|
+
"MEMORY_INDEX",
|
|
261
|
+
"MemoryExtension",
|
|
262
|
+
"canonical_git_root",
|
|
263
|
+
"project_memory_directory",
|
|
264
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""The plugins that give the agent its workspace memory and its background-daemon guide."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from zettcode.engine.plugins.context import PluginContext
|
|
8
|
+
from zettcode.engine.plugins.plugin import Plugin
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only
|
|
11
|
+
from zett_agent.extensions.base import AgentExtension
|
|
12
|
+
|
|
13
|
+
from zettcode.engine.plugins.state import AgentBuild
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Memory(Plugin[PluginContext]):
|
|
17
|
+
"""The agent's persistent workspace memory; off until the reader asks for it."""
|
|
18
|
+
|
|
19
|
+
name = "memory"
|
|
20
|
+
description = "Persistent workspace memory"
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def default_enabled(cls) -> bool:
|
|
24
|
+
"""Leave memory off until the reader asks for it with ``[plugins] enable``."""
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
def activate(self, ctx: PluginContext) -> None:
|
|
28
|
+
"""Register the memory extension; its import waits for the build."""
|
|
29
|
+
ctx.agent.register_extension_factory("MemoryExtension", self._memory)
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def _memory(_: AgentBuild) -> AgentExtension:
|
|
33
|
+
from .extension import MemoryExtension
|
|
34
|
+
|
|
35
|
+
return MemoryExtension()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DaemonGuide(Plugin[PluginContext]):
|
|
39
|
+
"""Tell the model it can start a background agent, when the request can use one."""
|
|
40
|
+
|
|
41
|
+
name = "daemon-guide"
|
|
42
|
+
description = "The background-daemon guide"
|
|
43
|
+
|
|
44
|
+
def activate(self, ctx: PluginContext) -> None:
|
|
45
|
+
"""Register the guide extension; its import waits for the build."""
|
|
46
|
+
ctx.agent.register_extension_factory("DaemonGuideExtension", self._daemon_guide)
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def _daemon_guide(_: AgentBuild) -> AgentExtension:
|
|
50
|
+
from .extension import DaemonGuideExtension
|
|
51
|
+
|
|
52
|
+
return DaemonGuideExtension()
|
zettcode/memory/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: zettcode-memory
|
|
3
|
+
Version: 0.4.1
|
|
4
|
+
Summary: The agent's persistent memory and its guide to background daemons, as a part of the agent that lives apart from it
|
|
5
|
+
Project-URL: Repository, https://github.com/Chang-LeHung/zettcode
|
|
6
|
+
Author: Chang-LeHung
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: agent,daemon,memory,zettcode
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: zett-agent<0.3,>=0.2.10
|
|
19
|
+
Requires-Dist: zettcode-engine
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
zettcode/memory/__init__.py,sha256=oqrFM-gtE9Neu9DSeEfAc1WJyiws06iwmB6bW479_go,811
|
|
2
|
+
zettcode/memory/extension.py,sha256=luTnueZ8Wc9_bvGF8V7Pvn8Z5bF-nb-OYLLXVBrPVJ8,11216
|
|
3
|
+
zettcode/memory/plugin.py,sha256=R2gvfgj7yYcebCr3Nk1Xjmy1FmX6Rb0uFaeTGQHL7UI,1751
|
|
4
|
+
zettcode/memory/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
zettcode_memory-0.4.1.dist-info/METADATA,sha256=OEbTvKzepebDc6OCbDxDN0GoI2nxayeozrtHg4czHIs,817
|
|
6
|
+
zettcode_memory-0.4.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
7
|
+
zettcode_memory-0.4.1.dist-info/entry_points.txt,sha256=Xksoj5hBsS371FCvj4Wdyj63b_1gJ8lBdOp2DQnUhTk,107
|
|
8
|
+
zettcode_memory-0.4.1.dist-info/RECORD,,
|