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))