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,68 @@
1
+ # SPDX-FileCopyrightText: 2026 Kiloloop
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Run git for the sync engine.
4
+
5
+ Every git invocation goes through one :class:`GitRunner`, so tests can record
6
+ calls or script answers without a repository, and the network verbs can carry
7
+ a timeout the default runner enforces. The runner never interprets git's
8
+ output; that is the engine's job.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import subprocess
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Optional, Protocol, Sequence
17
+
18
+ #: Exit codes the default runner synthesizes when git itself did not run.
19
+ EXIT_TIMEOUT = 124
20
+ EXIT_NOT_FOUND = 127
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class GitResult:
25
+ """What one git invocation returned."""
26
+
27
+ returncode: int
28
+ stdout: str = ""
29
+ stderr: str = ""
30
+
31
+ @property
32
+ def ok(self) -> bool:
33
+ return self.returncode == 0
34
+
35
+ @property
36
+ def timed_out(self) -> bool:
37
+ return self.returncode == EXIT_TIMEOUT
38
+
39
+ @property
40
+ def output(self) -> str:
41
+ """stdout and stderr together, stripped: the text for a message."""
42
+ return "\n".join(part for part in (self.stdout, self.stderr) if part).strip()
43
+
44
+
45
+ class GitRunner(Protocol):
46
+ """Run ``git`` with ``args`` in ``cwd``; ``timeout`` is seconds or ``None``."""
47
+
48
+ def __call__(self, args: Sequence[str], *, cwd: Path, timeout: Optional[float] = None) -> GitResult: ...
49
+
50
+
51
+ def run_git(args: Sequence[str], *, cwd: Path, timeout: Optional[float] = None) -> GitResult:
52
+ """The default runner: a subprocess, output captured, timeouts enforced."""
53
+ command = ["git", *args]
54
+ try:
55
+ completed = subprocess.run(
56
+ command,
57
+ cwd=str(cwd),
58
+ capture_output=True,
59
+ encoding="utf-8",
60
+ errors="surrogateescape",
61
+ check=False,
62
+ timeout=timeout,
63
+ )
64
+ except FileNotFoundError:
65
+ return GitResult(EXIT_NOT_FOUND, "", "git: command not found")
66
+ except subprocess.TimeoutExpired:
67
+ return GitResult(EXIT_TIMEOUT, "", f"git {' '.join(args)}: timed out after {timeout:g}s")
68
+ return GitResult(completed.returncode, completed.stdout, completed.stderr)
agent_memory/home.py ADDED
@@ -0,0 +1,246 @@
1
+ # SPDX-FileCopyrightText: 2026 Kiloloop
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Find the memory home.
4
+
5
+ Resolution order; the first hit wins:
6
+
7
+ 1. an explicit path (the ``--home`` flag);
8
+ 2. ``$AGENT_MEMORY_HOME``;
9
+ 3. ``$OACP_HOME``, recognized so existing homes keep working, never required;
10
+ 4. the nearest ``.agent-memory.json`` binding, walking up from the working
11
+ directory: ``{"schema_version": 1, "home": "...", "project": "..."}``;
12
+ 5. a workspace marker, walking up from the working directory: a symlink, or a
13
+ file named ``workspace.json``, whose real path has the shape
14
+ ``<home>/projects/<name>/workspace.json``; the marker names the project too;
15
+ 6. ``~/agent-memory``.
16
+
17
+ Any directory entry with the binding's name is the binding, and one that
18
+ is not a readable, well-formed v1 binding -- a dangling symlink, a directory,
19
+ unreadable or malformed JSON, an unknown ``schema_version``, no home -- is an
20
+ error, never a fall-through: silently picking a different store is worse
21
+ than stopping. So is an ancestor directory the process cannot inspect: it
22
+ might hold a binding, and only a directory known to hold none keeps the
23
+ walk going. The resolver only reads paths; it never asks whether any other
24
+ tool is installed.
25
+
26
+ The project is resolved separately (:func:`find_project`): when a flag or
27
+ an environment variable chose the home, the nearest binding or marker still
28
+ names the project, provided it binds the repository to that same home. A
29
+ binding for a different home lends nothing; the mismatch is reported.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import os
36
+ from dataclasses import dataclass
37
+ from pathlib import Path
38
+ from typing import Iterator, Mapping, Optional, Tuple
39
+
40
+ from .layout import PROJECTS_DIR
41
+
42
+ ENV_HOME = "AGENT_MEMORY_HOME"
43
+ ENV_COMPAT_HOME = "OACP_HOME"
44
+ BINDING_FILE = ".agent-memory.json"
45
+ BINDING_SCHEMA_VERSION = 1
46
+ WORKSPACE_FILE = "workspace.json"
47
+ DEFAULT_HOME = "~/agent-memory"
48
+
49
+ SOURCE_FLAG = "flag"
50
+ SOURCE_DEFAULT = "default"
51
+
52
+
53
+ class HomeError(Exception):
54
+ """The home could not be resolved safely."""
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class HomeResolution:
59
+ """Where the home is and which rule chose it."""
60
+
61
+ path: Path
62
+ #: ``flag``, ``env:<NAME>``, ``binding:<file>``, ``marker:<file>`` or ``default``.
63
+ source: str
64
+ #: The project a binding or a marker named, when one of them chose the home.
65
+ project: Optional[str] = None
66
+
67
+
68
+ def resolve_home(
69
+ explicit: Optional[str] = None,
70
+ *,
71
+ env: Optional[Mapping[str, str]] = None,
72
+ cwd: Optional[Path] = None,
73
+ ) -> HomeResolution:
74
+ """Apply the resolution order and return the first hit.
75
+
76
+ ``env`` and ``cwd`` default to the process environment and working
77
+ directory; tests pass their own to stay hermetic.
78
+ """
79
+ environ: Mapping[str, str] = os.environ if env is None else env
80
+ if explicit is not None:
81
+ return HomeResolution(_expand(explicit), SOURCE_FLAG)
82
+ for name in (ENV_HOME, ENV_COMPAT_HOME):
83
+ value = environ.get(name)
84
+ if value:
85
+ return HomeResolution(_expand(value), f"env:{name}")
86
+ start = (Path.cwd() if cwd is None else Path(cwd)).expanduser().absolute()
87
+ for finder in (find_binding, find_workspace_marker):
88
+ found = finder(start)
89
+ if found is not None:
90
+ return found
91
+ return HomeResolution(_expand(DEFAULT_HOME), SOURCE_DEFAULT)
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class ProjectResolution:
96
+ """Which project the repository at hand belongs to, and how that was decided."""
97
+
98
+ project: Optional[str]
99
+ #: ``binding:<file>`` or ``marker:<file>``; ``None`` when no project was found.
100
+ source: Optional[str]
101
+ #: Why a binding or marker that was found did not name the project, when one was found.
102
+ note: Optional[str] = None
103
+
104
+
105
+ def find_project(home: Path, start: Path) -> ProjectResolution:
106
+ """The project the nearest binding or marker at or above ``start`` names for ``home``.
107
+
108
+ The same walk :func:`resolve_home` makes, with the same fail-closed binding
109
+ handling; the first binding or marker found decides. It names the project
110
+ only when it binds the repository to ``home`` itself: a binding for another
111
+ home says nothing about this one, and borrowing its project would list the
112
+ wrong files.
113
+ """
114
+ start = Path(start).expanduser().absolute()
115
+ for finder in (find_binding, find_workspace_marker):
116
+ found = finder(start)
117
+ if found is None:
118
+ continue
119
+ if not _same_path(found.path, home):
120
+ return ProjectResolution(
121
+ None, None, f"{found.source} binds this repository to {found.path}, not to {home}; no project taken from it"
122
+ )
123
+ if found.project is None:
124
+ return ProjectResolution(None, None, f"{found.source} names no project")
125
+ return ProjectResolution(found.project, found.source)
126
+ return ProjectResolution(None, None)
127
+
128
+
129
+ def _same_path(first: Path, second: Path) -> bool:
130
+ return os.path.realpath(Path(first).expanduser()) == os.path.realpath(Path(second).expanduser())
131
+
132
+
133
+ def find_binding(start: Path) -> Optional[HomeResolution]:
134
+ """Load the nearest binding entry at or above ``start``; ``None`` when there is none.
135
+
136
+ Any directory entry with the binding's name counts, a dangling symlink or
137
+ a directory included: an entry that turns out unusable is an error from
138
+ :func:`load_binding`, never a reason to keep walking up.
139
+ """
140
+ for directory in _ancestors(start):
141
+ candidate = directory / BINDING_FILE
142
+ try:
143
+ os.lstat(candidate)
144
+ except FileNotFoundError:
145
+ continue
146
+ except OSError as exc:
147
+ # An ancestor the process may not inspect could hold a binding; walking
148
+ # past it would silently pick another store. Absence and inability are
149
+ # different answers, and only the first keeps the walk going.
150
+ raise HomeError(f"{candidate}: cannot inspect the binding slot: {exc.strerror or exc}") from exc
151
+ return load_binding(candidate)
152
+ return None
153
+
154
+
155
+ def load_binding(path: Path) -> HomeResolution:
156
+ """Parse one binding file; anything but a well-formed v1 binding raises :class:`HomeError`."""
157
+ if not path.is_file():
158
+ raise HomeError(f"{path}: binding {_describe_non_file(path)}")
159
+ try:
160
+ data = json.loads(path.read_text(encoding="utf-8"))
161
+ except (OSError, ValueError) as exc:
162
+ raise HomeError(f"{path}: cannot read binding: {exc}") from exc
163
+ if not isinstance(data, dict):
164
+ raise HomeError(f"{path}: binding must be a JSON object")
165
+ version = data.get("schema_version")
166
+ if isinstance(version, bool) or not isinstance(version, int) or version != BINDING_SCHEMA_VERSION:
167
+ raise HomeError(
168
+ f"{path}: unsupported binding schema_version {version!r} (this tool reads {BINDING_SCHEMA_VERSION})"
169
+ )
170
+ home = data.get("home")
171
+ if not isinstance(home, str) or not home:
172
+ raise HomeError(f"{path}: binding must name a non-empty 'home'")
173
+ project = data.get("project")
174
+ if project is not None and (not isinstance(project, str) or not project):
175
+ raise HomeError(f"{path}: binding 'project' must be a non-empty string when present")
176
+ home_path = _expand(home)
177
+ if not home_path.is_absolute():
178
+ home_path = path.parent / home_path
179
+ return HomeResolution(home_path, f"binding:{path}", project=project)
180
+
181
+
182
+ def find_workspace_marker(start: Path) -> Optional[HomeResolution]:
183
+ """Find the nearest workspace marker at or above ``start``; ``None`` when there is none.
184
+
185
+ A marker is any symlink, or a file named ``workspace.json``, whose real
186
+ path has the shape ``<home>/projects/<name>/workspace.json``. The shape
187
+ is the guard: an editor's ``workspace.json`` in a repo root never sits
188
+ two levels below a ``projects`` directory. The symlink's own name is not
189
+ load-bearing, so a repo can call it whatever it likes.
190
+ """
191
+ for directory in _ancestors(start):
192
+ for candidate in _marker_candidates(directory):
193
+ found = _home_from_workspace_file(candidate)
194
+ if found is not None:
195
+ home, project = found
196
+ return HomeResolution(home, f"marker:{candidate}", project=project)
197
+ return None
198
+
199
+
200
+ def _marker_candidates(directory: Path) -> Iterator[Path]:
201
+ plain = directory / WORKSPACE_FILE
202
+ if plain.exists():
203
+ yield plain
204
+ try:
205
+ entries = sorted(directory.iterdir())
206
+ except OSError:
207
+ return
208
+ for entry in entries:
209
+ if entry.name != WORKSPACE_FILE and entry.is_symlink():
210
+ yield entry
211
+
212
+
213
+ def _home_from_workspace_file(path: Path) -> Optional[Tuple[Path, str]]:
214
+ """``(home, project)`` when ``path`` resolves to ``<home>/projects/<project>/workspace.json``."""
215
+ try:
216
+ resolved = path.resolve(strict=True)
217
+ except (OSError, RuntimeError):
218
+ return None
219
+ if resolved.name != WORKSPACE_FILE or not resolved.is_file():
220
+ return None
221
+ projects = resolved.parent.parent
222
+ if projects.name != PROJECTS_DIR:
223
+ return None
224
+ return projects.parent, resolved.parent.name
225
+
226
+
227
+ def _ancestors(start: Path) -> Iterator[Path]:
228
+ yield start
229
+ yield from start.parents
230
+
231
+
232
+ def _describe_non_file(path: Path) -> str:
233
+ if path.is_dir():
234
+ return "is a directory, not a file"
235
+ if path.is_symlink():
236
+ return "is a symlink whose target is missing"
237
+ if not os.path.lexists(path):
238
+ return "does not exist"
239
+ return "is not a regular file"
240
+
241
+
242
+ def _expand(value: str) -> Path:
243
+ try:
244
+ return Path(value).expanduser()
245
+ except RuntimeError as exc: # ``~user`` for a user the system cannot look up
246
+ raise HomeError(f"cannot expand {value!r}: {exc}") from exc
agent_memory/layout.py ADDED
@@ -0,0 +1,198 @@
1
+ # SPDX-FileCopyrightText: 2026 Kiloloop
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The memory-home layout, declared once.
4
+
5
+ Every other encoding of the layout is derived from the ``TIERS`` table
6
+ below: the sync allowlist written to a home's ``.gitignore``, the directories
7
+ a sync may stage, the per-path allow check, and the files and subdirectories
8
+ a fresh tier starts with. Change the table and every derivation follows;
9
+ nothing else in the package spells these names.
10
+
11
+ A home has two tiers::
12
+
13
+ <home>/
14
+ .gitignore the sync allowlist (gitignore_text)
15
+ .oacp-memory-repo present when the home syncs through git
16
+ org-memory/ cross-project memory
17
+ projects/<name>/memory/ per-project memory
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import Callable, Iterator, List, Optional, Sequence, Tuple
26
+
27
+ #: Marks a home whose memory tiers sync through git. The name is a compatibility
28
+ #: contract with every existing home; keep it verbatim.
29
+ MARKER_FILE = ".oacp-memory-repo"
30
+ GITIGNORE_FILE = ".gitignore"
31
+ PROJECTS_DIR = "projects"
32
+ #: Local setup receipts, one per runtime and repository. Not a tier: the allowlist
33
+ #: never selects it, so it stays on the machine that wrote it.
34
+ SETUP_DIR = "setup"
35
+ WILDCARD = "*"
36
+
37
+ #: Top-level directories that must never sync, listed after every allow rule so
38
+ #: the deny wins even if the allowlist is widened later.
39
+ NEVER_SYNCED_DIRS: Tuple[str, ...] = ("keys",)
40
+ _NEVER_SYNCED_COMMENT = "# never sync private key material — explicit deny, wins over any future allowlist widening"
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Tier:
45
+ """One memory tier: where it lives under the home and what a fresh one holds."""
46
+
47
+ name: str
48
+ #: Path pattern relative to the home; ``*`` stands for one project name.
49
+ pattern: str
50
+ #: Files a fresh tier directory starts with; their content is the scaffolding verbs' business.
51
+ files: Tuple[str, ...]
52
+ #: Subdirectories a fresh tier directory starts with.
53
+ dirs: Tuple[str, ...]
54
+ #: Subdirectories inside the tier that never sync.
55
+ unsynced: Tuple[str, ...] = ()
56
+
57
+ @property
58
+ def parts(self) -> Tuple[str, ...]:
59
+ return tuple(self.pattern.split("/"))
60
+
61
+
62
+ ORG = Tier(
63
+ name="org",
64
+ pattern="org-memory",
65
+ files=("recent.md", "decisions.md", "rules.md"),
66
+ dirs=("events", "debriefs"),
67
+ )
68
+ PROJECT = Tier(
69
+ name="project",
70
+ pattern=f"{PROJECTS_DIR}/{WILDCARD}/memory",
71
+ files=("project_facts.md", "decision_log.md", "open_threads.md", "known_debt.md"),
72
+ dirs=("archive",),
73
+ unsynced=(".cache",),
74
+ )
75
+
76
+ #: The whole layout. The order is the order of the allowlist lines.
77
+ TIERS: Tuple[Tier, ...] = (ORG, PROJECT)
78
+
79
+
80
+ def gitignore_text() -> str:
81
+ """The canonical sync allowlist for a home's ``.gitignore``, byte for byte."""
82
+ lines = [WILDCARD, f"!{WILDCARD}/", f"!{GITIGNORE_FILE}", f"!{MARKER_FILE}"]
83
+ lines.extend(f"!{tier.pattern}/**" for tier in TIERS)
84
+ lines.extend(f"{tier.pattern}/{sub}/" for tier in TIERS for sub in tier.unsynced)
85
+ lines.append(_NEVER_SYNCED_COMMENT)
86
+ lines.extend(f"{name}/" for name in NEVER_SYNCED_DIRS)
87
+ return "\n".join(lines) + "\n"
88
+
89
+
90
+ def org_memory_dir(home: Path) -> Path:
91
+ return home.joinpath(*ORG.parts)
92
+
93
+
94
+ def project_memory_dir(home: Path, project: str) -> Path:
95
+ validate_project_name(project)
96
+ return home.joinpath(*(project if part == WILDCARD else part for part in PROJECT.parts))
97
+
98
+
99
+ def validate_project_name(project: str) -> None:
100
+ if not project or project.startswith(".") or "/" in project or "\\" in project:
101
+ raise ValueError("project name must be non-empty, contain no path separators and not start with '.'")
102
+
103
+
104
+ def allowed_memory_dirs(home: Path) -> List[Path]:
105
+ """Existing tier directories under ``home``, in allowlist order; projects sorted by name."""
106
+ return [path for tier in TIERS for path in _expand(home, tier.parts) if path.exists()]
107
+
108
+
109
+ def _expand(base: Path, parts: Sequence[str]) -> Iterator[Path]:
110
+ if not parts:
111
+ yield base
112
+ return
113
+ head, rest = parts[0], parts[1:]
114
+ if head != WILDCARD:
115
+ yield from _expand(base / head, rest)
116
+ return
117
+ try:
118
+ children = sorted(base.iterdir())
119
+ except OSError:
120
+ return
121
+ for child in children:
122
+ yield from _expand(child, rest)
123
+
124
+
125
+ def is_allowed_memory_path(path: str) -> bool:
126
+ """Whether a home-relative POSIX path is inside the sync allowlist.
127
+
128
+ A component named in ``NEVER_SYNCED_DIRS`` denies the path at any depth,
129
+ whatever the home's ``.gitignore`` says: the predicate, not the ignore
130
+ file, is what the sync engine trusts.
131
+ """
132
+ if path in (GITIGNORE_FILE, MARKER_FILE):
133
+ return True
134
+ parts = path.split("/")
135
+ if any(part in NEVER_SYNCED_DIRS for part in parts):
136
+ return False
137
+ for tier in TIERS:
138
+ pattern = tier.parts
139
+ if len(parts) <= len(pattern):
140
+ continue
141
+ if all(want == WILDCARD or want == have for want, have in zip(pattern, parts)):
142
+ return parts[len(pattern)] not in tier.unsynced
143
+ return False
144
+
145
+
146
+ def scaffold_home(home: Path, project: Optional[str] = None) -> List[Path]:
147
+ """Lay out ``home``: its directories, the canonical ``.gitignore`` and the org
148
+ tier, plus one project tier when ``project`` is given.
149
+
150
+ Only missing paths are created and nothing that exists is touched, so a
151
+ rerun on a complete home returns an empty list. The ``.gitignore`` slot
152
+ has a tier file's guarantee: whatever occupies it, a dangling link
153
+ included, is kept and never followed (:func:`write_if_absent`). Tier
154
+ *files* are not written here; their content ships with the scaffolding
155
+ verbs.
156
+
157
+ Returns the paths created, in creation order.
158
+ """
159
+ created: List[Path] = []
160
+
161
+ def mkdir(path: Path) -> None:
162
+ if not path.is_dir():
163
+ path.mkdir(parents=True)
164
+ created.append(path)
165
+
166
+ mkdir(home)
167
+ gitignore = home / GITIGNORE_FILE
168
+ if write_if_absent(gitignore, gitignore_text().encode("utf-8")):
169
+ created.append(gitignore)
170
+ mkdir(home / PROJECTS_DIR)
171
+ _scaffold_tier(ORG, org_memory_dir(home), mkdir)
172
+ if project is not None:
173
+ _scaffold_tier(PROJECT, project_memory_dir(home, project), mkdir)
174
+ return created
175
+
176
+
177
+ def write_if_absent(path: Path, data: bytes) -> bool:
178
+ """Create ``path`` holding ``data`` when no directory entry is there; True when it was created.
179
+
180
+ Whatever occupies the slot is kept and never followed: a regular file, a
181
+ directory, or a symlink, dangling included. The existence probe is only
182
+ advisory; the open is exclusive, so an entry that appears between the two
183
+ is kept as well. Any other failure propagates as ``OSError``.
184
+ """
185
+ if os.path.lexists(path):
186
+ return False
187
+ try:
188
+ with open(path, "xb") as handle:
189
+ handle.write(data)
190
+ except FileExistsError:
191
+ return False
192
+ return True
193
+
194
+
195
+ def _scaffold_tier(tier: Tier, root: Path, mkdir: Callable[[Path], None]) -> None:
196
+ mkdir(root)
197
+ for sub in tier.dirs:
198
+ mkdir(root / sub)