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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]

steps:
- name: Check out repository
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ 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.4 - 2026-10-26

### Fixed

- `pull` and `push` sync one at a time per home and never abort a rebase they did not start ([sync](docs/commands.md#sync)).
- `pull` with only unpushed commits no longer also reports "already synced" ([sync](docs/commands.md#sync)).
- `startup --pull` no longer publishes a debrief or event another agent wrote ([startup](docs/commands.md#startup)).
- `pull` and `push` update past untracked files the incoming commits do not touch, so SessionStart keeps pulling ([sync](docs/commands.md#sync)).
- `doctor` on Python 3.14 reports a debrief record it cannot inspect as unreadable, not as an irregular file ([doctor](docs/commands.md#doctor)).
- `enable` starts a new memory repository on `main` even where git's `init.defaultBranch` is `master` ([sync](docs/commands.md#sync)).

## 0.1.3 - 2026-09-29

### Fixed
Expand Down
16 changes: 13 additions & 3 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ credentials. `org init` is the org tier alone, for a home that already exists.

## Sync

`enable` makes the home a git repository of its own, puts the sync allowlist at
`enable` makes the home a git repository of its own on branch `main`, whatever
git's `init.defaultBranch` says (a home that is already a repository keeps its
branch), puts the sync allowlist at
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
Expand All @@ -84,15 +86,20 @@ one commit. With `--remote`, `enable` also pushes that initial commit.
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
them, except untracked files the incoming commits do not touch: those stay
in place through the update and are named. 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.
One `pull` or `push` syncs a home at a time: a second waits up to two minutes
for the first to finish, and neither aborts a rebase it did not start.

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.
It leaves out a debrief or event that names another agent as its author
([startup](#startup)).
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.
Expand Down Expand Up @@ -152,7 +159,10 @@ edited, never written through a symlink, and recorded in the receipt.
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 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
the pull left them. The pull leaves another agent's pending files for that
agent to publish: a debrief whose filename names a different agent, or an
event whose frontmatter `agent:` does. Files with no author (the curated
org files, the project tier) sync as before. `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
character budget (`--max-chars`, at least 1). The default output is what
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.3"
__version__ = "0.1.4"

__all__ = ["HomeError", "HomeResolution", "__version__", "resolve_home"]
16 changes: 9 additions & 7 deletions src/agent_memory/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ def _walk_error(exc: OSError) -> None:
for name in sorted(dirnames):
entry = current / name
try:
is_link = entry.is_symlink()
is_link = stat.S_ISLNK(os.lstat(entry).st_mode)
except OSError as exc:
walk_errors.append(f"{_relative(entry)}: {exc.__class__.__name__}")
continue
Expand All @@ -258,16 +258,18 @@ def _walk_error(exc: OSError) -> None:
staging.append(rel)
continue
# The namespace holds regular files reached without following links;
# a classification failure surfaces, never raises.
# a classification failure surfaces, never raises. lstat, not the
# pathlib predicates: since Python 3.14 those answer False on any
# OSError, so a record that could not be inspected would read as irregular.
try:
if file_path.is_symlink():
irregular.append(f"{rel} (symlink)")
continue
regular = file_path.is_file()
mode = os.lstat(file_path).st_mode
except OSError as exc:
walk_errors.append(f"{rel}: {exc.__class__.__name__}")
continue
if not regular:
if stat.S_ISLNK(mode):
irregular.append(f"{rel} (symlink)")
continue
if not stat.S_ISREG(mode):
irregular.append(f"{rel} (not a regular file)")
continue
total += 1
Expand Down
47 changes: 45 additions & 2 deletions src/agent_memory/startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@
``archive/`` are excluded), and the rendered text is cut at a character
budget with a notice, so a hook can never flood a session.

With ``--pull`` the session's runtime is the publishing identity, and a
path another agent wrote is not its to publish: a debrief names its agent in
its filename and an event in its frontmatter, so either one, when it names
someone else, is held for that agent's own session. Files that carry no
author (the curated org files, the project tier) publish as before.

Output shapes: ``--json`` is the manifest itself, ``schema_version`` first;
the default is what the runtime's hook expects on stdout: plain text for
claude, whose session start takes stdout as context, and the hook JSON
Expand All @@ -28,8 +34,9 @@
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple

from . import layout, sync
from . import debrief, events, layout, sync
from .git_runner import GitRunner, run_git
from .publication import WriterError

SCHEMA_VERSION = 1
RUNTIME_CLAUDE = "claude"
Expand All @@ -46,6 +53,8 @@
RESULT_DEGRADED = "degraded"
PULL_NOT_REQUESTED = "not_requested"
PULL_ERROR = "error"
#: The most of an event the author check reads; the frontmatter sits at its head.
_EVENT_HEAD_BYTES = 64 * 1024


def build_manifest(
Expand Down Expand Up @@ -161,7 +170,7 @@ def _sync(
pull_info: Dict[str, Any] = {"requested": pull, "status": PULL_NOT_REQUESTED, "ok": True, "lines": []}
if pull:
try:
outcome = sync.pull(home, agent=runtime, runner=runner)
outcome = sync.pull(home, agent=runtime, runner=runner, hold=lambda path: _held(home, path, runtime))
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 All @@ -176,6 +185,40 @@ def _sync(
return info, pull_info


def _held(home: Path, relative: str, runtime: str) -> bool:
"""Whether ``relative`` names an author other than ``runtime``; no author named means not held."""
author = author_of(home, relative)
return author is not None and author != runtime


def author_of(home: Path, relative: str) -> Optional[str]:
"""The agent a memory path names as its author, or ``None`` when it names none.

A debrief, ``org-memory/debriefs/<project>/<YYYY>/<MM>/<YYYYMMDD>-<agent>-<session>.md``,
names it in the filename: the session id has no dash, so the last dash
splits the agent from it whatever dashes the agent's own name carries. An
event, ``org-memory/events/<name>.md``, names it in the frontmatter
``agent:`` field. Anything else, and anything that does not parse, names
no one.
"""
parts = relative.split("/")
if parts[:2] == [layout.ORG.pattern, "debriefs"] and len(parts) == 6 and parts[5].endswith(".md"):
date, _, rest = parts[5][: -len(".md")].partition("-")
agent, _, session = rest.rpartition("-")
if len(date) == 8 and date.isdigit() and debrief.SESSION_RE.match(session) and debrief.AGENT_RE.match(agent):
return agent
return None
if parts[:2] == [layout.ORG.pattern, events.EVENTS_DIR] and len(parts) == 3 and parts[2].endswith(".md"):
try:
with open(home / relative, "rb") as handle:
frontmatter, _ = debrief.split_record(handle.read(_EVENT_HEAD_BYTES))
except (OSError, ValueError, WriterError):
return None
agent = frontmatter.get("agent", "")
return agent if debrief.AGENT_RE.match(agent) else None
return None


# --- rendering --------------------------------------------------------------


Expand Down
Loading
Loading