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.
@@ -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()
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,,
@@ -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,3 @@
1
+ [zettcode.plugin]
2
+ daemon-guide = zettcode.memory.plugin:DaemonGuide
3
+ memory = zettcode.memory.plugin:Memory