agent-memory-cli 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.
- agent_memory/__init__.py +9 -0
- agent_memory/__main__.py +9 -0
- agent_memory/archive.py +359 -0
- agent_memory/cli.py +437 -0
- agent_memory/debrief.py +336 -0
- agent_memory/doctor.py +710 -0
- agent_memory/git_runner.py +68 -0
- agent_memory/home.py +246 -0
- agent_memory/layout.py +198 -0
- agent_memory/org.py +218 -0
- agent_memory/publication.py +433 -0
- agent_memory/setup/__init__.py +53 -0
- agent_memory/setup/claude.py +34 -0
- agent_memory/setup/codex.py +39 -0
- agent_memory/setup/common.py +1015 -0
- agent_memory/setup/legacy.py +67 -0
- agent_memory/startup.py +254 -0
- agent_memory/status.py +137 -0
- agent_memory/sync.py +649 -0
- agent_memory/templates/org-memory/decisions.md +5 -0
- agent_memory/templates/org-memory/recent.md +16 -0
- agent_memory/templates/org-memory/rules.md +5 -0
- agent_memory/templates/project-memory/decision_log.md +3 -0
- agent_memory/templates/project-memory/known_debt.md +8 -0
- agent_memory/templates/project-memory/open_threads.md +3 -0
- agent_memory/templates/project-memory/project_facts.md +4 -0
- agent_memory/templates/workflow/SKILL.md +42 -0
- agent_memory/workflow.py +287 -0
- agent_memory_cli-0.1.0.dist-info/METADATA +261 -0
- agent_memory_cli-0.1.0.dist-info/RECORD +33 -0
- agent_memory_cli-0.1.0.dist-info/WHEEL +4 -0
- agent_memory_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agent_memory_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
agent_memory/debrief.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Kiloloop
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Publish a session debrief into the debrief store: ``agent-memory debrief write``.
|
|
4
|
+
|
|
5
|
+
Implements the writer contract of the memory layout spec (the kernel's
|
|
6
|
+
``docs/protocol/org_memory.md`` -> "Debrief Store"). The layout and schema are
|
|
7
|
+
the spec's; everything here -- schema completeness, the content hash, and
|
|
8
|
+
failure-atomic publication through :mod:`agent_memory.publication` -- is the
|
|
9
|
+
writer's responsibility.
|
|
10
|
+
|
|
11
|
+
Canonical path::
|
|
12
|
+
|
|
13
|
+
<home>/org-memory/debriefs/<project>/<YYYY>/<MM>/<YYYYMMDD>-<agent>-<session>.md
|
|
14
|
+
|
|
15
|
+
Exit codes of the verb:
|
|
16
|
+
0 published (or idempotent re-publish of a byte-identical record)
|
|
17
|
+
1 usage / validation error
|
|
18
|
+
2 publication failure (collision, read-back mismatch, hostile target)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import datetime as dt
|
|
24
|
+
import hashlib
|
|
25
|
+
import re
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Dict, NamedTuple, Tuple
|
|
28
|
+
|
|
29
|
+
from . import layout
|
|
30
|
+
from .publication import STAGE_PREFIX, WriterError, publish, staging_path # noqa: F401 (re-exported)
|
|
31
|
+
|
|
32
|
+
SCHEMA_VERSION = 1
|
|
33
|
+
|
|
34
|
+
# Mirrors the protocol's canonical agent-name rule; hyphens, dots,
|
|
35
|
+
# underscores and mixed case are all representable in the agent segment.
|
|
36
|
+
AGENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
|
37
|
+
|
|
38
|
+
# The session identifier is the substring after the FINAL hyphen, so it must
|
|
39
|
+
# never contain one -- that is what keeps the three-part filename uniquely
|
|
40
|
+
# parseable for any valid agent name.
|
|
41
|
+
SESSION_RE = re.compile(r"^[a-z0-9]{1,32}$")
|
|
42
|
+
|
|
43
|
+
FRONTMATTER_DELIM = b"---\n"
|
|
44
|
+
|
|
45
|
+
REQUIRED_FRONTMATTER_ORDER = (
|
|
46
|
+
"schema_version",
|
|
47
|
+
"project",
|
|
48
|
+
"agent",
|
|
49
|
+
"runtime",
|
|
50
|
+
"session",
|
|
51
|
+
"started_utc",
|
|
52
|
+
"ended_utc",
|
|
53
|
+
"content_sha256",
|
|
54
|
+
"immutable",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# A control character in any identity field would break out of the frontmatter
|
|
58
|
+
# block it is serialized into and corrupt the path segment it names, so every
|
|
59
|
+
# identity value is screened for them before composition.
|
|
60
|
+
CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f]")
|
|
61
|
+
|
|
62
|
+
# Runtime family names follow the same shape as agent names.
|
|
63
|
+
RUNTIME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
|
64
|
+
|
|
65
|
+
STATUS_DRY_RUN = "dry-run"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class DebriefResult(NamedTuple):
|
|
69
|
+
"""Outcome of one debrief write."""
|
|
70
|
+
|
|
71
|
+
path: Path
|
|
72
|
+
status: str
|
|
73
|
+
content_sha256: str
|
|
74
|
+
record: bytes
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# --------------------------------------------------------------------------
|
|
78
|
+
# validation
|
|
79
|
+
# --------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def valid_project_segment(name: str) -> bool:
|
|
83
|
+
"""Workspace project-name rule: no leading dot, no path separators.
|
|
84
|
+
|
|
85
|
+
Control characters are rejected on top of the protocol rule: they cannot
|
|
86
|
+
appear in a usable path segment, and a newline would inject extra lines
|
|
87
|
+
into the frontmatter block the name is serialized into.
|
|
88
|
+
"""
|
|
89
|
+
return (
|
|
90
|
+
bool(name)
|
|
91
|
+
and not name.startswith(".")
|
|
92
|
+
and "/" not in name
|
|
93
|
+
and "\\" not in name
|
|
94
|
+
and not CONTROL_CHARS_RE.search(name)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_utc(label: str, value: str) -> dt.datetime:
|
|
99
|
+
"""Parse an ISO 8601 UTC timestamp that ends in ``Z``."""
|
|
100
|
+
if not value.endswith("Z"):
|
|
101
|
+
raise WriterError(f"{label} must be ISO 8601 UTC ending in 'Z': {value!r}", 1)
|
|
102
|
+
try:
|
|
103
|
+
parsed = dt.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
|
|
104
|
+
except ValueError as exc:
|
|
105
|
+
raise WriterError(f"{label} is not a valid UTC timestamp: {value!r} ({exc})", 1) from exc
|
|
106
|
+
return parsed.replace(tzinfo=dt.timezone.utc)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def validate_identity(project: str, agent: str, runtime: str, session: str) -> None:
|
|
110
|
+
if not valid_project_segment(project):
|
|
111
|
+
raise WriterError(
|
|
112
|
+
f"project {project!r} is not a valid workspace name (must not start with '.' or contain '/' or '\\')",
|
|
113
|
+
1,
|
|
114
|
+
)
|
|
115
|
+
if not AGENT_RE.match(agent):
|
|
116
|
+
raise WriterError(f"agent {agent!r} does not match the protocol agent-name rule {AGENT_RE.pattern}", 1)
|
|
117
|
+
if not SESSION_RE.match(session):
|
|
118
|
+
raise WriterError(
|
|
119
|
+
f"session {session!r} must be 1-32 lowercase letters/digits with no "
|
|
120
|
+
"hyphens (the identifier is parsed as the substring after the final hyphen)",
|
|
121
|
+
1,
|
|
122
|
+
)
|
|
123
|
+
if not RUNTIME_RE.match(runtime):
|
|
124
|
+
raise WriterError(f"runtime {runtime!r} must match {RUNTIME_RE.pattern}", 1)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def validate_body(body: bytes) -> None:
|
|
128
|
+
"""The record is a Markdown file, so the body must be valid UTF-8.
|
|
129
|
+
|
|
130
|
+
Checked before the store is touched: a record whose body cannot be decoded
|
|
131
|
+
is unreadable to every consumer, and the post-publication read-back cannot
|
|
132
|
+
catch it because it compares the file against the same bytes that composed
|
|
133
|
+
it.
|
|
134
|
+
"""
|
|
135
|
+
if not body:
|
|
136
|
+
raise WriterError("refusing to publish a debrief with an empty body", 1)
|
|
137
|
+
try:
|
|
138
|
+
body.decode("utf-8")
|
|
139
|
+
except UnicodeDecodeError as exc:
|
|
140
|
+
raise WriterError(f"debrief body is not valid UTF-8 at byte {exc.start}: {exc.reason}", 1) from exc
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# --------------------------------------------------------------------------
|
|
144
|
+
# record composition
|
|
145
|
+
# --------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def content_sha256(body: bytes) -> str:
|
|
149
|
+
"""Lowercase-hex SHA-256 over the exact body bytes -- no normalization."""
|
|
150
|
+
return hashlib.sha256(body).hexdigest()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _yaml_scalar(value: object) -> str:
|
|
154
|
+
"""Serialize one frontmatter value.
|
|
155
|
+
|
|
156
|
+
``schema_version`` is an integer and ``immutable`` a boolean; every other
|
|
157
|
+
field is a string, and strings are emitted in YAML single-quoted style
|
|
158
|
+
unconditionally. Conditional quoting is not safe here: identifiers the
|
|
159
|
+
protocol grammar accepts -- ``true``, ``null``, ``no``, ``on``, ``y`` --
|
|
160
|
+
are plain-scalar keywords a YAML reader re-types, silently changing the
|
|
161
|
+
record's identity, and leading indicators such as ``*`` or ``&`` produce a
|
|
162
|
+
record no parser will read at all. Single-quoted style preserves any
|
|
163
|
+
control-character-free string exactly, escaping an embedded quote by
|
|
164
|
+
doubling it.
|
|
165
|
+
"""
|
|
166
|
+
if isinstance(value, bool):
|
|
167
|
+
return "true" if value else "false"
|
|
168
|
+
if isinstance(value, int):
|
|
169
|
+
return str(value)
|
|
170
|
+
text = str(value)
|
|
171
|
+
if not text or text != text.strip():
|
|
172
|
+
raise WriterError(f"refusing to emit an untrimmed/empty frontmatter scalar: {text!r}")
|
|
173
|
+
if CONTROL_CHARS_RE.search(text):
|
|
174
|
+
# Defense in depth: identity fields are screened before composition, so
|
|
175
|
+
# reaching here means a caller bypassed validation.
|
|
176
|
+
raise WriterError(f"refusing to emit a frontmatter scalar with control characters: {text!r}")
|
|
177
|
+
return "'" + text.replace("'", "''") + "'"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def compose_record(
|
|
181
|
+
*,
|
|
182
|
+
project: str,
|
|
183
|
+
agent: str,
|
|
184
|
+
runtime: str,
|
|
185
|
+
session: str,
|
|
186
|
+
started_utc: str,
|
|
187
|
+
ended_utc: str,
|
|
188
|
+
body: bytes,
|
|
189
|
+
) -> bytes:
|
|
190
|
+
"""Build the full record: frontmatter block + verbatim body bytes."""
|
|
191
|
+
fields: Dict[str, object] = {
|
|
192
|
+
"schema_version": SCHEMA_VERSION,
|
|
193
|
+
"project": project,
|
|
194
|
+
"agent": agent,
|
|
195
|
+
"runtime": runtime,
|
|
196
|
+
"session": session,
|
|
197
|
+
"started_utc": started_utc,
|
|
198
|
+
"ended_utc": ended_utc,
|
|
199
|
+
"content_sha256": content_sha256(body),
|
|
200
|
+
"immutable": True,
|
|
201
|
+
}
|
|
202
|
+
lines = [f"{key}: {_yaml_scalar(fields[key])}" for key in REQUIRED_FRONTMATTER_ORDER]
|
|
203
|
+
head = FRONTMATTER_DELIM + ("\n".join(lines) + "\n").encode("utf-8") + FRONTMATTER_DELIM
|
|
204
|
+
record = head + body
|
|
205
|
+
_assert_record_roundtrips(record, fields, body)
|
|
206
|
+
return record
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _assert_record_roundtrips(record: bytes, fields: Dict[str, object], body: bytes) -> None:
|
|
210
|
+
"""Re-parse the composed record and assert it says what it was asked to say.
|
|
211
|
+
|
|
212
|
+
Composition is the one step that can silently change a record's identity,
|
|
213
|
+
and nothing downstream can catch it: the doctor never opens debrief files,
|
|
214
|
+
and the post-publication read-back compares the stored file against these
|
|
215
|
+
same composed bytes. So the writer closes the loop itself, here, before the
|
|
216
|
+
store is touched.
|
|
217
|
+
"""
|
|
218
|
+
parsed, parsed_body = split_record(record)
|
|
219
|
+
if list(parsed) != list(REQUIRED_FRONTMATTER_ORDER):
|
|
220
|
+
raise WriterError(
|
|
221
|
+
f"composed frontmatter does not carry exactly the required fields in canonical order: {list(parsed)}"
|
|
222
|
+
)
|
|
223
|
+
for key, expected in fields.items():
|
|
224
|
+
if isinstance(expected, bool):
|
|
225
|
+
want = "true" if expected else "false"
|
|
226
|
+
elif isinstance(expected, int):
|
|
227
|
+
want = str(expected)
|
|
228
|
+
else:
|
|
229
|
+
want = str(expected)
|
|
230
|
+
if parsed[key] != want:
|
|
231
|
+
raise WriterError(f"composed frontmatter field {key!r} did not round-trip: {parsed[key]!r} != {want!r}")
|
|
232
|
+
if parsed_body != body:
|
|
233
|
+
raise WriterError("composed record body did not round-trip byte-for-byte")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def split_record(raw: bytes) -> Tuple[Dict[str, str], bytes]:
|
|
237
|
+
"""Split a stored record into (frontmatter mapping, body bytes).
|
|
238
|
+
|
|
239
|
+
The body is every byte after the line that closes the frontmatter block --
|
|
240
|
+
the second ``---`` line including its trailing newline -- exactly as
|
|
241
|
+
stored. This is the definition the ``content_sha256`` field is computed
|
|
242
|
+
over, so it must not normalize anything.
|
|
243
|
+
"""
|
|
244
|
+
if not raw.startswith(FRONTMATTER_DELIM):
|
|
245
|
+
raise WriterError("record does not begin with a '---' frontmatter delimiter")
|
|
246
|
+
rest = raw[len(FRONTMATTER_DELIM) :]
|
|
247
|
+
end = rest.find(b"\n" + FRONTMATTER_DELIM)
|
|
248
|
+
if end == -1:
|
|
249
|
+
raise WriterError("record frontmatter block is not closed by a '---' line")
|
|
250
|
+
head = rest[:end]
|
|
251
|
+
body = rest[end + 1 + len(FRONTMATTER_DELIM) :]
|
|
252
|
+
|
|
253
|
+
frontmatter: Dict[str, str] = {}
|
|
254
|
+
for line in head.decode("utf-8").splitlines():
|
|
255
|
+
if not line.strip():
|
|
256
|
+
continue
|
|
257
|
+
key, _, value = line.partition(":")
|
|
258
|
+
value = value.strip()
|
|
259
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
|
|
260
|
+
quote = value[0]
|
|
261
|
+
value = value[1:-1]
|
|
262
|
+
if quote == "'":
|
|
263
|
+
# Undo YAML single-quoted escaping.
|
|
264
|
+
value = value.replace("''", "'")
|
|
265
|
+
frontmatter[key.strip()] = value
|
|
266
|
+
return frontmatter, body
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def verify_record(raw: bytes) -> None:
|
|
270
|
+
"""The consistency check publication runs over staged and read-back bytes:
|
|
271
|
+
the body must hash to the ``content_sha256`` the frontmatter declares."""
|
|
272
|
+
frontmatter, body = split_record(raw)
|
|
273
|
+
if content_sha256(body) != frontmatter.get("content_sha256"):
|
|
274
|
+
raise WriterError("body does not match content_sha256")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def canonical_name(started: dt.datetime, agent: str, session: str) -> str:
|
|
278
|
+
return f"{started.strftime('%Y%m%d')}-{agent}-{session}.md"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def canonical_path(home: Path, project: str, started: dt.datetime, agent: str, session: str) -> Path:
|
|
282
|
+
return (
|
|
283
|
+
layout.org_memory_dir(home)
|
|
284
|
+
/ "debriefs"
|
|
285
|
+
/ project
|
|
286
|
+
/ started.strftime("%Y")
|
|
287
|
+
/ started.strftime("%m")
|
|
288
|
+
/ canonical_name(started, agent, session)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# --------------------------------------------------------------------------
|
|
293
|
+
# the verb
|
|
294
|
+
# --------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def write_debrief(
|
|
298
|
+
*,
|
|
299
|
+
home: Path,
|
|
300
|
+
project: str,
|
|
301
|
+
agent: str,
|
|
302
|
+
runtime: str,
|
|
303
|
+
session: str,
|
|
304
|
+
started_utc: str,
|
|
305
|
+
ended_utc: str,
|
|
306
|
+
body: bytes,
|
|
307
|
+
dry_run: bool = False,
|
|
308
|
+
) -> DebriefResult:
|
|
309
|
+
"""Validate, compose and publish one debrief.
|
|
310
|
+
|
|
311
|
+
With ``dry_run`` the record is validated and composed exactly as it would
|
|
312
|
+
be published, and the store is not touched -- no directories created, no
|
|
313
|
+
files written. The status is then ``dry-run``.
|
|
314
|
+
"""
|
|
315
|
+
validate_identity(project, agent, runtime, session)
|
|
316
|
+
started = parse_utc("started_utc", started_utc)
|
|
317
|
+
ended = parse_utc("ended_utc", ended_utc)
|
|
318
|
+
if ended < started:
|
|
319
|
+
raise WriterError(f"ended_utc ({ended_utc}) is before started_utc ({started_utc})", 1)
|
|
320
|
+
validate_body(body)
|
|
321
|
+
|
|
322
|
+
record = compose_record(
|
|
323
|
+
project=project,
|
|
324
|
+
agent=agent,
|
|
325
|
+
runtime=runtime,
|
|
326
|
+
session=session,
|
|
327
|
+
started_utc=started_utc,
|
|
328
|
+
ended_utc=ended_utc,
|
|
329
|
+
body=body,
|
|
330
|
+
)
|
|
331
|
+
target = canonical_path(home, project, started, agent, session)
|
|
332
|
+
digest = content_sha256(body)
|
|
333
|
+
if dry_run:
|
|
334
|
+
return DebriefResult(target, STATUS_DRY_RUN, digest, record)
|
|
335
|
+
status = publish(target, record, verify=verify_record)
|
|
336
|
+
return DebriefResult(target, status, digest, record)
|