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,67 @@
1
+ # SPDX-FileCopyrightText: 2026 Kiloloop
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """What earlier tooling installed, so ``setup`` can retire it by exact match.
4
+
5
+ This is the one module in the package that names the kernel this tool grew
6
+ out of: the hook commands its setup registered and the exact scripts it
7
+ wrote. A registration is retired when its command equals one of these
8
+ strings; a script is removed only when its bytes match one of these
9
+ templates, digest for digest. Anything else at those paths is somebody's
10
+ own work: it is left alone and named in the report.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ from typing import Dict, Tuple
17
+
18
+ CLAUDE_LEGACY_PULL_COMMAND = ".claude/hooks/oacp-memory-pull.sh"
19
+ CLAUDE_LEGACY_PUSH_COMMAND = ".claude/hooks/oacp-memory-push.sh"
20
+
21
+ CLAUDE_LEGACY_PULL_SCRIPT = """\
22
+ #!/usr/bin/env bash
23
+ # Claude hook event: SessionStart (startup)
24
+ set -u
25
+
26
+ OACP_ROOT="${OACP_HOME:-$HOME/oacp}"
27
+ if [[ ! -f "$OACP_ROOT/.oacp-memory-repo" ]]; then
28
+ exit 0
29
+ fi
30
+
31
+ oacp memory pull --oacp-dir "$OACP_ROOT" || true
32
+ """
33
+
34
+ CLAUDE_LEGACY_PUSH_SCRIPT = """\
35
+ #!/usr/bin/env bash
36
+ # Claude hook event: SessionEnd / wrap-up
37
+ set -u
38
+
39
+ OACP_ROOT="${OACP_HOME:-$HOME/oacp}"
40
+ if [[ ! -f "$OACP_ROOT/.oacp-memory-repo" ]]; then
41
+ exit 0
42
+ fi
43
+
44
+ oacp memory push --oacp-dir "$OACP_ROOT" || true
45
+ """
46
+
47
+
48
+ def digest(text: str) -> str:
49
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
50
+
51
+
52
+ #: Registrations retired by exact command, per hook event.
53
+ CLAUDE_LEGACY_REGISTRATIONS: Dict[str, Tuple[str, ...]] = {
54
+ "SessionStart": (CLAUDE_LEGACY_PULL_COMMAND,),
55
+ "SessionEnd": (CLAUDE_LEGACY_PUSH_COMMAND,),
56
+ }
57
+
58
+ #: Files removed only when their digest is one of these, per repository-relative path.
59
+ CLAUDE_LEGACY_FILES: Dict[str, Tuple[str, ...]] = {
60
+ CLAUDE_LEGACY_PULL_COMMAND: (digest(CLAUDE_LEGACY_PULL_SCRIPT),),
61
+ CLAUDE_LEGACY_PUSH_COMMAND: (digest(CLAUDE_LEGACY_PUSH_SCRIPT),),
62
+ }
63
+
64
+ #: The codex startup command the kernel registers, and the flag that made it pull memory.
65
+ #: The entry stays (it verifies protocol files and status); only the flag is retired.
66
+ CODEX_SESSION_INIT_PREFIX: Tuple[str, ...] = ("oacp", "session-init", "--hook")
67
+ CODEX_LEGACY_PULL_FLAG = "--pull-memory"
@@ -0,0 +1,254 @@
1
+ # SPDX-FileCopyrightText: 2026 Kiloloop
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The startup manifest: ``agent-memory startup --runtime <r>``.
4
+
5
+ A session-start hook runs this once. It optionally pulls the home first,
6
+ then reports which memory files a session should read, in order, with each
7
+ file's readability, size and modification time as the pull left them, and
8
+ where the sync stands.
9
+ It never includes a file's content and never says a file was read: the
10
+ states are ``readable``, ``missing`` and ``unreadable``, nothing else, and
11
+ the manifest carries ``content_injected: false`` to say so. The list is
12
+ bounded by construction (the active project files, then the curated org
13
+ files, both from the layout table; ``events/``, ``debriefs/`` and
14
+ ``archive/`` are excluded), and the rendered text is cut at a character
15
+ budget with a notice, so a hook can never flood a session.
16
+
17
+ Output shapes: ``--json`` is the manifest itself, ``schema_version`` first;
18
+ the default is what the runtime's hook expects on stdout: plain text for
19
+ claude, whose session start takes stdout as context, and the hook JSON
20
+ envelope for codex, whose ``additionalContext`` carries the same text.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import datetime as dt
26
+ import os
27
+ import stat
28
+ from pathlib import Path
29
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
30
+
31
+ from . import layout, sync
32
+ from .git_runner import GitRunner, run_git
33
+
34
+ SCHEMA_VERSION = 1
35
+ RUNTIME_CLAUDE = "claude"
36
+ RUNTIME_CODEX = "codex"
37
+ RUNTIMES = (RUNTIME_CLAUDE, RUNTIME_CODEX)
38
+ DEFAULT_MAX_CHARS = 8000
39
+ #: The smallest budget the command line accepts; the notice fits in a few characters at any budget.
40
+ MIN_MAX_CHARS = 1
41
+
42
+ READABLE = "readable"
43
+ MISSING = "missing"
44
+ UNREADABLE = "unreadable"
45
+ RESULT_OK = "ok"
46
+ RESULT_DEGRADED = "degraded"
47
+ PULL_NOT_REQUESTED = "not_requested"
48
+ PULL_ERROR = "error"
49
+
50
+
51
+ def build_manifest(
52
+ home: Path,
53
+ *,
54
+ runtime: str,
55
+ project: Optional[str] = None,
56
+ pull: bool = False,
57
+ home_source: str = "flag",
58
+ project_source: Optional[str] = None,
59
+ notes: Sequence[str] = (),
60
+ runner: Optional[GitRunner] = None,
61
+ now: Optional[dt.datetime] = None,
62
+ ) -> Dict[str, Any]:
63
+ """The manifest for ``home``: the ordered files with their states, the sync's standing, warnings.
64
+
65
+ The pull, when requested, runs before any file is inspected, so sizes,
66
+ times and states describe the tree the session will read. ``notes`` are
67
+ warnings the caller already knows (why no project was resolved, say).
68
+ """
69
+ if runtime not in RUNTIMES:
70
+ raise ValueError(f"unknown runtime {runtime!r}; one of {', '.join(RUNTIMES)}")
71
+ home = Path(home).expanduser().absolute()
72
+ warnings: List[str] = list(notes)
73
+ files: List[Dict[str, Any]] = []
74
+
75
+ sync_info, pull_info = _sync(home, pull, runner, warnings)
76
+ if project is not None:
77
+ try:
78
+ layout.validate_project_name(project)
79
+ except ValueError as exc:
80
+ warnings.append(f"project {project!r}: {exc}; its files are skipped")
81
+ project = None
82
+ if not home.is_dir():
83
+ warnings.append(f"memory home {home} is not a directory; every file is missing")
84
+ if project is not None:
85
+ memory = layout.project_memory_dir(home, project)
86
+ files.extend(_entry(home, memory / name, layout.PROJECT.name, name) for name in layout.PROJECT.files)
87
+ else:
88
+ warnings.append("no project resolved; pass --project or bind the repository with `agent-memory init --repo .`")
89
+ org = layout.org_memory_dir(home)
90
+ files.extend(_entry(home, org / name, layout.ORG.name, name) for name in layout.ORG.files)
91
+ for entry in files:
92
+ if entry["state"] != READABLE:
93
+ reason = f" ({entry['error']})" if entry.get("error") else ""
94
+ warnings.append(f"{entry['relative']}: {entry['state']}{reason}")
95
+
96
+ excluded = [f"{layout.ORG.pattern}/{sub}/" for sub in layout.ORG.dirs]
97
+ if project is not None:
98
+ memory_rel = layout.project_memory_dir(home, project).relative_to(home).as_posix()
99
+ excluded.extend(f"{memory_rel}/{sub}/" for sub in layout.PROJECT.dirs)
100
+
101
+ moment = now or dt.datetime.now(dt.timezone.utc)
102
+ return {
103
+ "schema_version": SCHEMA_VERSION,
104
+ "runtime": runtime,
105
+ "generated_at_utc": _iso(moment),
106
+ "home": str(home),
107
+ "home_source": home_source,
108
+ "project": project,
109
+ "project_source": project_source if project is not None else None,
110
+ "content_injected": False,
111
+ "files": files,
112
+ "excluded": excluded,
113
+ "bytes_total": sum(int(entry["bytes"]) for entry in files),
114
+ "sync": sync_info,
115
+ "pull": pull_info,
116
+ "warnings": warnings,
117
+ "result": RESULT_DEGRADED if warnings else RESULT_OK,
118
+ }
119
+
120
+
121
+ def _entry(home: Path, path: Path, tier: str, name: str) -> Dict[str, Any]:
122
+ entry: Dict[str, Any] = {
123
+ "tier": tier,
124
+ "name": name,
125
+ "path": str(path),
126
+ "relative": path.relative_to(home).as_posix(),
127
+ "state": MISSING,
128
+ "bytes": 0,
129
+ "modified_at_utc": None,
130
+ }
131
+ try:
132
+ info = os.stat(path)
133
+ except FileNotFoundError:
134
+ return entry
135
+ except OSError as exc:
136
+ entry["state"] = UNREADABLE
137
+ entry["error"] = exc.strerror or str(exc)
138
+ return entry
139
+ if not stat.S_ISREG(info.st_mode):
140
+ entry["state"] = UNREADABLE
141
+ entry["error"] = "not a regular file"
142
+ return entry
143
+ try:
144
+ with open(path, "rb") as handle:
145
+ handle.read(1)
146
+ except OSError as exc:
147
+ entry["state"] = UNREADABLE
148
+ entry["error"] = exc.strerror or str(exc)
149
+ return entry
150
+ entry["state"] = READABLE
151
+ entry["bytes"] = info.st_size
152
+ entry["modified_at_utc"] = _iso(dt.datetime.fromtimestamp(info.st_mtime, dt.timezone.utc))
153
+ return entry
154
+
155
+
156
+ def _sync(
157
+ home: Path, pull: bool, runner: Optional[GitRunner], warnings: List[str]
158
+ ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
159
+ marker = sync.is_configured(home)
160
+ info: Dict[str, Any] = {"marker": marker, "last_commit_at_utc": None}
161
+ pull_info: Dict[str, Any] = {"requested": pull, "status": PULL_NOT_REQUESTED, "ok": True, "lines": []}
162
+ if pull:
163
+ try:
164
+ outcome = sync.pull(home, runner=runner)
165
+ pull_info = {"requested": True, "status": outcome.status, "ok": outcome.ok, "lines": list(outcome.lines)}
166
+ except (sync.SyncError, OSError) as exc:
167
+ pull_info = {"requested": True, "status": PULL_ERROR, "ok": False, "lines": [f"memory pull: {exc}"]}
168
+ if not pull_info["ok"]:
169
+ said = pull_info["lines"][0] if pull_info["lines"] else pull_info["status"]
170
+ warnings.append(f"memory pull did not complete; local memory may be stale: {said}")
171
+ if marker and sync.is_git_repo(home, runner):
172
+ result = (runner or run_git)(["log", "-1", "--format=%ct"], cwd=home, timeout=None)
173
+ stamp = result.stdout.strip()
174
+ if result.ok and stamp.isdigit():
175
+ info["last_commit_at_utc"] = _iso(dt.datetime.fromtimestamp(int(stamp), dt.timezone.utc))
176
+ return info, pull_info
177
+
178
+
179
+ # --- rendering --------------------------------------------------------------
180
+
181
+
182
+ def render_text(manifest: Dict[str, Any], *, max_chars: int = DEFAULT_MAX_CHARS) -> str:
183
+ """The manifest as the lines a session-start hook prints; cut at ``max_chars`` with a notice."""
184
+ head = f"agent-memory startup ({manifest['runtime']}): home {manifest['home']} ({manifest['home_source']})"
185
+ if manifest["project"] is not None:
186
+ head += f", project {manifest['project']} ({manifest['project_source'] or 'flag'})"
187
+ else:
188
+ head += ", no project"
189
+ lines = [head]
190
+ pull = manifest["pull"]
191
+ if pull["requested"]:
192
+ if pull["lines"]:
193
+ lines.extend(pull["lines"])
194
+ elif pull["status"] == "not_configured":
195
+ lines.append("memory pull: sync is not enabled for this home; skipped.")
196
+ if manifest["sync"]["last_commit_at_utc"]:
197
+ lines.append(f"memory sync: last commit {manifest['sync']['last_commit_at_utc']}.")
198
+
199
+ number = 0
200
+ project_files = [entry for entry in manifest["files"] if entry["tier"] == layout.PROJECT.name]
201
+ org_files = [entry for entry in manifest["files"] if entry["tier"] == layout.ORG.name]
202
+ if project_files:
203
+ lines.append("Project memory, read in this order (states are readability only; no content is injected):")
204
+ for entry in project_files:
205
+ number += 1
206
+ lines.append(f" {number}. {_describe(entry)}")
207
+ lines.append("Org memory, curated context; consult what governs the work before doing it (not read by default):")
208
+ for entry in org_files:
209
+ number += 1
210
+ lines.append(f" {number}. {_describe(entry)}")
211
+ lines.append(f"Excluded by default: {', '.join(manifest['excluded'])}")
212
+ if manifest["warnings"]:
213
+ lines.append("Warnings:")
214
+ lines.extend(f" - {warning}" for warning in manifest["warnings"])
215
+ return _bound("\n".join(lines) + "\n", max_chars, manifest["runtime"])
216
+
217
+
218
+ def render_codex_hook(manifest: Dict[str, Any], *, max_chars: int = DEFAULT_MAX_CHARS) -> Dict[str, Any]:
219
+ """The Codex ``SessionStart`` hook envelope carrying the rendered text as ``additionalContext``."""
220
+ output: Dict[str, Any] = {
221
+ "continue": True,
222
+ "hookSpecificOutput": {
223
+ "hookEventName": "SessionStart",
224
+ "additionalContext": render_text(manifest, max_chars=max_chars),
225
+ },
226
+ }
227
+ if manifest["result"] != RESULT_OK:
228
+ output["systemMessage"] = "agent-memory startup completed in degraded mode; see the warnings in its context."
229
+ return output
230
+
231
+
232
+ def _describe(entry: Dict[str, Any]) -> str:
233
+ if entry["state"] == READABLE:
234
+ return f"{entry['relative']}: readable, {entry['bytes']} bytes, modified {entry['modified_at_utc']}"
235
+ reason = f" ({entry['error']})" if entry.get("error") else ""
236
+ return f"{entry['relative']}: {entry['state']}{reason}"
237
+
238
+
239
+ def _bound(text: str, max_chars: int, runtime: str) -> str:
240
+ """``text`` cut to at most ``max_chars`` characters, notice included; a budget below zero counts as zero."""
241
+ max_chars = max(0, max_chars)
242
+ if len(text) <= max_chars:
243
+ return text
244
+ suffix = (
245
+ f"\n[agent-memory: manifest text cut at {max_chars} characters; "
246
+ f"run `agent-memory startup --runtime {runtime} --json` for the whole manifest]\n"
247
+ )
248
+ if len(suffix) >= max_chars:
249
+ suffix = "[cut]\n"[:max_chars]
250
+ return text[: max_chars - len(suffix)] + suffix
251
+
252
+
253
+ def _iso(moment: dt.datetime) -> str:
254
+ return moment.astimezone(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
agent_memory/status.py ADDED
@@ -0,0 +1,137 @@
1
+ # SPDX-FileCopyrightText: 2026 Kiloloop
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """``agent-memory status``: which home resolves, and where its sync stands.
4
+
5
+ The readout is the resolution (path, the rule that chose it, the bound
6
+ project), the layout (marker, allowlist, tiers), and, when the home is a sync
7
+ repository, its :class:`~agent_memory.sync.GitState`. The remote is contacted
8
+ only with ``--fetch``; otherwise ahead/behind count against the last fetched
9
+ upstream. Exit 0 when the tree is clean and not diverged, 1 when it is dirty
10
+ or diverged (or the home does not exist). Ahead and behind are reported, not
11
+ failed: they are what ``push`` and ``pull`` are for.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import List, Optional
19
+
20
+ from . import layout, sync
21
+ from .doctor import enclosing_repository, sync_state_text
22
+ from .git_runner import GitRunner
23
+ from .home import HomeResolution
24
+ from .sync import GitState
25
+
26
+ EXIT_OK = 0
27
+ EXIT_FAILED = 1
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Status:
32
+ """Everything the verb prints, plus the exit contract."""
33
+
34
+ home: Path
35
+ source: str
36
+ project: Optional[str]
37
+ exists: bool
38
+ marker: bool = False
39
+ gitignore: str = ""
40
+ org_memory: bool = False
41
+ projects: int = 0
42
+ #: ``None`` without the marker; otherwise whether the home is a git repository.
43
+ repository: Optional[bool] = None
44
+ #: Set when the home is a repository only by sitting inside another one.
45
+ enclosing: Optional[Path] = None
46
+ git: Optional[GitState] = None
47
+ #: Whether the remote was contacted for this readout.
48
+ fetched: bool = False
49
+
50
+ @property
51
+ def clean(self) -> bool:
52
+ """The home exists and its tree is neither dirty nor diverged."""
53
+ if not self.exists:
54
+ return False
55
+ return self.git is None or not (self.git.dirty or self.git.diverged)
56
+
57
+ @property
58
+ def exit_code(self) -> int:
59
+ return EXIT_OK if self.clean else EXIT_FAILED
60
+
61
+ def lines(self) -> List[str]:
62
+ lines = [f"home: {self.home}", f"source: {self.source}"]
63
+ if self.project:
64
+ lines.append(f"project: {self.project}")
65
+ if not self.exists:
66
+ lines.append("exists: no")
67
+ return lines
68
+ lines.extend(
69
+ [
70
+ "exists: yes",
71
+ f"marker: {'present' if self.marker else 'absent'}",
72
+ f"gitignore: {self.gitignore}",
73
+ f"org-memory: {'present' if self.org_memory else 'absent'}",
74
+ f"projects: {self.projects} with a memory dir",
75
+ ]
76
+ )
77
+ if self.repository is None:
78
+ lines.append("sync: not configured")
79
+ elif not self.repository:
80
+ lines.append("sync: marker present, but the home is not a git repository")
81
+ elif self.enclosing is not None:
82
+ lines.append(f"sync: marker present, but the home is inside the repository at {self.enclosing}, not one of its own")
83
+ elif self.git is not None:
84
+ lines.append(f"sync: {sync_state_text(self.git)}")
85
+ if self.git.has_remote:
86
+ lines.append("fetch: done" if self.fetched else "fetch: skipped (pass --fetch to contact the remote)")
87
+ lines.append(f"tree: {'dirty' if self.git.dirty else 'clean'}")
88
+ return lines
89
+
90
+
91
+ def inspect(resolution: HomeResolution, *, fetch: bool = False, runner: Optional[GitRunner] = None) -> Status:
92
+ """Read the home ``resolution`` names; nothing is changed, and no network without ``fetch``."""
93
+ home = resolution.path
94
+ if not home.is_dir():
95
+ return Status(home, resolution.source, resolution.project, exists=False)
96
+ tiers = layout.allowed_memory_dirs(home)
97
+ org = layout.org_memory_dir(home)
98
+ marker = sync.is_configured(home)
99
+ repository: Optional[bool] = None
100
+ enclosing: Optional[Path] = None
101
+ git: Optional[GitState] = None
102
+ if marker:
103
+ repository = sync.is_git_repo(home, runner)
104
+ if repository:
105
+ enclosing = enclosing_repository(home, runner)
106
+ if repository and enclosing is None:
107
+ git = sync.git_state(home, runner=runner, fetch=fetch)
108
+ return Status(
109
+ home,
110
+ resolution.source,
111
+ resolution.project,
112
+ exists=True,
113
+ marker=marker,
114
+ gitignore=gitignore_state(home),
115
+ org_memory=org in tiers,
116
+ projects=len([path for path in tiers if path != org]),
117
+ repository=repository,
118
+ enclosing=enclosing,
119
+ git=git,
120
+ fetched=fetch and git is not None and git.has_remote,
121
+ )
122
+
123
+
124
+ def gitignore_state(home: Path) -> str:
125
+ """How the root ``.gitignore`` relates to the canonical allowlist, in a few words."""
126
+ path = home / layout.GITIGNORE_FILE
127
+ try:
128
+ data = path.read_bytes()
129
+ except FileNotFoundError:
130
+ return "absent"
131
+ except OSError as exc:
132
+ return f"unreadable ({exc.strerror})"
133
+ if data == layout.gitignore_text().encode("utf-8"):
134
+ return "canonical"
135
+ if sync.gitignore_has_managed_block(data.decode("utf-8", errors="replace")):
136
+ return "canonical managed block, other lines kept"
137
+ return "present, differs from canonical"