Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to agent-memory are documented in this file. The format
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 0.1.3 - 2026-09-29

### Fixed

- `pull`, `push`, and SessionStart sync can resolve homes with local memory changes behind upstream ([sync](docs/commands.md#sync)).

## 0.1.2 - 2026-09-17

### Added
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Python 3.10+ is required; sync needs git 2.25+, hooks use Bash, and
agent-memory enable --remote git@github.com:YOUR_ORG/agent-memory-store.git
agent-memory push --agent codex
# After another machine pushes, with a clean local tree:
agent-memory pull
agent-memory pull --agent claude
```

Elsewhere, `agent-memory clone <remote-url> --home <local-home>`, then repeat
Expand Down Expand Up @@ -111,13 +111,14 @@ points into a home's `projects/` tree. Project selection uses `--project` or a
matching binding/marker. OACP is not required.

Sync makes the home a git repository with an allowlist and `.oacp-memory-repo`
marker. `push` commits selected paths; `pull` fast-forwards a clean tree that
is not ahead or diverged. No merges; keys and setup receipts stay local.
Network verbs time out after 30 seconds.
marker. `push` and `pull --agent NAME` commit selected paths, rebase when behind,
and deliver. A plain `pull` updates a clean tree and reports unpushed commits.
Conflicts abort the rebase and retain local commits. Keys and setup receipts
stay local. Network and rebase operations time out after 30 seconds.

`setup` installs a SessionStart hook and memory workflow. `startup` lists
metadata for the four project files, then the three curated org files; it
injects no content. `--pull` refreshes first, warning on failure. The workflow
injects no content. `--pull` commits and syncs first under the runtime identity, warning on failure. The workflow
tells the agent to run `recall` for an 8,000-character bounded read. Raise
`--max-chars` or read remaining files directly when cut. `capture` records
decisions during work. At the end, update threads and debt, optionally publish
Expand Down
33 changes: 21 additions & 12 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,17 +78,26 @@ credentials. `org init` is the org tier alone, for a home that already exists.
the head of its `.gitignore` as a managed block (existing lines are kept, lines
an earlier block carried are retired, and the write comes with a before/after
receipt that names them), drops the sync marker, and makes
one commit. With `--remote`, `enable` also pushes that initial commit. `push` commits only what the allowlist selects, as a partial
commit, so anything else staged in the index stays staged and uncommitted;
it refuses a home that is behind or diverged, and a push the remote rejects
leaves the local commit in place and says so. A credential helper a sandbox
blocked is a distinct failure from a rejection -- git prompts for a username
and has no terminal to read it from -- and `push` names it as such; the remedy
is to rerun with the sandbox off. `pull` fast-forwards only when
the tree is clean, not ahead, not diverged, and has an upstream. `clone`
brings a memory repository down; `disable` removes the marker. The network
verbs time out after 30 seconds. The engine never reads memory content,
never merges, and never touches `keys/`. It needs git 2.25 or newer.
one commit. With `--remote`, `enable` also pushes that initial commit.

`push` and `pull --agent NAME` commit only what the allowlist selects, fetch,
rebase local commits onto upstream when behind, then push. Anything else
staged stays staged and uncommitted. If unrelated changes remain when an
update is needed, the command reports the paths and stops without stashing
them. A rebase conflict aborts, names the conflicting paths, and retains the
local commit; differing same-path additions are never automatically resolved.
Identical additions already upstream need no duplicate commit after rebase.
Local merge commits require manual integration so their resolutions cannot
be lost. An existing Git operation must be finished before either verb can run.

A plain `pull` updates a clean home and reports any commits still ahead;
a dirty home requires `--agent NAME`. `startup --pull` supplies its runtime
name as the agent, so SessionStart can also resolve dirty, behind homes.
A failed fetch or rejected push leaves newly committed work local. A credential
helper blocked by the sandbox is reported separately; rerun with the sandbox
off. `clone` brings a memory repository down; `disable` removes the marker.
Network and rebase operations time out after 30 seconds. The engine does not
read memory content directly and never touches `keys/`. It needs git 2.25 or newer.

## Archive and restore

Expand Down Expand Up @@ -142,7 +151,7 @@ edited, never written through a symlink, and recorded in the receipt.
`startup --runtime <claude|codex>` prints the session-start manifest: the
project's four active files, then the three curated org files, each with its
readability, size and modification time, and where the sync stands; with
`--pull` it fast-forwards the home first, and the files are described as
`--pull` it commits and syncs the home under the runtime identity first, and the files are described as
the pull left them. `events/`, `debriefs/` and `archive/` are excluded. No
content is included and no file is claimed as read
(`content_injected: false`); the text, notice included, is cut at a
Expand Down
2 changes: 1 addition & 1 deletion src/agent_memory/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@

from .home import HomeError, HomeResolution, resolve_home

__version__ = "0.1.2"
__version__ = "0.1.3"

__all__ = ["HomeError", "HomeResolution", "__version__", "resolve_home"]
6 changes: 3 additions & 3 deletions src/agent_memory/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ def add(name: str, handler: Callable[[argparse.Namespace], int], help_text: str,
clone_ = add("clone", _clone, "Clone a memory repository into the home.")
clone_.add_argument("url", help="git remote URL to clone")
clone_.add_argument("--force", action="store_true", help="move a non-empty home aside before cloning")
add("pull", _pull, "Fast-forward the home from its upstream when the tree is clean and not ahead.")
add("pull", _pull, "Update from upstream; with --agent, commit allowlisted changes and deliver them too.", agent)
add("push", _push, "Commit the allowlisted memory changes and push them when a remote exists.", agent)
add("disable", _disable, "Remove the sync marker; the repository stays in place.")
for name, handler, help_text, positional, positional_help in (
Expand Down Expand Up @@ -121,7 +121,7 @@ def add(name: str, handler: Callable[[argparse.Namespace], int], help_text: str,
"startup",
_startup,
"Print the session-start manifest: the memory files to read, in order, with their readability, size and "
"age, and where the sync stands. Content is never included. With --pull, fast-forward the home first.",
"age, and where the sync stands. Content is never included. With --pull, commit and sync memory under the runtime identity first.",
)
startup_.add_argument("--runtime", choices=startup.RUNTIMES, required=True, help="shape the output for this runtime's hook")
startup_.add_argument("--project", metavar="ID", help="the project tier to list (default: the one the binding or marker names)")
Expand Down Expand Up @@ -297,7 +297,7 @@ def _clone(args: argparse.Namespace) -> int:


def _pull(args: argparse.Namespace) -> int:
return _report(sync.pull(resolve_home(args.home).path))
return _report(sync.pull(resolve_home(args.home).path, agent=args.agent))


def _push(args: argparse.Namespace) -> int:
Expand Down
6 changes: 3 additions & 3 deletions src/agent_memory/startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def build_manifest(
warnings: List[str] = list(notes)
files: List[Dict[str, Any]] = []

sync_info, pull_info = _sync(home, pull, runner, warnings)
sync_info, pull_info = _sync(home, pull, runner, warnings, runtime)
if project is not None:
try:
layout.validate_project_name(project)
Expand Down Expand Up @@ -154,14 +154,14 @@ def _entry(home: Path, path: Path, tier: str, name: str) -> Dict[str, Any]:


def _sync(
home: Path, pull: bool, runner: Optional[GitRunner], warnings: List[str]
home: Path, pull: bool, runner: Optional[GitRunner], warnings: List[str], runtime: str
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
marker = sync.is_configured(home)
info: Dict[str, Any] = {"marker": marker, "last_commit_at_utc": None}
pull_info: Dict[str, Any] = {"requested": pull, "status": PULL_NOT_REQUESTED, "ok": True, "lines": []}
if pull:
try:
outcome = sync.pull(home, runner=runner)
outcome = sync.pull(home, agent=runtime, runner=runner)
pull_info = {"requested": True, "status": outcome.status, "ok": outcome.ok, "lines": list(outcome.lines)}
except (sync.SyncError, OSError) as exc:
pull_info = {"requested": True, "status": PULL_ERROR, "ok": False, "lines": [f"memory pull: {exc}"]}
Expand Down
148 changes: 104 additions & 44 deletions src/agent_memory/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@

The engine owns the sync marker, the managed block of the home's
``.gitignore``, the git state readout and the verbs ``init``, ``clone``,
``pull``, ``push`` and ``disable``. It never reads memory content, never
merges and never touches ``keys/``.
``pull``, ``push`` and ``disable``. It never reads memory content directly and never touches ``keys/``.

The publication boundary
------------------------
Expand All @@ -27,10 +26,10 @@
every write comes back with a before/after receipt that names them.

The network verbs (fetch, pull, push, clone) run under a 30 s timeout.
``pull`` fast-forwards only when the tree is clean, not ahead, not diverged
and an upstream exists. ``push`` refuses a repository that is behind or
diverged, and a push the remote rejects leaves the local commit in place and
says so: a local-only commit and remote delivery are distinct outcomes.
``push`` and ``pull --agent`` commit allowlisted changes before fetching,
rebase when behind, then deliver. A clean ``pull`` without an agent updates
and reports unpushed commits. Conflicts abort the rebase and keep local
commits; rejected delivery also leaves them local.
"""

from __future__ import annotations
Expand Down Expand Up @@ -350,39 +349,43 @@ def clone(home: Path, url: str, *, force: bool = False, runner: Optional[GitRunn
)


def pull(home: Path, *, runner: Optional[GitRunner] = None) -> Outcome:
"""Fast-forward ``home`` from its upstream when that is the only thing that could happen.
def pull(
home: Path,
*,
agent: Optional[str] = None,
runner: Optional[GitRunner] = None,
env: Optional[Mapping[str, str]] = None,
) -> Outcome:
"""Update the home, publishing dirty memory only with an explicit identity.

Silent on a home that is not configured for sync. Every state that rules
a fast-forward out (a failed fetch, a dirty tree, divergence) is its own
status and not a success; being ahead or having no upstream is reported
and is not an error.
Clean pulls keep their read-mostly behavior: fast-forward, or rebase local
commits and report ahead. An explicit agent permits dirty publication/delivery;
SessionStart supplies its runtime name through this same entry point.
"""
home = _home(home)
if not is_configured(home):
return Outcome("not_configured", True)
_require_repo(home, runner)
if _operation_in_progress(home, runner):
return Outcome("operation_in_progress", False, ("memory pull: a git operation is in progress; finish it first.",))
if changed_paths(home, runner):
if agent:
return _sync_changes(home, agent=agent, runner=runner, env=env)
return Outcome("dirty", False, ("memory pull: uncommitted changes present; pass --agent NAME to commit and sync.",))
state = git_state(home, runner=runner, fetch=True)
if not state.has_remote:
return Outcome("local_only", True, ("memory pull: local-only repository; no remote to pull from.",))
if state.fetch_failed:
return _fetch_failure(state, "memory pull")
if state.dirty:
return Outcome("dirty", False, ("memory pull: uncommitted changes present; not pulling.",))
if state.diverged:
return Outcome("diverged", False, ("memory pull: diverged from upstream; resolve manually.",))
if not state.has_upstream:
return Outcome("no_upstream", True, ("memory pull: no upstream branch configured; skipping.",))
if state.ahead:
return Outcome("ahead", True, (f"memory pull: {state.ahead} unpushed commit(s); nothing to pull.",))
if not state.behind:
return Outcome("up_to_date", True, ("memory pull: already synced.",))
result = _git(home, ["pull", "--ff-only", "--quiet"], runner, timeout=NETWORK_TIMEOUT_SECONDS)
if result.timed_out:
return Outcome("pull_timed_out", False, (f"memory pull: {result.output}",))
if not result.ok:
return Outcome("pull_failed", False, (f"memory pull: fast-forward failed: {result.output}",))
return Outcome("synced", True, (f"memory pull: synced {state.behind} commit(s).",))
updated = _update(home, state, runner)
if not updated.ok:
return updated
current = git_state(home, runner=runner, fetch=False) if state.behind else state
if current.ahead:
return Outcome("ahead", True, (*updated.lines, f"memory pull: {current.ahead} unpushed commit(s); use push to deliver."))
return updated


def push(
Expand All @@ -392,36 +395,93 @@ def push(
runner: Optional[GitRunner] = None,
env: Optional[Mapping[str, str]] = None,
) -> Outcome:
"""Commit the allowlisted memory changes and deliver them when a remote exists.

Silent on a home that is not configured for sync. A home that is behind
or diverged is refused before anything is committed. A commit that could
not be delivered -- no remote, an unreachable one, a rejected push -- is
reported as such, distinctly from delivery; a credential helper a sandbox
blocked is named as its own cause, not as a rejection.
"""
"""Commit allowlisted changes, update from upstream, and deliver to the remote."""
home = _home(home)
if not is_configured(home):
return Outcome("not_configured", True)
_require_repo(home, runner)
state = git_state(home, runner=runner, fetch=True)
if state.has_remote and not state.fetch_failed:
if state.diverged:
return Outcome("diverged", False, ("memory push: diverged from upstream; resolve manually before pushing.",))
if state.behind:
return Outcome(
"behind", False, (f"memory push: behind upstream by {state.behind} commit(s); pull before pushing.",)
)
if _operation_in_progress(home, runner):
return Outcome("operation_in_progress", False, ("memory push: a git operation is in progress; finish it first.",))
return _sync_changes(home, agent=agent, runner=runner, env=env)


def _operation_in_progress(home: Path, runner: Optional[GitRunner]) -> bool:
# Never abort or publish into an operation started by somebody else.
for name in ("rebase-merge", "rebase-apply", "MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD"):
result = _git(home, ["rev-parse", "--git-path", name], runner)
if not result.ok:
raise SyncError(f"cannot inspect git operation state: {result.output}")
path = result.stdout.strip()
if path and (home / path).exists():
return True
return False


def _sync_changes(
home: Path, *, agent: Optional[str], runner: Optional[GitRunner], env: Optional[Mapping[str, str]]
) -> Outcome:
published = _publish(home, agent=agent, runner=runner, env=env)
if not published.ok:
return published
# Commit before fetch: even a network failure leaves a recoverable local commit.
state = git_state(home, runner=runner, fetch=True)
lines = list(published.lines)
if not state.has_remote:
lines.append("memory push: no remote configured; the commit remains local.")
return Outcome("local_only", True, tuple(lines), published.committed, published.preserved)
if not state.fetch_failed and state.has_upstream and state.behind:
updated = _update(home, state, runner)
lines.extend(updated.lines)
if not updated.ok:
return Outcome(updated.status, False, tuple(lines), published.committed, published.preserved)
state = git_state(home, runner=runner, fetch=False)
return _deliver(home, published, state, lines, runner)


def _update(home: Path, state: GitState, runner: Optional[GitRunner]) -> Outcome:
if not state.behind:
return Outcome("up_to_date", True, ("memory pull: already synced.",))
remaining = changed_paths(home, runner)
if remaining:
return Outcome("dirty", False, (
"memory pull: cannot update with remaining uncommitted paths; left untouched: " + ", ".join(remaining),
))
if not state.ahead:
result = _git(home, ["pull", "--ff-only", "--quiet"], runner, timeout=NETWORK_TIMEOUT_SECONDS)
if result.timed_out:
return Outcome("pull_timed_out", False, (f"memory pull: {result.output}",))
if not result.ok:
return Outcome("pull_failed", False, (f"memory pull: fast-forward failed: {result.output}",))
return Outcome("synced", True, (f"memory pull: synced {state.behind} commit(s).",))
merges = _git(home, ["rev-list", "--merges", f"{state.upstream}..HEAD"], runner)
if not merges.ok or merges.stdout.strip():
return Outcome("diverged", False, (
"memory pull: local merge commits cannot be safely rebased automatically; resolve manually."
if merges.ok else f"memory pull: cannot inspect local history: {merges.output}",
))
# Rebase exactly the fetched upstream. Do not stash unrelated edits or reuse
# a recorded conflict resolution: a conflicting same-path add must surface.
result = _git(home, ["-c", "rerere.enabled=false", "-c", "rebase.updateRefs=false",
"rebase", "--no-autostash", state.upstream],
runner, timeout=NETWORK_TIMEOUT_SECONDS)
if result.ok:
return Outcome("synced", True, ("memory pull: rebased local commits onto upstream.",))
conflicts = _git(home, ["diff", "--name-only", "--diff-filter=U", "-z"], runner)
paths = [path for path in conflicts.stdout.split("\0") if path] if conflicts.ok else []
if _operation_in_progress(home, runner):
aborted = _git(home, ["rebase", "--abort"], runner, timeout=NETWORK_TIMEOUT_SECONDS)
if not aborted.ok or _operation_in_progress(home, runner):
return Outcome("rebase_abort_failed", False, (
f"memory pull: rebase failed and could not be aborted; manual recovery required. {aborted.output}",
))
detail = ", ".join(paths) if paths else result.output
status = "pull_timed_out" if result.timed_out else "diverged"
return Outcome(status, False, (
f"memory pull: {'rebase timed out' if result.timed_out else 'rebase failed'}; "
f"local commits retained. Conflicting paths or git error: {detail}",
))


def disable(home: Path) -> Outcome:
"""Remove the marker; the repository and its history stay in place."""
home = _home(home)
Expand Down Expand Up @@ -552,9 +612,9 @@ def _deliver(
failure = _fetch_failure(state, "memory push")
lines.append(f"{failure.lines[0]} The commit remains local.")
return Outcome(failure.status, False, tuple(lines), published.committed, published.preserved, receipt)
if not published.committed and state.has_upstream and not state.ahead:
if state.has_upstream and not state.ahead:
lines.append("memory push: remote already up to date.")
return Outcome("up_to_date", True, tuple(lines), (), published.preserved, receipt)
return Outcome("up_to_date", True, tuple(lines), published.committed, published.preserved, receipt)
result = _push_remote(home, state, runner)
if not result.ok:
if credential_helper_blocked(result.output):
Expand Down
Loading
Loading