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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
MNEMON_DATA_DIR=~/.mnemon
MNEMON_STORE=default

# Retention. Set MNEMON_MAX_INSIGHTS=0 to disable automatic pruning.
MNEMON_MAX_INSIGHTS=1000
MNEMON_AUTO_PRUNE_MIN_AGE=24h

# Optional embeddings. The defaults below use Ollama.
MNEMON_EMBED_ENDPOINT=http://localhost:11434
MNEMON_EMBED_MODEL=nomic-embed-text
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,16 @@ Mnemon architecture.
| `MNEMON_DATA_DIR` | `~/.mnemon` | Base data directory |
| `MNEMON_STORE` | *(active file or `default`)* | Named memory store for data isolation |

**Retention**:

| Environment Variable | Default | Description |
|---|---|---|
| `MNEMON_MAX_INSIGHTS` | `1000` | Active-insight ceiling; `0` disables automatic pruning |
| `MNEMON_AUTO_PRUNE_MIN_AGE` | `24h` | Grace period before an insight can be auto-pruned; accepts `24h`, `7d`, or `0` |

Each automatic deletion is soft, appears in the oplog as a `prune` operation, and is
reported by ID in the triggering command's `auto_pruned_ids` field.

**Embedding** (only relevant if using embeddings):

| Environment Variable | Default | Description |
Expand Down
19 changes: 10 additions & 9 deletions cmd/memory/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ exports are documented in docs/IMPORT.md.`,

edgesInserted := 0
temporalEdgesRepaired := 0
pruned := 0
prunedIDs := []string{}
if err := db.InTransaction(func() error {
// Insert explicit edges for successfully imported insights.
for _, de := range draft.Edges {
Expand Down Expand Up @@ -277,21 +277,22 @@ exports are documented in docs/IMPORT.md.`,
}

var pruneErr error
pruned, pruneErr = db.AutoPrune(store.MaxInsightsLimit(), nil)
prunedIDs, pruneErr = db.AutoPruneWithResult(store.MaxInsightsLimit(), nil, "")
return pruneErr
}); err != nil {
return fmt.Errorf("finalize import graph: %w", err)
}

_ = temporalEdgesRepaired // computed internally; not surfaced in default output
summary := map[string]interface{}{
"imported": countAction(results, "added"),
"updated": countAction(results, "updated"),
"skipped": countAction(results, "skipped"),
"errors": countErrors(results),
"edges_inserted": edgesInserted,
"auto_pruned": pruned,
"results": results,
"imported": countAction(results, "added"),
"updated": countAction(results, "updated"),
"skipped": countAction(results, "skipped"),
"errors": countErrors(results),
"edges_inserted": edgesInserted,
"auto_pruned": len(prunedIDs),
"auto_pruned_ids": prunedIDs,
"results": results,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
Expand Down
74 changes: 74 additions & 0 deletions cmd/memory/import_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package memory

import (
"encoding/json"
"io"
"os"
"path/filepath"
Expand All @@ -11,6 +12,79 @@ import (
"github.com/mnemon-dev/mnemon/internal/memory/store"
)

func TestImportGraceProtectsNewbornBatchAndReportsIDs(t *testing.T) {
t.Setenv("MNEMON_EMBED_ENDPOINT", "http://127.0.0.1:1")
t.Setenv("MNEMON_MAX_INSIGHTS", "1")
t.Setenv("MNEMON_AUTO_PRUNE_MIN_AGE", "")

oldDataDir, oldStoreName, oldReadOnly := dataDir, storeName, readOnly
oldImportNoDiff, oldImportDryRun := importNoDiff, importDryRun
t.Cleanup(func() {
dataDir, storeName, readOnly = oldDataDir, oldStoreName, oldReadOnly
importNoDiff, importDryRun = oldImportNoDiff, oldImportDryRun
})
dataDir = t.TempDir()
storeName = ""
readOnly = false
importNoDiff = true
importDryRun = false

draftPath := filepath.Join(t.TempDir(), "memory_draft.json")
draft := `{
"schema_version": "1",
"insights": [
{"content": "first newborn memory", "importance": 1, "created_at": "2024-01-01T00:00:00Z"},
{"content": "second newborn memory", "importance": 1, "created_at": "2024-01-02T00:00:00Z"}
]
}`
if err := os.WriteFile(draftPath, []byte(draft), 0o644); err != nil {
t.Fatalf("write draft: %v", err)
}

var runErr error
out := captureStdout(t, func() {
runErr = importCmd.RunE(importCmd, []string{draftPath})
})
if runErr != nil {
t.Fatalf("import: %v", runErr)
}
var summary struct {
AutoPruned int `json:"auto_pruned"`
AutoPrunedIDs []string `json:"auto_pruned_ids"`
}
if err := json.Unmarshal([]byte(out), &summary); err != nil {
t.Fatalf("decode import output: %v\n%s", err, out)
}
if summary.AutoPruned != 0 || summary.AutoPrunedIDs == nil || len(summary.AutoPrunedIDs) != 0 {
t.Fatalf("newborn batch was pruned: count %d ids %v", summary.AutoPruned, summary.AutoPrunedIDs)
}

db, err := store.Open(store.StoreDir(dataDir, store.DefaultStoreName))
if err != nil {
t.Fatalf("open imported store: %v", err)
}
defer db.Close()
active, err := db.GetAllActiveInsights()
if err != nil {
t.Fatalf("get active insights: %v", err)
}
if len(active) != 2 {
t.Fatalf("active insights = %d, want 2 newborn memories", len(active))
}
var storedAt string
if err := db.Conn().QueryRow(
`SELECT stored_at FROM insights WHERE content = 'first newborn memory'`).Scan(&storedAt); err != nil {
t.Fatalf("read stored_at: %v", err)
}
physicalTime, err := time.Parse(time.RFC3339, storedAt)
if err != nil {
t.Fatalf("parse stored_at %q: %v", storedAt, err)
}
if physicalTime.Before(time.Now().UTC().Add(-time.Minute)) {
t.Fatalf("historical import inherited event time as stored_at: %s", physicalTime)
}
}

func TestImportRepairsBackdatedTemporalBackbone(t *testing.T) {
t.Setenv("MNEMON_EMBED_ENDPOINT", "http://127.0.0.1:1")

Expand Down
9 changes: 5 additions & 4 deletions cmd/memory/remember.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ var rememberCmd = &cobra.Command{
var (
edgeStats graph.EdgeStats
ei float64
pruned int
prunedIDs []string
embedded bool
)
err = db.InTransaction(func() error {
Expand Down Expand Up @@ -259,9 +259,9 @@ var rememberCmd = &cobra.Command{

// Auto-prune if over capacity (excludeID protects the just-created insight)
var pruneErr error
pruned, pruneErr = db.AutoPrune(store.MaxInsightsLimit(), []string{insight.ID})
prunedIDs, pruneErr = db.AutoPruneWithResult(store.MaxInsightsLimit(), []string{insight.ID}, insight.ID)
if pruneErr != nil {
fmt.Fprintf(os.Stderr, "warning: auto-prune: %v\n", pruneErr)
return fmt.Errorf("auto-prune: %w", pruneErr)
}

db.LogOp("remember", insight.ID, insight.Content)
Expand Down Expand Up @@ -303,7 +303,8 @@ var rememberCmd = &cobra.Command{
"causal_candidates": causalCandidates,
"embedded": embedded,
"effective_importance": ei,
"auto_pruned": pruned,
"auto_pruned": len(prunedIDs),
"auto_pruned_ids": prunedIDs,
}
if replacedID != "" {
output["replaced_id"] = replacedID
Expand Down
152 changes: 152 additions & 0 deletions cmd/memory/remember_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package memory

import (
"encoding/json"
"strings"
"testing"
"time"

"github.com/mnemon-dev/mnemon/internal/memory/model"
"github.com/mnemon-dev/mnemon/internal/memory/store"
)

func configureRememberTest(t *testing.T) {
t.Helper()
oldDataDir, oldStoreName, oldReadOnly := dataDir, storeName, readOnly
oldCategory, oldImportance := remCategory, remImportance
oldTags, oldSource, oldEntities := remTags, remSource, remEntities
oldEntityMode, oldNoDiff := remEntityMode, remNoDiff
t.Cleanup(func() {
dataDir, storeName, readOnly = oldDataDir, oldStoreName, oldReadOnly
remCategory, remImportance = oldCategory, oldImportance
remTags, remSource, remEntities = oldTags, oldSource, oldEntities
remEntityMode, remNoDiff = oldEntityMode, oldNoDiff
})

dataDir = t.TempDir()
storeName = ""
readOnly = false
remCategory = "fact"
remImportance = 3
remTags = ""
remSource = "user"
remEntities = ""
remEntityMode = "merge"
remNoDiff = true
t.Setenv("MNEMON_EMBED_ENDPOINT", "http://127.0.0.1:1")
t.Setenv("MNEMON_MAX_INSIGHTS", "1")
t.Setenv("MNEMON_AUTO_PRUNE_MIN_AGE", "0")
}

func seedOldPruneCandidate(t *testing.T, id string) {
t.Helper()
db, err := store.Open(store.StoreDir(dataDir, store.DefaultStoreName))
if err != nil {
t.Fatalf("open seed store: %v", err)
}
createdAt := time.Now().UTC().Add(-48 * time.Hour)
err = db.InsertInsight(&model.Insight{
ID: id,
Content: "old retention candidate",
Category: model.CategoryFact,
Importance: 1,
Tags: []string{},
Entities: []string{},
Source: "test",
CreatedAt: createdAt,
UpdatedAt: createdAt,
})
if err != nil {
db.Close()
t.Fatalf("insert seed: %v", err)
}
if err := db.Close(); err != nil {
t.Fatalf("close seed store: %v", err)
}
}

func TestRememberReportsAutoPrunedIDsAndTrigger(t *testing.T) {
configureRememberTest(t)
seedOldPruneCandidate(t, "old-prune-target")

var runErr error
out := captureStdout(t, func() {
runErr = rememberCmd.RunE(rememberCmd, []string{"new durable memory"})
})
if runErr != nil {
t.Fatalf("remember: %v", runErr)
}
var result struct {
ID string `json:"id"`
AutoPruned int `json:"auto_pruned"`
AutoPrunedIDs []string `json:"auto_pruned_ids"`
}
if err := json.Unmarshal([]byte(out), &result); err != nil {
t.Fatalf("decode remember output: %v\n%s", err, out)
}
if result.AutoPruned != 1 || len(result.AutoPrunedIDs) != 1 || result.AutoPrunedIDs[0] != "old-prune-target" {
t.Fatalf("auto-prune result = count %d ids %v", result.AutoPruned, result.AutoPrunedIDs)
}

db, err := store.Open(store.StoreDir(dataDir, store.DefaultStoreName))
if err != nil {
t.Fatalf("reopen store: %v", err)
}
defer db.Close()
entries, err := db.GetOplog(20)
if err != nil {
t.Fatalf("get oplog: %v", err)
}
found := false
for _, entry := range entries {
if entry.Operation == "prune" && entry.InsightID == "old-prune-target" {
found = true
if !strings.Contains(entry.Detail, "trigger="+result.ID) {
t.Errorf("auto-prune detail %q does not name trigger %s", entry.Detail, result.ID)
}
}
}
if !found {
t.Fatal("auto-pruned id is missing from the oplog")
}
}

func TestRememberRollsBackWhenAutoPruneAuditFails(t *testing.T) {
configureRememberTest(t)
seedOldPruneCandidate(t, "rollback-old")

db, err := store.Open(store.StoreDir(dataDir, store.DefaultStoreName))
if err != nil {
t.Fatalf("open store: %v", err)
}
if _, err := db.Conn().Exec(`
CREATE TRIGGER reject_remember_auto_prune_audit
BEFORE INSERT ON oplog
WHEN NEW.operation = 'prune'
BEGIN
SELECT RAISE(ABORT, 'audit unavailable');
END`); err != nil {
db.Close()
t.Fatalf("create rejecting trigger: %v", err)
}
if err := db.Close(); err != nil {
t.Fatalf("close store: %v", err)
}

if err := rememberCmd.RunE(rememberCmd, []string{"must roll back"}); err == nil {
t.Fatal("remember succeeded after required auto-prune audit failed")
}

db, err = store.Open(store.StoreDir(dataDir, store.DefaultStoreName))
if err != nil {
t.Fatalf("reopen store: %v", err)
}
defer db.Close()
active, err := db.GetAllActiveInsights()
if err != nil {
t.Fatalf("get active insights: %v", err)
}
if len(active) != 1 || active[0].ID != "rollback-old" {
t.Fatalf("active insights after rollback = %v, want only rollback-old", active)
}
}
2 changes: 2 additions & 0 deletions docs/IMPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ mnemon import --store project-alpha memory_draft.json
"errors": 0,
"edges_inserted": 3,
"auto_pruned": 0,
"auto_pruned_ids": [],
"results": [
{"index": 0, "id": "a1b2c3d4...", "content": "Chose Qdrant...", "action": "added"},
{"index": 1, "id": "e5f6a7b8...", "content": "The user prefers...", "action": "skipped"}
Expand All @@ -164,6 +165,7 @@ mnemon import --store project-alpha memory_draft.json
| `errors` | Number of failed writes. Import allows partial success; script callers should check this is `0` |
| `edges_inserted` | Number of explicit edges inserted |
| `auto_pruned` | Number of memories auto-pruned after capacity checks |
| `auto_pruned_ids` | Exact IDs soft-deleted by auto-prune; each has a matching `prune` oplog entry |

---

Expand Down
7 changes: 7 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,13 @@ Nodes are colored by category (decision, fact, insight, preference, context); ed
| `MNEMON_EMBED_API_KEY` | (none) | Bearer token for OpenAI-compatible servers |
| `MNEMON_EMBED_DIMENSIONS` | (native) | Embedding dimensions; set to truncate (e.g., `256` for Matryoshka models) |
| `MNEMON_MAX_INSIGHTS` | `1000` | Active-insight ceiling before auto-pruning starts; `0` disables auto-pruning |
| `MNEMON_AUTO_PRUNE_MIN_AGE` | `24h` | Minimum age before automatic pruning; accepts durations such as `24h`, integer days such as `7d`, or `0` to disable the grace period |

Auto-prune may temporarily leave the active count above the ceiling when every
eligible insight is still inside the grace period. Age is measured from local
store insertion, so newly imported historical memories are protected too. Each deletion is soft,
commits atomically with a `prune` oplog entry, and is returned by ID in
`auto_pruned_ids` alongside the existing `auto_pruned` count.

---

Expand Down
6 changes: 4 additions & 2 deletions docs/design/05-pipelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ BEGIN TRANSACTION
├── CreateCausalEdges → keywords + token overlap → auto causal edges
└── CreateSemanticEdges → cos >= 0.80 auto-link
④ RefreshEffectiveImportance → update EI decay values
⑤ AutoPrune → soft-delete lowest EI when total > 1000
⑤ AutoPrune → soft-delete lowest EI older than grace period
+ required per-ID oplog record
COMMIT
```

Expand All @@ -74,7 +75,8 @@ COMMIT
],
"embedded": true,
"effective_importance": 0.85,
"auto_pruned": 0
"auto_pruned": 0,
"auto_pruned_ids": []
}
```

Expand Down
Loading
Loading