From f30d72bd254c0be34c96a09f233b1565c7b44564 Mon Sep 17 00:00:00 2001 From: Tars Date: Wed, 19 Aug 2026 00:44:15 -0700 Subject: [PATCH] feat(memory): make the auto-prune insight ceiling configurable MaxInsights has been a hardcoded package constant at 1000, so a store that outgrows that figure has no way to say so. It prunes on every remember and import for the rest of its life, and the only lever available is deleting the data it is being asked to keep. MNEMON_MAX_INSIGHTS sets the active-insight ceiling; 0 or below switches auto-pruning off entirely by resolving to a ceiling no store reaches, which keeps the capacity check in AutoPrune unchanged. An unparseable value warns on stderr and falls back to the 1000 default rather than shrinking the store. mnemon gc reports the configured ceiling, so raising the cap is visible where an operator looks for it. Verified with make test, including new store tests for resolution and the disabled ceiling, and a gc command test asserting the reported value. --- cmd/memory/gc.go | 9 ++- cmd/memory/gc_test.go | 58 ++++++++++++++++++++ cmd/memory/import.go | 2 +- cmd/memory/remember.go | 2 +- docs/USAGE.md | 1 + docs/zh/USAGE.md | 1 + internal/memory/store/node.go | 31 +++++++++++ internal/memory/store/store_test.go | 85 +++++++++++++++++++++++++++++ 8 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 cmd/memory/gc_test.go diff --git a/cmd/memory/gc.go b/cmd/memory/gc.go index c828afd8..a329e519 100644 --- a/cmd/memory/gc.go +++ b/cmd/memory/gc.go @@ -75,12 +75,19 @@ Keep mode: db.LogOp("gc", "", fmt.Sprintf("threshold=%.2f found=%d total=%d", gcThreshold, len(candidates), total)) + // Report the ceiling the way it is configured rather than the sentinel + // AutoPrune consumes: 0 is how MNEMON_MAX_INSIGHTS spells "no cap". + maxInsights := store.MaxInsightsLimit() + if maxInsights == store.MaxInsightsUnlimited { + maxInsights = 0 + } + output := map[string]interface{}{ "total_insights": total, "threshold": gcThreshold, "candidates_found": len(candidates), "candidates": candidates, - "max_insights": store.MaxInsights, + "max_insights": maxInsights, "actions": map[string]string{ "purge": "mnemon forget ", "keep": "mnemon gc --keep ", diff --git a/cmd/memory/gc_test.go b/cmd/memory/gc_test.go new file mode 100644 index 00000000..d3cbaaf8 --- /dev/null +++ b/cmd/memory/gc_test.go @@ -0,0 +1,58 @@ +package memory + +import ( + "encoding/json" + "testing" + + "github.com/mnemon-dev/mnemon/internal/memory/store" +) + +// gc is where an operator reads the ceiling back, so it must report the +// configured value and not the built-in default -- otherwise raising the cap +// looks like it did nothing. +func TestGC_ReportsConfiguredMaxInsights(t *testing.T) { + tests := []struct { + name string + env string + want float64 + }{ + {"default", "", float64(store.MaxInsights)}, + {"raised", "5000", 5000}, + // Disabled is reported the way it is configured, not as the + // internal sentinel. + {"disabled", "0", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("MNEMON_MAX_INSIGHTS", tt.env) + + oldDataDir, oldStoreName, oldReadOnly := dataDir, storeName, readOnly + oldThreshold, oldLimit, oldKeep := gcThreshold, gcLimit, gcKeepID + t.Cleanup(func() { + dataDir, storeName, readOnly = oldDataDir, oldStoreName, oldReadOnly + gcThreshold, gcLimit, gcKeepID = oldThreshold, oldLimit, oldKeep + }) + dataDir = t.TempDir() + storeName = "" + readOnly = false + gcThreshold, gcLimit, gcKeepID = 0.5, 20, "" + + var runErr error + out := captureStdout(t, func() { + runErr = gcCmd.RunE(gcCmd, nil) + }) + if runErr != nil { + t.Fatalf("gc: %v", runErr) + } + + var got map[string]interface{} + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("decode gc output: %v (output %q)", err, out) + } + if got["max_insights"] != tt.want { + t.Errorf("max_insights = %v, want %v", got["max_insights"], tt.want) + } + }) + } +} diff --git a/cmd/memory/import.go b/cmd/memory/import.go index 0100634b..08160d80 100644 --- a/cmd/memory/import.go +++ b/cmd/memory/import.go @@ -277,7 +277,7 @@ exports are documented in docs/IMPORT.md.`, } var pruneErr error - pruned, pruneErr = db.AutoPrune(store.MaxInsights, nil) + pruned, pruneErr = db.AutoPrune(store.MaxInsightsLimit(), nil) return pruneErr }); err != nil { return fmt.Errorf("finalize import graph: %w", err) diff --git a/cmd/memory/remember.go b/cmd/memory/remember.go index c37497a2..c144ea62 100644 --- a/cmd/memory/remember.go +++ b/cmd/memory/remember.go @@ -259,7 +259,7 @@ var rememberCmd = &cobra.Command{ // Auto-prune if over capacity (excludeID protects the just-created insight) var pruneErr error - pruned, pruneErr = db.AutoPrune(store.MaxInsights, []string{insight.ID}) + pruned, pruneErr = db.AutoPrune(store.MaxInsightsLimit(), []string{insight.ID}) if pruneErr != nil { fmt.Fprintf(os.Stderr, "warning: auto-prune: %v\n", pruneErr) } diff --git a/docs/USAGE.md b/docs/USAGE.md index a1d894a0..1ae08a81 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -243,6 +243,7 @@ Nodes are colored by category (decision, fact, insight, preference, context); ed | `MNEMON_EMBED_ENDPOINT` | `http://localhost:11434` | Ollama API endpoint | | `MNEMON_EMBED_MODEL` | `nomic-embed-text` | Ollama embedding model | | `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 | --- diff --git a/docs/zh/USAGE.md b/docs/zh/USAGE.md index 9078b8f9..d719099d 100644 --- a/docs/zh/USAGE.md +++ b/docs/zh/USAGE.md @@ -247,6 +247,7 @@ open graph.html | `MNEMON_EMBED_ENDPOINT` | `http://localhost:11434` | Ollama API 端点 | | `MNEMON_EMBED_MODEL` | `nomic-embed-text` | Ollama 嵌入模型 | | `MNEMON_EMBED_DIMENSIONS` | (原生维度) | 嵌入向量维度;可设置截断值(例如 Matryoshka 模型使用 `256`) | +| `MNEMON_MAX_INSIGHTS` | `1000` | 触发自动清理的活跃洞察数量上限;设为 `0` 可关闭自动清理 | --- diff --git a/internal/memory/store/node.go b/internal/memory/store/node.go index a7b9b0cb..0f1d95f6 100644 --- a/internal/memory/store/node.go +++ b/internal/memory/store/node.go @@ -6,6 +6,7 @@ import ( "math" "os" "sort" + "strconv" "strings" "time" @@ -23,8 +24,38 @@ const ( // PruneBatchSize is how many excess insights to prune at once. PruneBatchSize = 10 + + // MaxInsightsUnlimited is the ceiling MaxInsightsLimit returns when + // auto-pruning is switched off. No store reaches it, so AutoPrune's + // capacity check never trips and no other code path needs a special case. + MaxInsightsUnlimited = math.MaxInt ) +// MaxInsightsLimit returns the active auto-prune capacity ceiling. +// +// Resolution: MNEMON_MAX_INSIGHTS > MaxInsights. A value of 0 or below +// switches auto-pruning off and resolves to MaxInsightsUnlimited; an +// unparseable value is reported on stderr and ignored, so a typo cannot +// silently shrink the store. +// +// Resolved at call time rather than at package init, so a caller or test that +// sets the variable after start-up sees the value it set. +func MaxInsightsLimit() int { + raw := strings.TrimSpace(os.Getenv("MNEMON_MAX_INSIGHTS")) + if raw == "" { + return MaxInsights + } + n, err := strconv.Atoi(raw) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: invalid MNEMON_MAX_INSIGHTS %q: %v (ignored)\n", raw, err) + return MaxInsights + } + if n <= 0 { + return MaxInsightsUnlimited + } + return n +} + // InsertInsight inserts a new insight into the database. func (db *DB) InsertInsight(i *model.Insight) error { _, err := db.execer().Exec( diff --git a/internal/memory/store/store_test.go b/internal/memory/store/store_test.go index e2813f42..620451cb 100644 --- a/internal/memory/store/store_test.go +++ b/internal/memory/store/store_test.go @@ -740,6 +740,91 @@ func TestAutoPrune_RespectsExcludeIDs(t *testing.T) { } } +func TestMaxInsightsLimit(t *testing.T) { + tests := []struct { + name string + env string + want int + }{ + {"unset", "", MaxInsights}, + {"raised", "5000", 5000}, + {"lowered", "200", 200}, + {"padded", " 2500 ", 2500}, + {"zero disables", "0", MaxInsightsUnlimited}, + {"negative disables", "-1", MaxInsightsUnlimited}, + // A typo must not silently shrink the store. + {"unparseable", "1_000", MaxInsights}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("MNEMON_MAX_INSIGHTS", tt.env) + if got := MaxInsightsLimit(); got != tt.want { + t.Errorf("MaxInsightsLimit() with %q = %d, want %d", tt.env, got, tt.want) + } + }) + } +} + +// Switching auto-prune off has to hold at the capacity check itself, not only +// at the call sites, or a future caller reintroduces the reaping. +func TestAutoPrune_UnlimitedCeilingPrunesNothing(t *testing.T) { + db := testDB(t) + for i := range 5 { + if err := db.InsertInsight(makeInsight("uncapped-"+string(rune('a'+i)), "content", 1)); err != nil { + t.Fatalf("insert: %v", err) + } + } + + pruned, err := db.AutoPrune(MaxInsightsUnlimited, nil) + if err != nil { + t.Fatalf("auto prune: %v", err) + } + if pruned != 0 { + t.Errorf("want 0 pruned with auto-prune disabled, got %d", pruned) + } + all, _ := db.GetAllActiveInsights() + if len(all) != 5 { + t.Errorf("want all 5 insights retained, got %d", len(all)) + } +} + +// A raised ceiling has to change what AutoPrune actually takes, not only what +// gc reports: the same store that prunes under a lower resolved ceiling is +// left whole once MNEMON_MAX_INSIGHTS resolves above its size. +func TestAutoPrune_RaisedCeilingChangesEnforcement(t *testing.T) { + db := testDB(t) + for i := range 5 { + if err := db.InsertInsight(makeInsight("raised-"+string(rune('a'+i)), "content", 2)); err != nil { + t.Fatalf("insert: %v", err) + } + } + + t.Setenv("MNEMON_MAX_INSIGHTS", "8") + pruned, err := db.AutoPrune(MaxInsightsLimit(), nil) + if err != nil { + t.Fatalf("auto prune: %v", err) + } + if pruned != 0 { + t.Errorf("raised ceiling: want 0 pruned, got %d", pruned) + } + + // Control: the same store over a lower resolved ceiling does prune, so the + // zero above is the ceiling's doing rather than an inert store. + t.Setenv("MNEMON_MAX_INSIGHTS", "3") + pruned, err = db.AutoPrune(MaxInsightsLimit(), nil) + if err != nil { + t.Fatalf("auto prune: %v", err) + } + if pruned != 2 { + t.Errorf("lowered ceiling control: want 2 pruned, got %d", pruned) + } + + all, _ := db.GetAllActiveInsights() + if len(all) != 3 { + t.Errorf("want 3 remaining after control prune, got %d", len(all)) + } +} + func TestAutoPrune_NothingToPrune(t *testing.T) { db := testDB(t) db.InsertInsight(makeInsight("ok-1", "content", 3))