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
18 changes: 18 additions & 0 deletions docs/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,24 @@ Memory types: `fact`, `preference`, `decision`, `task`, `project_overview`,
`agent_instruction`, `file_annotation`, `code_note`, `reference`. When omitted,
the type is inferred heuristically.

## History

An audit timeline of memory changes — what was created, updated, deleted, or
crawled, when, and by which source (`cli`, `mcp`, or `crawler`). Metadata only:
no old content is retained.

```sh
# Recent changes (newest first)
memd history

# Filter by action, type, scope, or time
memd history --action delete --type fact --since 7d --limit 50
```

Each crawl pass logs a single rolled-up event (e.g. `12 indexed, 3 deleted`)
rather than one event per file. The log starts empty on first run and accrues
going forward — past mutations are not backfilled.

## Crawler

| Command | Description |
Expand Down
1 change: 1 addition & 0 deletions docs/mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ highlighting, and grouped counts happen server-side.
| `forget_memory` | `id` | Delete a memory. |
| `list_memories` | `type?`, `scope?`, `limit?`, `offset?`, `include_content?`, `crop_length?`, `facets?` | List recent memories — metadata-only by default. |
| `stats` | `group_by?` | Document count + server-side facet counts (default: type/scope/source). |
| `history` | `action?`, `type?`, `scope?`, `since?`, `limit?` | Audit timeline of memory changes (create/update/delete/crawl), newest first. Metadata only. |

## Protocol

Expand Down
115 changes: 115 additions & 0 deletions docs/superpowers/specs/2026-06-15-memory-history-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# memd memory history — design

**Date:** 2026-06-15
**Status:** Approved
**Author:** Quentin de Quelen (with Claude)

## Goal

Give users an auditable timeline of what changed in their memory store: which
memories were created, updated, or deleted, when, and by which source. Today,
once a memory is updated or forgotten its prior state is gone and there is no
record the event happened.

Scope is an **audit timeline** — metadata only. We do not retain old content,
diffs, or an undo/restore capability (explicitly out of scope).

## Storage — a `memory_events` index

A second Meilisearch index alongside `memories`, created idempotently at daemon
startup. It is a plain log: **no embedder**. One document per event.

| field | type | notes |
|---|---|---|
| `id` | string | UUIDv7 primary key (time-ordered) |
| `ts` | i64 | unix seconds — sortable |
| `action` | string | `create` \| `update` \| `delete` \| `crawl` — filterable |
| `memory_id` | string | affected memory id (empty for `crawl`) — filterable |
| `title` | string | snapshot of the memory title (readable after delete) — searchable |
| `type` | string | memory type snapshot — filterable |
| `scope` | string | scope snapshot — filterable |
| `source` | string | `cli` \| `mcp` \| `crawler` — filterable |
| `source_client` | string? | optional client id |
| `detail` | string? | for `crawl`: `"12 indexed, 3 updated, 2 deleted, 0 errors"` — searchable |

Index settings:

- `filterableAttributes = [action, type, scope, source, memory_id, ts]`
- `sortableAttributes = [ts]`
- `searchableAttributes = [title, detail, memory_id]`

No backfill: the log starts empty and accrues going forward — past mutations
cannot be reconstructed. The events index is included automatically in dumps and
engine migrations because those operate instance-wide.

## Recording — single chokepoint in `MemoryService`

`MeiliClient` becomes index-aware: add an `index: String` field (defaulting to
`"memories"`, preserving every existing call site) and a `for_index(uid)` helper
returning a clone pointed at another index. Events reuse the same upsert/search
plumbing.

`MemoryService` gains an `EventLog` (a client bound to `memory_events`):

- `save` logs a `create` **only on a real write** — the content-hash dedup
early-return logs nothing.
- `update` and `forget` take a new `Source` argument so the event records who
performed it. `forget` reads the doc's metadata **before** deleting, to
snapshot title/type/scope into the event.
- Recording is **best-effort and off the critical path**: fire-and-forget, no
Meilisearch task wait. A logging failure never blocks or fails a memory
mutation — mirroring the existing `bump_accessed` pattern. (Consequence: the
log is eventually consistent; an event may not be queryable for a few hundred
ms after the mutation.)
- The **crawler** stays event-free per file. `crawler::scan` emits a single
`crawl` summary event at the end, built from its existing `CrawlSummary`.

Access bumps (`bump_accessed`, the `read` last-accessed write) go straight to
the client, not through `save/update`, so they correctly produce no events.

Rejected alternative: logging at each call site (CLI/MCP/crawler). Three places
to keep in sync and easy to miss a path; the service is the single funnel every
mutation already passes through.

## Surface — CLI + MCP

### CLI

```
memd history [--action <create|update|delete|crawl>] [--type <t>] \
[--scope <s>] [--since <span>] [--limit <n>]
```

Newest first (`ts:desc`). Reuses `parse_since` (`30d`, `12h`, `45m`, unix secs).
One line per event:

```
2026-06-15T10:30:00Z update [fact] My title (mcp · scope: global)
2026-06-15T10:28:11Z crawl 12 indexed, 3 updated, 2 deleted, 0 errors (crawler)
```

### MCP

A `history` tool with the same filters (`action`, `type`, `scope`, `since`,
`limit`), returning lightweight JSON rows so an agent can ask "what changed
recently."

### Status (optional)

A `history: N events` line in `memd status`.

## Testing

Unit tests for the pure pieces (no live Meilisearch), matching the existing
unit-test style:

- event construction from each mutation kind (create/update/delete/crawl)
- the history filter-expression builder
- the CLI line renderer
- `Source` round-trips into the event record

## Out of scope

- Before/after content diffs
- Undo / restore / revert
- Per-file crawler events (summary only)
45 changes: 44 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ pub async fn status() -> Result<()> {
.unwrap_or(0);
println!(" memories: {count}");
}
if let Some(events) = svc.events().count().await {
println!(" events: {events}");
}
}

let mcp_up = daemon_healthy(&cfg).await;
Expand Down Expand Up @@ -290,14 +293,54 @@ pub async fn search(
pub async fn forget(id: String) -> Result<()> {
let cfg = Config::load_or_init()?;
let svc = require_daemon(&cfg).await?;
if svc.forget(&id).await? {
if svc.forget(&id, Source::Cli).await? {
println!("Forgot memory {id}");
} else {
println!("No memory with id {id}");
}
Ok(())
}

/// Show the history of memory changes (most recent first).
pub async fn history(
action: Option<String>,
ty: Option<String>,
scope: Option<String>,
since: Option<String>,
limit: usize,
) -> Result<()> {
let cfg = Config::load_or_init()?;
let svc = require_daemon(&cfg).await?;

let parsed_action = match &action {
Some(s) => Some(crate::history::EventAction::parse(s).ok_or_else(|| {
anyhow::anyhow!("unknown action: {s} (use create/update/delete/crawl)")
})?),
None => None,
};
let since_ts = match since {
Some(s) => Some(parse_since(&s)?),
None => None,
};

let query = crate::history::EventQuery {
action: parsed_action,
r#type: ty,
scope,
since: since_ts,
limit,
};
let events = svc.history(&query).await?;
if events.is_empty() {
println!("No history yet.");
return Ok(());
}
for ev in &events {
println!("{}", crate::history::render_event(ev));
}
Ok(())
}

/// Run a one-off crawl.
pub async fn crawl_run() -> Result<()> {
let cfg = Config::load_or_init()?;
Expand Down
15 changes: 14 additions & 1 deletion src/crawler/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,19 @@ pub async fn scan(cfg: &Config, svc: &MemoryService) -> Result<CrawlSummary> {
summary.deleted = reconcile_deletions(svc, &seen).await.unwrap_or(0);
summary.finished_at = crate::memory::model::now_secs();
save_summary(&summary)?;

// Record one rolled-up history event per crawl — but only when something
// actually changed, so periodic reconciles that find nothing don't flood
// the audit log.
if summary.indexed + summary.deleted + summary.errors > 0 {
let detail = format!(
"{} indexed, {} skipped, {} deleted, {} errors",
summary.indexed, summary.skipped, summary.deleted, summary.errors
);
svc.events()
.record(crate::history::MemoryEvent::crawl(detail))
.await;
}
Ok(summary)
}

Expand Down Expand Up @@ -139,7 +152,7 @@ pub async fn index_one(cfg: &Config, svc: &MemoryService, path: &Path) -> Result
/// Remove the document for a deleted/removed file path.
pub async fn remove_one(svc: &MemoryService, path: &Path) -> Result<bool> {
let id = path_id(&path.to_string_lossy());
svc.forget(&id).await
svc.forget(&id, crate::memory::Source::Crawler).await
}

/// Watch configured roots and keep the index in sync. Runs until cancelled.
Expand Down
6 changes: 5 additions & 1 deletion src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ pub async fn serve() -> Result<()> {
.ensure_index(&cfg.embedder.source, &cfg.embedder.model)
.await
.context("ensuring memories index")?;
tracing::info!("Meilisearch ready; index configured");
svc.events()
.ensure()
.await
.context("ensuring memory_events index")?;
tracing::info!("Meilisearch ready; indexes configured");

// 3. Crawler + watcher in the background.
let crawl_cfg = cfg.clone();
Expand Down
Loading
Loading