Skip to content
Closed
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
8 changes: 6 additions & 2 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -745,7 +745,7 @@ For monorepos, detection now honors the **nearest** `.engram/config.json` at or

### Admin tools

`mem_delete` is ID-based and requires `id`; optional `hard_delete=true` permanently deletes the observation. It does not accept or auto-detect `project`.
`mem_delete` is ID-based and requires `id`; it always soft-deletes by setting `deleted_at` while preserving the original content and metadata. It does not accept or auto-detect `project`, and it does not expose permanent deletion.

`mem_merge_projects` requires `from` (comma-separated source project names) and `to` (canonical target project name). It does not accept or auto-detect `project`.

Expand Down Expand Up @@ -816,7 +816,7 @@ Suggest a stable `topic_key` from `type + title` (or content fallback). Uses fam

### mem_delete

Delete an observation by ID. Uses soft-delete by default (`deleted_at`); optional hard-delete for permanent removal.
Soft-delete an observation by ID. The MCP tool always sets `deleted_at` while preserving the original content and metadata; permanent removal is handled by local curation flows such as the TUI Recycle Bin, not by MCP.

### mem_save_prompt

Expand Down Expand Up @@ -1149,12 +1149,15 @@ Interactive Bubbletea-based terminal UI. Launch with `engram tui`.
| **Timeline** | Chronological context around an observation (before/after) |
| **Sessions** | Browse all sessions |
| **Session Detail** | Observations within a specific session |
| **Recycle Bin** | Deleted memories with restore and permanent-delete actions |

### Navigation

- `j/k` or arrow keys — Navigate lists
- `Enter` — Select / drill into detail
- `c` — Copy observation content to clipboard (OSC 52; works in search results, recent list, detail, and session views)
- `r` — Restore a deleted observation from deleted-memory views
- `d` or `delete` — Delete the selected item after confirmation; deleted observations can be permanently removed from deleted-memory views
- `t` — View timeline for selected observation
- `s` or `/` — Quick search from any screen
- `Esc` or `q` — Go back / quit
Expand All @@ -1164,6 +1167,7 @@ Interactive Bubbletea-based terminal UI. Launch with `engram tui`.

- **Catppuccin Mocha** color palette
- **`(active)` badge** — shown next to sessions and observations from active sessions, sorted to top
- **Deleted-memory rows** — Search memories, Recent observations, Browse sessions, and Recycle Bin can include deleted observations; they render red with `[deleted]`
- **Scroll indicators** — position in long lists (e.g. "showing 1-20 of 50")
- **2-line items** — each observation shows title + content preview

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ engram tui

**Navigation**: `j/k` vim keys, `Enter` to drill in, `c` to copy content to clipboard (OSC 52), `/` to search, `Esc` back. Catppuccin Mocha theme.

The TUI can show deleted memories in Search memories, Recent observations, Browse sessions, and Recycle Bin; deleted rows render red with `[deleted]`, can be restored with `r`, and can be permanently removed with `d` after confirmation.

## Git Sync

Share memories across machines. Uses compressed chunks — no merge conflicts, no huge files.
Expand Down
9 changes: 5 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Next session starts → Previous session context is injected automatically
|------|---------|
| `mem_save` | Save a structured observation (decision, bugfix, pattern, etc.); best-effort captures process-local current prompt context when available unless `capture_prompt=false` |
| `mem_update` | Update an existing observation by ID |
| `mem_delete` | Delete an observation (soft-delete by default, hard-delete optional) |
| `mem_delete` | Soft-delete an observation by setting `deleted_at` while preserving content |
| `mem_suggest_topic_key` | Suggest a stable `topic_key` for evolving topics before saving |
| `mem_search` | Full-text search across all memories |
| `mem_session_summary` | Save end-of-session summary |
Expand Down Expand Up @@ -95,8 +95,9 @@ Token-efficient memory retrieval — don't dump everything, drill in:
- Exact dedupe prevents repeated inserts in a rolling window (hash + project + scope + type + title)
- Duplicates update metadata (`duplicate_count`, `last_seen_at`, `updated_at`) instead of creating new rows
- Topic upserts increment `revision_count` so evolving decisions stay in one memory
- `mem_delete` uses soft-delete by default (`deleted_at`), with optional hard delete
- `mem_search`, `mem_context`, recent lists, and timeline ignore soft-deleted observations
- MCP `mem_delete` soft-deletes by setting `deleted_at` while preserving content and metadata
- `mem_search`, `mem_context`, recent lists, and timeline ignore soft-deleted observations by default; TUI browse paths can opt into deleted rows for restore workflows
- The TUI Recycle Bin can restore deleted observations or permanently purge them after confirmation

---

Expand Down Expand Up @@ -185,7 +186,7 @@ architecture/auth/model/detail ✗ three levels — flatten to two

### Lifecycle and pruning

Topic keys are not pruned automatically. An observation updated via upsert keeps a single row with the latest content and an incremented `revision_count`. Use `mem_delete` to remove an observation (soft-delete by default) when a topic is no longer relevant. Soft-deleted observations are excluded from search and context but their IDs remain in the store for audit purposes. Use `--hard` to remove them permanently.
Topic keys are not pruned automatically. An observation updated via upsert keeps a single row with the latest content and an incremented `revision_count`. Use `mem_delete` to soft-delete an observation when a topic is no longer relevant. Soft-deleted observations are excluded from default search and context, while IDs and content remain in the store for audit and restore workflows. The TUI Recycle Bin can restore them or permanently purge them after confirmation.

### Scope interaction

Expand Down
14 changes: 3 additions & 11 deletions internal/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ Examples:
if shouldRegister("mem_delete", allowlist) {
srv.AddTool(
mcp.NewTool("mem_delete",
mcp.WithDescription("Delete an observation by ID. Soft-delete by default; set hard_delete=true for permanent deletion."),
mcp.WithDescription("Soft-delete an observation by ID, moving it to the recycle bin while preserving content for TUI restoration."),
mcp.WithDeferLoading(true),
mcp.WithTitleAnnotation("Delete Memory"),
mcp.WithReadOnlyHintAnnotation(false),
Expand All @@ -437,9 +437,6 @@ Examples:
mcp.Required(),
mcp.Description("Observation ID to delete"),
),
mcp.WithBoolean("hard_delete",
mcp.Description("If true, permanently deletes the observation"),
),
),
queuedWriteHandler(writeQueue, handleDelete(s)),
)
Expand Down Expand Up @@ -1312,16 +1309,11 @@ func handleDelete(s *store.Store) server.ToolHandlerFunc {
return mcp.NewToolResultError("id is required"), nil
}

hardDelete := boolArg(req, "hard_delete", false)
if err := s.DeleteObservation(id, hardDelete); err != nil {
if err := s.DeleteObservation(id, false); err != nil {
return mcp.NewToolResultError("Failed to delete memory: " + err.Error()), nil
}

mode := "soft-deleted"
if hardDelete {
mode = "permanently deleted"
}
return mcp.NewToolResultText(fmt.Sprintf("Memory #%d %s", id, mode)), nil
return mcp.NewToolResultText(fmt.Sprintf("Memory #%d soft-deleted and moved to the recycle bin", id)), nil
}
}

Expand Down
84 changes: 81 additions & 3 deletions internal/mcp/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package mcp

import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -843,8 +844,69 @@ func TestHandleSearchAndCRUDHandlers(t *testing.T) {
if delRes.IsError {
t.Fatalf("unexpected delete error: %s", callResultText(t, delRes))
}
if !strings.Contains(callResultText(t, delRes), "permanently deleted") {
t.Fatalf("expected hard delete message")
deleteText := callResultText(t, delRes)
if !strings.Contains(deleteText, "soft-deleted") {
t.Fatalf("expected soft delete message, got %q", deleteText)
}
if strings.Contains(deleteText, "permanently deleted") {
t.Fatalf("MCP delete must not report permanent deletion, got %q", deleteText)
}
}

func TestHandleDeleteAlwaysSoftDeletesAndPreservesContent(t *testing.T) {
s := newMCPTestStore(t)
if err := s.CreateSession("s-delete", "engram", "/tmp/engram"); err != nil {
t.Fatalf("create session: %v", err)
}

obsID, err := s.AddObservation(store.AddObservationParams{
SessionID: "s-delete",
Type: "decision",
Title: "Preserve me",
Content: "Original content must remain available in the recycle bin",
Project: "engram",
Scope: "project",
})
if err != nil {
t.Fatalf("add observation: %v", err)
}

deleteHandler := handleDelete(s)
req := mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{
"id": float64(obsID),
"hard_delete": true,
}}}
res, err := deleteHandler(context.Background(), req)
if err != nil {
t.Fatalf("delete handler error: %v", err)
}
if res.IsError {
t.Fatalf("unexpected delete error: %s", callResultText(t, res))
}
deleteText := callResultText(t, res)
if !strings.Contains(deleteText, "soft-deleted") || !strings.Contains(deleteText, "recycle bin") {
t.Fatalf("expected recycle-bin soft delete message, got %q", deleteText)
}
if strings.Contains(deleteText, "permanently deleted") {
t.Fatalf("MCP delete must never report permanent deletion, got %q", deleteText)
}

if _, err := s.GetObservation(obsID); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("GetObservation after soft delete err = %v, want sql.ErrNoRows", err)
}

deleted, err := s.GetObservationIncludingDeleted(obsID)
if err != nil {
t.Fatalf("GetObservationIncludingDeleted: %v", err)
}
if deleted.DeletedAt == nil || *deleted.DeletedAt == "" {
t.Fatalf("expected deleted_at to be set, got %#v", deleted.DeletedAt)
}
if deleted.Title != "Preserve me" {
t.Fatalf("title after delete = %q, want %q", deleted.Title, "Preserve me")
}
if deleted.Content != "Original content must remain available in the recycle bin" {
t.Fatalf("content after delete = %q", deleted.Content)
}
}

Expand Down Expand Up @@ -2476,7 +2538,7 @@ func TestExplicitSessionIDBypassesDefault(t *testing.T) {
}
}

func TestDestructiveToolAnnotation(t *testing.T) {
func TestNewServerToolAnnotations(t *testing.T) {
s := newMCPTestStore(t)
srv := NewServer(s)
tools := srv.ListTools()
Expand All @@ -2492,6 +2554,22 @@ func TestDestructiveToolAnnotation(t *testing.T) {
if ann.ReadOnlyHint == nil || *ann.ReadOnlyHint {
t.Error("mem_delete should NOT be marked readOnly")
}
if !tool.Tool.DeferLoading {
t.Error("mem_delete should have DeferLoading=true")
}
props := tool.Tool.InputSchema.Properties
if _, ok := props["id"]; !ok {
t.Fatal("mem_delete schema must include id")
}
if _, ok := props["hard_delete"]; ok {
t.Fatal("mem_delete schema must not advertise hard_delete")
}
if strings.Contains(tool.Tool.Description, "hard_delete") || strings.Contains(tool.Tool.Description, "permanent") {
t.Fatalf("mem_delete description must not advertise hard delete, got %q", tool.Tool.Description)
}
if !strings.Contains(tool.Tool.Description, "recycle bin") {
t.Fatalf("mem_delete description should mention recycle bin semantics, got %q", tool.Tool.Description)
}
}

// ─── Phase 3: MCPConfig, Default Project, Normalization, Similar Warnings ────
Expand Down
Loading