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: 3 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:
test "${RELEASE_TAG}" = "v${PACKAGE_VERSION}"

- name: Upload release artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: python-package-distributions
path: dist/
Expand All @@ -57,7 +57,7 @@ jobs:

steps:
- name: Download release artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: python-package-distributions
path: dist/
Expand All @@ -78,7 +78,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Download release artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: python-package-distributions
path: dist/
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ 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.1 - 2026-09-12

### Added

- `event write` publishes one org-memory event under `org-memory/events/`, never replacing a published record ([event](docs/commands.md#event-write)).
- A `v*` tag attaches the built sdist and wheel to the GitHub Release ([releases](https://github.com/kiloloop/agent-memory/releases)).

### Fixed

- A nested `.gitignore` or `.oacp-memory-repo` no longer blocks `pull`; the allowlist anchors both names to the home root ([sync](docs/commands.md#sync)).
- `enable` retires superseded lines when it updates a home's `.gitignore`, and the receipt names them ([sync](docs/commands.md#sync)).
- `doctor` scopes its working-tree row to the allowlist: memory changes read DIRTY, and changes elsewhere are named as outside it ([doctor](docs/commands.md#doctor)).
- `push` and `doctor` keep both paths of a rename git detects in the working tree; the source path was previously misread ([sync](docs/commands.md#sync)).
- `push` names a credential helper a sandbox blocked as its own failure, with the remedy, instead of reporting it as a rejected push ([sync](docs/commands.md#sync)).

## 0.1.0 - 2026-09-06

### Added
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ similarity queries, synthesis, or indexing. Recall reads a fixed file set.
| [`capture`][capture-and-recall] | Record a decision. |
| [`recall`][capture-and-recall] | Read bounded context. |
| [`debrief write`][debrief-write] | Publish a session summary. |
| [`event write`][event-write] | Publish an org-memory event. |

[status]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#status
[doctor]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#doctor
Expand All @@ -157,6 +158,7 @@ similarity queries, synthesis, or indexing. Recall reads a fixed file set.
[startup]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#startup
[capture-and-recall]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#capture-and-recall
[debrief-write]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#debrief-write
[event-write]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#event-write

## Examples

Expand Down
43 changes: 39 additions & 4 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ the tree is dirty or diverged (ahead and behind are reported, not failed).
layout (presence, canonical paths, staging leftovers, symlinks; it never opens
a record, and a traversal it cannot finish is an error row, never a pass) and
the sync repository (marker, allowlist, tracked and untracked memory files,
tree, upstream, remote, last-commit age, per-instance state, overlay ignores).
the tree with memory changes told apart from changes outside the allowlist,
upstream, remote, last-commit age, per-instance state, overlay ignores).
It reads no memory content, repairs nothing, exits 1 only on an error row,
and points at `memory-lint` when that is installed; `--json` emits the same
rows as data.
Expand All @@ -41,12 +42,16 @@ 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
the head of its `.gitignore` as a managed block (existing lines are kept, and
the write comes with a before/after receipt), drops the sync marker, and makes
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. `pull` fast-forwards only when
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,
Expand Down Expand Up @@ -158,3 +163,33 @@ record reports as a collision. That narrower contract rests on the store
directory not being writable by other users, its default mode, so the swap
needs the owner's own uid; it is a platform limitation. Either way the writer restages and
retries, and a swap that persists is reported with the canonical path absent.

## Event write

`event write` publishes one org-memory event into the home's events store,
at `org-memory/events/<YYYYMMDD>-<HHMMSS>-<slug>.md`: the mechanical writer
the layout spec names, ported from the kernel's `write-event` script and
byte-identical to it for the same inputs. The record is a frontmatter block
of plain scalars (`created_at_utc`, `date`, `agent`, `project`, `type`, then
`source_ref`, `related` and `supersedes` when given), a blank line, the body
with its trailing newlines dropped, and one closing newline. Because the
scalars are plain, a value a YAML reader would not hand back verbatim -- a
control character, a leading indicator, `: ` or ` #` inside, or a string it
re-types such as `true`, `12` or `2026-03-21` -- is refused (exit 1) rather
than written. The body comes from `--body`, from `--body-file <path>` or
`--body-file -` (stdin), or from piped stdin when neither is given. A body
file is read as text, as the script reads it, so its CRLF and CR line endings
become LF and a Windows-authored file lands the same record as its LF twin;
stdin and `--body` are taken as given. `--type` is one of `decision`,
`event`, `rule`; the slug is lowercase alphanumerics and hyphens,
alphanumeric at both ends, no dots. Publication is the writer
contract of `debrief write`, through the same `agent_memory.publication`
module: staged in a private file, verified through the descriptor that wrote
it by a check that parses the frontmatter back, published with an atomic
no-replace link, and read back. An identical record already at the name is
idempotent (exit 0, the file untouched), a differing one is a collision (exit
2, a published record is never replaced), and a failure before the link
leaves `events/` with no partial record and no staging debris. `--dry-run`
composes and prints the record and touches nothing; `--json` reports the
path, status, stamp and identity. The verb appends one event and reads
nothing; synthesis stays with the caller.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Changelog = "https://github.com/kiloloop/agent-memory/blob/main/CHANGELOG.md"
dev = [
"build>=1.2,<2",
"pytest>=8,<10",
"ruff>=0.11,<1",
"ruff>=0.16,<0.17",
"twine>=7,<8",
]

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.0"
__version__ = "0.1.1"

__all__ = ["HomeError", "HomeResolution", "__version__", "resolve_home"]
113 changes: 112 additions & 1 deletion src/agent_memory/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from pathlib import Path
from typing import Callable, List, Optional, Sequence, Tuple

from . import __version__, archive, debrief, doctor, org, setup, startup, status, sync, workflow
from . import __version__, archive, debrief, doctor, events, org, setup, startup, status, sync, workflow
from .home import BINDING_FILE, HomeError, HomeResolution, find_project, resolve_home

EXIT_OK = 0
Expand Down Expand Up @@ -189,6 +189,38 @@ def add(name: str, handler: Callable[[argparse.Namespace], int], help_text: str,
write.add_argument("--oacp-dir", dest="home", metavar="PATH", help=argparse.SUPPRESS)
write.add_argument("--dry-run", action="store_true", help="validate and compose the record, print it, and write nothing")
write.add_argument("--json", dest="json_output", action="store_true", help="emit a machine-readable result")
event_ = commands.add_parser("event", help="Org-memory event commands.", description="Org-memory event commands.")
event_commands = event_.add_subparsers(dest="event_command", metavar="<command>")
event_help = (
"Publish one org-memory event into the home's events store, failure-atomically: the canonical path only ever "
"holds a complete, verified record, byte-identical to the kernel's write-event script for the same inputs. "
"Exit 0 published (or an identical record was already there), 1 on a validation error, 2 on a publication "
"failure."
)
event_write = event_commands.add_parser("write", parents=[common], help=event_help, description=event_help)
event_write.set_defaults(handler=_event_write)
event_write.add_argument("--agent", required=True, help="agent creating the event")
event_write.add_argument("--project", required=True, help="originating project")
event_write.add_argument(
"--type", dest="event_type", required=True, choices=sorted(events.ALLOWED_TYPES), help="event type"
)
event_write.add_argument(
"--slug", required=True, help="short slug for the filename: lowercase alphanumerics and hyphens, no dots"
)
event_body = event_write.add_mutually_exclusive_group()
event_body.add_argument("--body", help="event body, inline")
event_body.add_argument("--body-file", help="path to the event body in Markdown, or '-' to read stdin")
event_write.add_argument("--source-ref", help="provenance id, e.g. the debrief stem the event was folded from")
event_write.add_argument(
"--related", help="cross-references: comma-separated or a JSON array (e.g. 'PR #43,issue #10')"
)
event_write.add_argument("--supersedes", help="path of the event this entry overrides")
# The kernel script's home flag; accepted, unadvertised, through v0.1.x.
event_write.add_argument("--oacp-dir", dest="home", metavar="PATH", help=argparse.SUPPRESS)
event_write.add_argument(
"--dry-run", action="store_true", help="validate and compose the record, print it, and write nothing"
)
event_write.add_argument("--json", dest="json_output", action="store_true", help="emit a machine-readable result")
return parser


Expand Down Expand Up @@ -414,6 +446,85 @@ def _debrief_write(args: argparse.Namespace) -> int:
return EXIT_OK


def _event_write(args: argparse.Namespace) -> int:
home = resolve_home(args.home).path
try:
body = _event_body(args)
except OSError as exc:
print(f"ERROR: cannot read body: {exc}", file=sys.stderr)
return EXIT_FAILED
except events.WriterError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return exc.code

try:
result = events.write_event(
home=home,
agent=args.agent,
project=args.project,
event_type=args.event_type,
slug=args.slug,
body=body,
source_ref=args.source_ref,
related=events.normalize_related(args.related) if args.related else None,
supersedes=args.supersedes,
dry_run=args.dry_run,
)
except events.WriterError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return exc.code
except OSError as exc:
print(f"ERROR: publication failed: {exc}", file=sys.stderr)
return 2

if args.json_output:
print(
json.dumps(
{
"path": str(result.path),
"status": result.status,
"created_at_utc": result.created_at_utc,
"date": result.created_at_utc[:10],
"agent": args.agent,
"project": args.project,
"type": args.event_type,
},
indent=2,
)
)
else:
print(f"{result.status}: {result.path}")
print(f"created_at_utc: {result.created_at_utc}")

if args.dry_run:
print("--- record preview (nothing was written) ---", file=sys.stderr)
sys.stderr.flush()
sys.stderr.buffer.write(result.record)
sys.stderr.buffer.flush()
return EXIT_OK


def _event_body(args: argparse.Namespace) -> bytes:
"""The body bytes: ``--body-file`` (a path, or ``-`` for stdin), ``--body``, else piped stdin.

Trailing newlines are dropped, as the kernel script drops them; the record
closes the body with exactly one. A body file is read as text, so its
CRLF and CR line endings become LF (:func:`events.body_from_file`); stdin
and the inline body are taken as given, both as the script does.
"""
if args.body_file is not None and args.body_file != "-":
return events.body_from_file(Path(args.body_file).expanduser().read_bytes())
if args.body_file is not None:
raw = sys.stdin.buffer.read()
elif args.body is not None:
raw = args.body.encode("utf-8")
elif not sys.stdin.isatty():
raw = sys.stdin.buffer.read()
else:
raise events.WriterError("no body provided: use --body, --body-file <path|->, or pipe the body to stdin", 1)
return raw.rstrip(b"\n")


def _report(outcome: sync.Outcome) -> int:
if outcome.lines:
print("\n".join(outcome.lines), file=sys.stdout if outcome.ok else sys.stderr)
Expand Down
44 changes: 35 additions & 9 deletions src/agent_memory/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -444,15 +444,7 @@ def check_memory_sync(
state = None

if state is not None:
if state.dirty:
cat.add(
"working-tree",
Severity.warn,
"working tree — DIRTY memory changes present",
"Run `agent-memory push` or resolve changes manually",
)
else:
cat.add("working-tree", Severity.ok, "working tree — clean")
_add_working_tree(cat, home, runner, state)

text = f"sync state — {sync_state_text(state)}"
if not state.has_remote:
Expand Down Expand Up @@ -534,6 +526,40 @@ def check_memory_sync(
return cat


def _add_working_tree(cat: Category, home: Path, runner: Optional[GitRunner], state: GitState) -> None:
"""The tree row, scoped like the counts above it.

Memory changes inside the allowlist are what ``push`` would publish and
read DIRTY. Changes anywhere else are named as outside the allowlist: not
memory content, but ``pull`` still refuses a tree that carries them.
"""
if not state.dirty:
cat.add("working-tree", Severity.ok, "working tree — clean")
return
try:
changed = sync.changed_paths(home, runner)
except sync.SyncError as exc:
cat.add("working-tree", Severity.warn, f"working tree — DIRTY; the change readout failed: {exc}")
return
memory = [path for path in changed if layout.is_allowed_memory_path(path)]
elsewhere = [path for path in changed if not layout.is_allowed_memory_path(path)]
if memory:
text = f"working tree — DIRTY {len(memory)} memory change(s): {_summarize(memory)}"
if elsewhere:
text += f"; {len(elsewhere)} change(s) outside the memory allowlist"
cat.add("working-tree", Severity.warn, text, "Run `agent-memory push` or resolve changes manually")
elif elsewhere:
cat.add(
"working-tree",
Severity.warn,
f"working tree — {len(elsewhere)} change(s) outside the memory allowlist: {_summarize(elsewhere)}; "
"no memory change is pending, but memory pull refuses a dirty tree",
"Commit, stash or ignore them outside the memory tiers",
)
else:
cat.add("working-tree", Severity.ok, "working tree — clean")


def enclosing_repository(home: Path, runner: Optional[GitRunner] = None) -> Optional[Path]:
"""The worktree root when ``home`` sits inside a repository that is not its own; ``None`` when it is the root."""
root = sync.worktree_root(home, runner)
Expand Down
Loading