Skip to content
Open
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ and recall all operate on whole files, and a file is either active or invalid wi
between. Frontmatter carries the stable name, a one-sentence abstract, the type and its schema
fields, status, timestamps, links, weight, and provenance; the body is free markdown.

Explicit links must name distinct active memories in the same store. `correct --link`
replaces the full list; `correct --clear-links` removes every link. MCP `memory_correct`
uses `links: [...]` and `links: []` for the same operations. Omitting links preserves
historical relationships during unrelated correction. See the
[operation boundary design](docs/design/management-operation-boundaries.md).

## Proof it works

Measured on LongMemEval-S with a bounded haystack, 120 episodes, `claude -p` (Haiku 4.5) as
Expand Down
12 changes: 12 additions & 0 deletions docs/TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Management follow-ups

- Decide explicit confirmation and host-level authorization for permanent GC, import and
cross-store changes; CLI labels alone do not identify a human.
- Decide preimage retention and recovery guarantees for split and in-place correction,
including stores outside Git and multi-file Manage failures.
- Decide whether direct link/unlink delta commands, relation audit history and per-operation
bounds are needed; current correct replaces the complete list.
- Correct explicit feedback persistence: Store.feedback currently returns an adjusted object
without writing the weight back to disk.
- Track stale Manage rewrites separately from correction serialization: Manage may prepare a
record before another writer changes it, then pass that old object to Store.write. See #16.
4 changes: 4 additions & 0 deletions docs/design/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Design

- [Management operation boundaries](management-operation-boundaries.md) defines the
correction and relationship mutation contract.
21 changes: 21 additions & 0 deletions docs/design/management-operation-boundaries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Management operation boundaries

Correction is a read-modify-write operation on a canonical memory file. Concurrent
corrections can silently discard one another when either reads before acquiring the store's
writer lock. A relationship mutation can also leave a memory pointing at a missing or
invalid target. CLI and MCP must expose the same operation rather than define separate rules.

The Store holds one writer lock from the correction read through validation, persistence,
and projection. Direct rewrites and corrections share one persistence path. Validation
failure leaves the canonical memory and its projections unchanged; after success, projections
can be rebuilt from canonical files.

An active source may link only to distinct, active targets in the same store. Omitted links
leave relationships untouched, including historical relationships whose targets later became
invalid. An explicit list replaces the complete set, validates every submitted target under
current rules, and may be empty to remove all relationships. CLI and MCP pass corrections to
the same Store boundary.

This change covers correction serialization, relationship replacement, and adapter parity.
It does not add RBAC, approvals, standalone unlink, archive management, feedback redesign,
retrieval changes, or a general rollback system.
65 changes: 65 additions & 0 deletions docs/plans/memory-management-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Agent memory management operation audit

Baseline: `34d12a2f8678d5561aba27bd8ff73c5ae4b6a258` (main).
This change isolates management boundaries from the separate memory archival lifecycle.
Paths beginning with `core/` refer to `packages/core/src/agent_memory/core/`.

## Operation matrix

| Operation | Existing entry / authority | This change and remaining risks |
|---|---|---|
| Read/Recall/Context/Trace | Existing CLI, MCP and executor reads | Unchanged |
| Create/update | CLI record, MCP memory_record, executor reconcile -> Store.record_many | New links validated; body replacement still requires supersedes; metadata preimages need Git |
| Patch | reconcile.OP_ALIASES maps patch to update | No separate patch API |
| Correct | CLI correct, MCP memory_correct -> Store.correct | Active source/successor and link validation under writer lock; replacement lacks guaranteed preimages |
| Link | record/correct; Manage._add_cooccurrence_links is existing deterministic T0 | MCP correct now exposes links; new targets must be distinct active memories in this store |
| Unlink | No standalone CLI/MCP/Manage verb | correct replaces full list; MCP links=[] and CLI --clear-links clear it. Target and Raw retained; previous set needs Git/caller knowledge |
| Supersede | record/correct, Manage proposals/exact duplicates | Invalid correct successor rejected; existing predecessor checks retained |
| Merge | Manage._review -> decide -> _merge, CLI decide | Existing proposal revalidation and per-kind sleep caps retained; multi-file partial failure and loss of distinctions remain possible |
| Split | Manage._split through existing proposals | Unchanged; rewrites original with first part without unconditional snapshot |
| Invalidate/delete | CLI delete, existing Manage proposals | Unchanged in this PR |
| Date/weight maintenance | Deterministic Manage routines | Unchanged |
| Feedback | CLI/MCP Store.feedback | Audit finding only: returned weight changes but is not persisted; fix deferred |
| Group merge/cluster | Manage -> Store.record | Existing authority; no scope ACL added |
| Redistill request | Manage -> Pending | Unchanged in this PR |
| Physical delete | CLI gc; absent from Manage/MCP | Unchanged; human-run label is not authentication |
| Inspect/rebuild/export | Existing CLI | Unchanged; legacy dangling links still reported by rebuild |
| Import/migrate | Existing CLI | Unchanged; owner authorization for overwrite is a recommendation |

## Implemented checks

Implicitly retained links are not revalidated. Explicit replacement validates every submitted
target and rejects duplicates, including previously stored targets that later became invalid.
Missing, self and invalid targets fail before mutation. Existing historical links can remain
during unrelated updates. Names resolve within one configured store;
Recall scope is only a search filter, not authorization. Store.write checks that the
source path belongs to this store. MCP rejects malformed arrays instead of clearing links.
Missing correction sources/successors retain explicit NotFoundError behavior.

Store.correct and Store.write share one locked persistence path. Correction reads after locking
and validates before appending provenance. This prevents stale correction updates
and evidence side effects; a whole Manage sleep remains nontransactional. Automatic
cooccurrence linking remains enabled. No link/unlink delta command is added.

Manage._review uses existing caps, proposal menu and decide; _open regenerates proposals
before decisions. Unknown/stale proposals cannot dictate arbitrary targets. Direct CLI
decide lacks the per-sleep cap. reasoning.parse accepts verdicts/text, not executable
operations. Executor reconcile handles do not apply to direct CLI/MCP record/correct.
DecisionLedger/reports record outcomes; failure may precede an entry, and Git recovery
requires a successful commit.

## Recommendations, not implemented permissions

Deployment owners should control permanent GC, bulk overwrite/import, cross-store changes
and direct filesystem/database access. No RBAC, actor authentication, approval service,
policy engine, restore command, guaranteed snapshots or host sandbox is implemented.
Shell access can bypass tool menus. Correct/split can still lose old wording without Git.
See [follow-ups](../TODO.md).

## Verification

Management boundary tests cover link replacement/clearing, all write paths rejecting
invalid endpoints, active correction/successor checks, historical links and retained
target/evidence. Adapter tests cover CLI/MCP parity, malformed arrays and unavailable
destructive tools. Existing storage/indexer/Manage/reasoning/reconcile tests cover normal
writes/reads and proposal controls. Validation commands/results are recorded in the PR.
13 changes: 11 additions & 2 deletions packages/cli/src/agent_memory/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,16 @@ def _parser() -> argparse.ArgumentParser:
corrector.add_argument("--body", default=None)
corrector.add_argument("--body-file", default=None)
corrector.add_argument("--supersede-with", default=None)
corrector.add_argument("--link", action="append", default=None)
correct_links = corrector.add_mutually_exclusive_group()
correct_links.add_argument(
"--link",
action="append",
default=None,
help="replace the complete link list; repeat for each retained target",
)
correct_links.add_argument(
"--clear-links", action="store_true", help="replace the link list with an empty list"
)
corrector.add_argument("--provenance", action="append", default=[])
corrector.set_defaults(handler=_correct)

Expand Down Expand Up @@ -303,7 +312,7 @@ def _correct(store: Store, args: argparse.Namespace) -> dict[str, object]:
abstract=args.abstract,
body=body,
supersede_with=args.supersede_with,
links=args.link,
links=[] if args.clear_links else args.link,
provenance=args.provenance,
)
return {
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/agent_memory/core/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,15 @@ def repair(sheet: str, refused: str) -> str:
such as `project` or `topic` name the subdirectory; pick an existing one, and pass
`--create-group` only when a new one is genuinely needed.

## Relationship maintenance

Use `mem record --link <target>` for links to existing active memories. To revise links,
`mem correct <name> --link <target>` replaces the complete list; repeat `--link` for each
retained target. MCP `memory_correct` accepts `links`, with `[]` clearing the list. Choose
another active memory in this store as each target. Use `mem correct <name> --clear-links`
to remove all links. Existing historical links may stay when links are omitted.
Use these commands for changes so validation and indexing run together.

## Write discipline

{discipline}
Expand Down
94 changes: 67 additions & 27 deletions packages/core/src/agent_memory/core/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ def _write_one(self, spec: dict[str, object]) -> MemoryRecord:
self._enforce_update_only(existing, candidate)
record_module.validate(candidate, self.config, schema)
record_module.canonicalise_dates(candidate)
self._validate_links(candidate, existing, replace_links=spec.get("links") is not None)
predecessor = self._predecessor(candidate, supersedes)

for excerpt in _as_sequence(spec.get("provenance")):
Expand Down Expand Up @@ -301,28 +302,36 @@ def correct(
valid_from: str | None = None,
provenance: list[str] | None = None,
) -> MemoryRecord:
current = self.find(name)
if current is None or current.path is None:
raise NotFoundError(f"no memory named {name}")
now = self.clock.timestamp()
if supersede_with:
successor = self.find(supersede_with)
if successor is None:
raise NotFoundError(f"no memory named {supersede_with}")
record_module.invalidate(current, successor.valid_from or now, supersede_with)
if abstract is not None:
current.abstract = abstract.strip()
if body is not None:
current.body = body
if links is not None:
current.links = list(links)
if valid_from is not None:
current.valid_from = valid_from
current.updated = now
with store_lock(self.layout):
for excerpt in provenance or []:
current.provenance.append(self._store_provenance(current.name, excerpt))
return self.write(current)
current = self.find(name)
if current is None or current.path is None:
raise NotFoundError(f"no memory named {name}")
if not current.is_active():
raise ValidationError(
[FieldError("status", "correction requires an active memory")]
)
now = self.clock.timestamp()
if supersede_with:
successor = self.find(supersede_with)
if successor is None:
raise NotFoundError(f"no memory named {supersede_with}")
if not successor.is_active():
raise ValidationError(
[FieldError("supersede_with", "successor must be active")]
)
record_module.invalidate(current, successor.valid_from or now, supersede_with)
if abstract is not None:
current.abstract = abstract.strip()
if body is not None:
current.body = body
if links is not None:
current.links = list(links)
if valid_from is not None:
current.valid_from = valid_from
current.updated = now
return self._write_locked(
current, replace_links=links is not None, provenance=provenance
)

def delete(self, name: str) -> MemoryRecord:
"""Marks the record invalid. The file stays; physical removal is a human command."""
Expand Down Expand Up @@ -350,15 +359,46 @@ def gc(self) -> list[str]:

def write(self, record: MemoryRecord) -> MemoryRecord:
"""Validate, persist, reproject. Agent writes and Manage rewrites share this path."""
if record.path is None:
raise NotFoundError(f"{record.name} has no location on disk")
record_module.validate(record, self.config, self.schemas.get(record.type))
record_module.canonicalise_dates(record)
with store_lock(self.layout):
record.path.write_text(record.to_text(), encoding="utf-8")
self._project()
return self._write_locked(record)

def _write_locked(
self,
record: MemoryRecord,
*,
replace_links: bool = False,
provenance: list[str] | None = None,
) -> MemoryRecord:
self._validate_write(record, replace_links=replace_links)
for excerpt in provenance or []:
record.provenance.append(self._store_provenance(record.name, excerpt))
assert record.path is not None
record.path.write_text(record.to_text(), encoding="utf-8")
self._project()
return record

def _validate_write(self, record: MemoryRecord, *, replace_links: bool = False) -> None:
if record.path is None or self.layout.type_of(record.path) != record.type:
raise ValidationError([FieldError("path", "memory must belong to this store")])
record_module.validate(record, self.config, self.schemas.get(record.type))
record_module.canonicalise_dates(record)
self._validate_links(record, self.find(record.name), replace_links=replace_links)

def _validate_links(
self, record: MemoryRecord, existing: MemoryRecord | None, *, replace_links: bool = False
) -> None:
if replace_links and len(record.links) != len(set(record.links)):
raise ValidationError([FieldError("links", "duplicate target")])
names = set(record.links) if replace_links else set(record.links) - set(
existing.links if existing else []
)
for name in sorted(names):
target = self.find(name)
if name == record.name or target is None or not target.is_active():
raise ValidationError(
[FieldError("links", f"{name} must name another active memory")]
)

def feedback(self, name: str, delta: float) -> MemoryRecord:
current = self.find(name)
if current is None or current.path is None:
Expand Down
11 changes: 11 additions & 0 deletions packages/mcp/src/agent_memory/mcp/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@
"abstract": {"type": "string"},
"body": {"type": "string"},
"supersede_with": {"type": "string"},
"links": {
"type": "array",
"items": {"type": "string"},
"description": "Replace all links; empty list removes all links",
},
},
"required": ["name"],
},
Expand Down Expand Up @@ -93,6 +98,11 @@ def dispatch(store: Store, tool: str, arguments: dict[str, object]) -> dict[str,


def _require(tool: str, arguments: dict[str, object]) -> None:
if "links" in arguments and (
not isinstance(arguments["links"], list)
or not all(isinstance(item, str) for item in arguments["links"])
):
raise ValidationError([FieldError("links", "must be an array of memory names")])
schema = SCHEMAS[tool]
required = schema.get("required")
missing = [
Expand Down Expand Up @@ -158,6 +168,7 @@ def _correct(store: Store, arguments: dict[str, object]) -> dict[str, object]:
abstract=_optional(arguments, "abstract"),
body=_optional(arguments, "body"),
supersede_with=_optional(arguments, "supersede_with"),
links=_string_list(arguments["links"]) if "links" in arguments else None,
)
return {
"name": corrected.name,
Expand Down
9 changes: 9 additions & 0 deletions skills/agent-memory/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ The store's `schemas/` directory lists the types and what each one is for. Group
such as `project` or `topic` name the subdirectory; pick an existing one, and pass
`--create-group` only when a new one is genuinely needed.

## Relationship maintenance

Use `mem record --link <target>` for links to existing active memories. To revise links,
`mem correct <name> --link <target>` replaces the complete list; repeat `--link` for each
retained target. MCP `memory_correct` accepts `links`, with `[]` clearing the list. Choose
another active memory in this store as each target. Use `mem correct <name> --clear-links`
to remove all links. Existing historical links may stay when links are omitted.
Use these commands for changes so validation and indexing run together.

## Write discipline

Recall first to see whether this atom already exists.
Expand Down
Loading
Loading