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.
@@ -0,0 +1,9 @@
1
+ # SPDX-FileCopyrightText: 2026 Kiloloop
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Cross-session memory for coding agents: plain files, git-native, no server."""
4
+
5
+ from .home import HomeError, HomeResolution, resolve_home
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ __all__ = ["HomeError", "HomeResolution", "__version__", "resolve_home"]
@@ -0,0 +1,9 @@
1
+ # SPDX-FileCopyrightText: 2026 Kiloloop
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Run agent-memory with ``python -m agent_memory``."""
4
+
5
+ from .cli import main
6
+
7
+
8
+ if __name__ == "__main__":
9
+ raise SystemExit(main())
@@ -0,0 +1,359 @@
1
+ # SPDX-FileCopyrightText: 2026 Kiloloop
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Archive and restore supplementary memory files without clobbering or escaping.
4
+
5
+ A project's memory directory holds the active files the layout names plus
6
+ any number of supplementary files. ``archive`` moves one supplementary file
7
+ into ``memory/archive/`` under ``<UTC timestamp>_<basename>``; ``restore``
8
+ moves an archived file back to its original basename, into an active slot
9
+ that must be empty. The active files are never archived.
10
+
11
+ Three invariants replace check-then-rename:
12
+
13
+ * **No replace.** The move is ``os.link`` then ``os.unlink``. The link fails
14
+ if the destination exists at the instant it is made, so a file that
15
+ appears between any check and the move is never overwritten, and the
16
+ moved file keeps its bytes and metadata because it is the same inode. A
17
+ filesystem without hard links is refused rather than worked around.
18
+ * **Containment.** The project name and both basenames are validated
19
+ lexically. The home is resolved once; every directory below it
20
+ (``projects``, the project, ``memory``, ``memory/archive``) is opened one
21
+ path component at a time with ``O_NOFOLLOW``, so a symlink at any level is
22
+ refused, and the resulting directory handles address the file for every
23
+ check and for the move itself. A directory swapped for a symlink after it
24
+ was checked cannot redirect the link or the unlink: both run relative to
25
+ the handle, never by re-resolving a path. The file itself may not be a
26
+ symlink.
27
+ * **Protected identity.** An active file is protected by what it is, not by
28
+ how it is spelled: a source whose inode is one of the active files (a case
29
+ variant on a case-insensitive filesystem, a normalization variant, a hard
30
+ link) is refused like the exact name.
31
+
32
+ A dry run performs every check, including the ones on the archive path, and
33
+ reports the same paths; only the directory creation and the move are
34
+ skipped. POSIX only: Windows is refused, dry run included.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import datetime as dt
40
+ import errno
41
+ import os
42
+ import re
43
+ import stat
44
+ from pathlib import Path
45
+ from typing import Any, Callable, Dict, Optional, Tuple
46
+
47
+ from . import layout
48
+
49
+ ARCHIVE_DIR = "archive"
50
+ #: The active files of a project tier; these are never archived.
51
+ PROTECTED_FILES: Tuple[str, ...] = layout.PROJECT.files
52
+ _PLATFORM = os.name
53
+
54
+ #: errno values a filesystem without hard links (or one that forbids them) answers os.link with.
55
+ _NO_HARD_LINKS = frozenset(
56
+ code for code in (getattr(errno, name, None) for name in ("EPERM", "EXDEV", "ENOTSUP", "EOPNOTSUPP", "EACCES")) if code
57
+ )
58
+ #: errno values open(O_NOFOLLOW) answers with when the last component is a symlink (Linux/macOS: ELOOP; BSD: EMLINK).
59
+ _IS_SYMLINK = frozenset(code for code in (getattr(errno, name, None) for name in ("ELOOP", "EMLINK")) if code)
60
+ #: Open a directory by one component, never following a symlink at that component.
61
+ _DIR_FLAGS = (
62
+ os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
63
+ )
64
+
65
+ _SAFE_BASENAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
66
+ _ARCHIVED_BASENAME_RE = re.compile(r"^(?P<timestamp>\d{8}T\d{6}Z)_(?P<basename>[A-Za-z0-9][A-Za-z0-9._-]{0,127})$")
67
+
68
+
69
+ class ArchiveError(Exception):
70
+ """The operation was refused; nothing was moved."""
71
+
72
+
73
+ # --- names -----------------------------------------------------------------
74
+
75
+
76
+ def validate_memory_basename(file_name: str) -> None:
77
+ if "/" in file_name or "\\" in file_name or not _SAFE_BASENAME_RE.fullmatch(file_name):
78
+ raise ArchiveError("memory file name must be a simple basename containing only [A-Za-z0-9._-]")
79
+
80
+
81
+ def build_archive_name(memory_file: str, now: Optional[dt.datetime] = None) -> str:
82
+ validate_memory_basename(memory_file)
83
+ current = now or dt.datetime.now(dt.timezone.utc)
84
+ return f"{current.astimezone(dt.timezone.utc).strftime('%Y%m%dT%H%M%SZ')}_{memory_file}"
85
+
86
+
87
+ def original_name_from_archive(archived_file: str) -> str:
88
+ if "/" in archived_file or "\\" in archived_file:
89
+ raise ArchiveError("archived file name must be a simple basename")
90
+ match = _ARCHIVED_BASENAME_RE.fullmatch(archived_file)
91
+ if match is None:
92
+ raise ArchiveError("archived file name must match <UTC timestamp>_<basename>")
93
+ return match.group("basename")
94
+
95
+
96
+ # --- the verbs -------------------------------------------------------------
97
+
98
+
99
+ def archive(
100
+ home: Path,
101
+ project: str,
102
+ memory_file: str,
103
+ *,
104
+ dry_run: bool = False,
105
+ now: Optional[dt.datetime] = None,
106
+ ) -> Dict[str, Any]:
107
+ """Move ``memory/<memory_file>`` to ``memory/archive/<UTC>_<memory_file>``."""
108
+ _require_posix()
109
+ validate_memory_basename(memory_file)
110
+ if memory_file in PROTECTED_FILES:
111
+ raise ArchiveError(f"cannot archive standard active memory file: {memory_file}")
112
+ archived_file = build_archive_name(memory_file, now=now)
113
+ memory_dir, memory_fd = _open_memory_dir(home, project)
114
+ archive_dir = memory_dir / ARCHIVE_DIR
115
+ source = memory_dir / memory_file
116
+ destination = archive_dir / archived_file
117
+ try:
118
+ # The layout is validated before the file is looked up, on the path both runs share:
119
+ # a symlink or a non-directory at memory/archive is refused here; an absent one is None.
120
+ archive_fd = _open_archive_dir(memory_fd, archive_dir, create=False)
121
+ try:
122
+ source_stat = _stat_regular_file(memory_file, memory_fd, source, "memory file")
123
+ _refuse_active_identity(source_stat, memory_fd, memory_dir, memory_file)
124
+ if archive_fd is not None:
125
+ _require_absent(archived_file, archive_fd, destination, "archive destination")
126
+ if not dry_run:
127
+ if archive_fd is None:
128
+ archive_fd = _open_archive_dir(memory_fd, archive_dir, create=True)
129
+ if archive_fd is None: # unreachable: create=True never returns None
130
+ raise ArchiveError(f"archive directory not found: {archive_dir}")
131
+ _move_no_replace(memory_file, memory_fd, archived_file, archive_fd, source_stat, source, destination)
132
+ finally:
133
+ if archive_fd is not None:
134
+ os.close(archive_fd)
135
+ finally:
136
+ os.close(memory_fd)
137
+ return {
138
+ "project": project,
139
+ "action": "archive",
140
+ "memory_file": memory_file,
141
+ "archived_file": archived_file,
142
+ "source": str(source),
143
+ "destination": str(destination),
144
+ "dry_run": dry_run,
145
+ "status": "dry-run" if dry_run else "archived",
146
+ }
147
+
148
+
149
+ def restore(home: Path, project: str, archived_file: str, *, dry_run: bool = False) -> Dict[str, Any]:
150
+ """Move ``memory/archive/<archived_file>`` back to ``memory/<basename>``, which must not exist."""
151
+ _require_posix()
152
+ restored_file = original_name_from_archive(archived_file)
153
+ memory_dir, memory_fd = _open_memory_dir(home, project)
154
+ archive_dir = memory_dir / ARCHIVE_DIR
155
+ source = archive_dir / archived_file
156
+ destination = memory_dir / restored_file
157
+ try:
158
+ archive_fd = _open_archive_dir(memory_fd, archive_dir, create=False)
159
+ if archive_fd is None:
160
+ raise ArchiveError(f"memory archive directory not found: {archive_dir}")
161
+ try:
162
+ source_stat = _stat_regular_file(archived_file, archive_fd, source, "archived memory file")
163
+ _require_absent(restored_file, memory_fd, destination, "active memory destination")
164
+ if not dry_run:
165
+ _move_no_replace(archived_file, archive_fd, restored_file, memory_fd, source_stat, source, destination)
166
+ finally:
167
+ os.close(archive_fd)
168
+ finally:
169
+ os.close(memory_fd)
170
+ return {
171
+ "project": project,
172
+ "action": "restore",
173
+ "archived_file": archived_file,
174
+ "restored_file": restored_file,
175
+ "source": str(source),
176
+ "destination": str(destination),
177
+ "dry_run": dry_run,
178
+ "status": "dry-run" if dry_run else "restored",
179
+ }
180
+
181
+
182
+ # --- containment: directory handles -----------------------------------------
183
+
184
+
185
+ def _require_posix() -> None:
186
+ if _PLATFORM != "posix":
187
+ raise ArchiveError("archive and restore are supported on POSIX systems only")
188
+
189
+
190
+ def _open_memory_dir(home: Path, project: str) -> Tuple[Path, int]:
191
+ """The project's memory directory as (path, handle), every directory below the home opened without following symlinks."""
192
+ try:
193
+ layout.validate_project_name(project)
194
+ except ValueError as exc:
195
+ raise ArchiveError(str(exc)) from None
196
+ root = Path(home).expanduser().resolve()
197
+ memory_dir = layout.project_memory_dir(root, project)
198
+ project_dir = memory_dir.parent
199
+ fd = _open_dir(str(root), None, root, "home", lambda: f"home not found: {root}")
200
+ current = root
201
+ for part in memory_dir.relative_to(root).parts:
202
+ current = current / part
203
+ if current == memory_dir:
204
+ missing = f"memory directory not found: {memory_dir}"
205
+ else:
206
+ missing = f"project '{project}' not found at {project_dir}"
207
+ try:
208
+ child = _open_dir(part, fd, current, "directory", lambda: missing)
209
+ finally:
210
+ os.close(fd)
211
+ fd = child
212
+ return memory_dir, fd
213
+
214
+
215
+ def _open_archive_dir(memory_fd: int, archive_dir: Path, *, create: bool) -> Optional[int]:
216
+ """A handle on ``memory/archive``; ``None`` when it is absent and not to be created.
217
+
218
+ A symlink or a non-directory at that name is refused here, on the path
219
+ both dry run and real run share, so a dry run never reports a move the
220
+ real run could not perform.
221
+ """
222
+ created = False
223
+ while True:
224
+ try:
225
+ return os.open(ARCHIVE_DIR, _DIR_FLAGS, dir_fd=memory_fd)
226
+ except OSError as exc:
227
+ if exc.errno == errno.ENOENT and not create:
228
+ return None
229
+ if exc.errno == errno.ENOENT and not created:
230
+ try:
231
+ os.mkdir(ARCHIVE_DIR, dir_fd=memory_fd)
232
+ except FileExistsError:
233
+ pass
234
+ except OSError as mkdir_exc:
235
+ raise ArchiveError(
236
+ f"cannot create the archive directory {archive_dir}: {mkdir_exc.strerror}"
237
+ ) from None
238
+ created = True
239
+ continue
240
+ raise _open_error(
241
+ exc, ARCHIVE_DIR, memory_fd, archive_dir, "archive directory", lambda: f"archive directory not found: {archive_dir}"
242
+ ) from None
243
+
244
+
245
+ def _open_dir(name: str, dir_fd: Optional[int], path: Path, what: str, missing: Callable[[], str]) -> int:
246
+ try:
247
+ return os.open(name, _DIR_FLAGS, dir_fd=dir_fd)
248
+ except OSError as exc:
249
+ raise _open_error(exc, name, dir_fd, path, what, missing) from None
250
+
251
+
252
+ def _open_error(
253
+ exc: OSError, name: str, dir_fd: Optional[int], path: Path, what: str, missing: Callable[[], str]
254
+ ) -> ArchiveError:
255
+ # Linux answers O_NOFOLLOW on a symlink with ELOOP; macOS answers O_DIRECTORY|O_NOFOLLOW with ENOTDIR.
256
+ # Either way the open refused it; the lstat only decides which refusal to name.
257
+ if exc.errno in _IS_SYMLINK or (exc.errno == errno.ENOTDIR and _is_symlink(name, dir_fd)):
258
+ return ArchiveError(f"refusing to operate through a symlink: {what} {path} is a symlink")
259
+ if exc.errno == errno.ENOTDIR:
260
+ return ArchiveError(f"{what} {path} is not a directory")
261
+ if exc.errno == errno.ENOENT:
262
+ return ArchiveError(missing())
263
+ return ArchiveError(f"cannot open {what} {path}: {exc.strerror}")
264
+
265
+
266
+ def _is_symlink(name: str, dir_fd: Optional[int]) -> bool:
267
+ try:
268
+ return stat.S_ISLNK(os.stat(name, dir_fd=dir_fd, follow_symlinks=False).st_mode)
269
+ except OSError:
270
+ return False
271
+
272
+
273
+ def _stat_regular_file(name: str, dir_fd: int, path: Path, what: str) -> os.stat_result:
274
+ try:
275
+ st = os.stat(name, dir_fd=dir_fd, follow_symlinks=False)
276
+ except FileNotFoundError:
277
+ raise ArchiveError(f"{what} not found: {path}") from None
278
+ except OSError as exc:
279
+ raise ArchiveError(f"cannot stat {what} {path}: {exc.strerror}") from None
280
+ if stat.S_ISLNK(st.st_mode):
281
+ raise ArchiveError(f"refusing to move a symlink: {what} {path}")
282
+ if not stat.S_ISREG(st.st_mode):
283
+ raise ArchiveError(f"{what} is not a regular file: {path}")
284
+ return st
285
+
286
+
287
+ def _require_absent(name: str, dir_fd: int, path: Path, what: str) -> None:
288
+ try:
289
+ os.stat(name, dir_fd=dir_fd, follow_symlinks=False)
290
+ except FileNotFoundError:
291
+ return
292
+ except OSError as exc:
293
+ raise ArchiveError(f"cannot stat {what} {path}: {exc.strerror}") from None
294
+ raise ArchiveError(f"{what} already exists: {path}")
295
+
296
+
297
+ def _refuse_active_identity(source_stat: os.stat_result, memory_fd: int, memory_dir: Path, memory_file: str) -> None:
298
+ """Refuse a source that *is* an active file, whatever name addresses it on this filesystem."""
299
+ for protected in PROTECTED_FILES:
300
+ try:
301
+ st = os.stat(protected, dir_fd=memory_fd, follow_symlinks=False)
302
+ except FileNotFoundError:
303
+ continue
304
+ except OSError as exc:
305
+ raise ArchiveError(f"cannot stat memory file {memory_dir / protected}: {exc.strerror}") from None
306
+ if (st.st_dev, st.st_ino) == (source_stat.st_dev, source_stat.st_ino):
307
+ raise ArchiveError(
308
+ f"cannot archive standard active memory file: {memory_file} is {protected} on this filesystem"
309
+ )
310
+
311
+
312
+ # --- the move --------------------------------------------------------------
313
+
314
+
315
+ def _move_no_replace(
316
+ src_name: str,
317
+ src_fd: int,
318
+ dst_name: str,
319
+ dst_fd: int,
320
+ expected: os.stat_result,
321
+ source: Path,
322
+ destination: Path,
323
+ ) -> None:
324
+ """Link ``src_name`` (in ``src_fd``) as ``dst_name`` (in ``dst_fd``) without replacing anything, then unlink the source.
325
+
326
+ Both syscalls run relative to the directory handles, so nothing re-resolves a
327
+ path after validation. The new link must be a regular file with the identity
328
+ that was checked; a symlink or a different inode planted at the source name
329
+ meanwhile is refused and the link (ours by construction) removed. Best effort
330
+ for a regular file: some filesystems reuse a freed inode number at once. The
331
+ name lies inside the validated directory either way.
332
+ """
333
+ try:
334
+ os.link(src_name, dst_name, src_dir_fd=src_fd, dst_dir_fd=dst_fd, follow_symlinks=False)
335
+ except FileExistsError:
336
+ raise ArchiveError(f"destination already exists: {destination}") from None
337
+ except OSError as exc:
338
+ if exc.errno in _NO_HARD_LINKS:
339
+ raise ArchiveError(
340
+ f"cannot link {source} to {destination}: {exc.strerror}; "
341
+ "archive and restore need a filesystem with hard links"
342
+ ) from None
343
+ raise ArchiveError(f"cannot link {source} to {destination}: {exc.strerror}") from None
344
+ try:
345
+ linked = os.stat(dst_name, dir_fd=dst_fd, follow_symlinks=False)
346
+ except OSError as exc:
347
+ raise ArchiveError(f"cannot stat the new link {destination}: {exc.strerror}") from None
348
+ if (linked.st_dev, linked.st_ino) != (expected.st_dev, expected.st_ino) or not stat.S_ISREG(linked.st_mode):
349
+ try:
350
+ os.unlink(dst_name, dir_fd=dst_fd)
351
+ except OSError:
352
+ pass
353
+ raise ArchiveError(f"refusing to complete the move: {source} changed after it was checked")
354
+ try:
355
+ os.unlink(src_name, dir_fd=src_fd)
356
+ except OSError as exc:
357
+ raise ArchiveError(
358
+ f"archived copy created at {destination} but the source could not be removed: {source}: {exc.strerror}"
359
+ ) from None