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
9 changes: 8 additions & 1 deletion cmd/memory/gc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>",
"keep": "mnemon gc --keep <id>",
Expand Down
58 changes: 58 additions & 0 deletions cmd/memory/gc_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
2 changes: 1 addition & 1 deletion cmd/memory/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion cmd/memory/remember.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down
1 change: 1 addition & 0 deletions docs/zh/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 可关闭自动清理 |

---

Expand Down
31 changes: 31 additions & 0 deletions internal/memory/store/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"math"
"os"
"sort"
"strconv"
"strings"
"time"

Expand All @@ -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(
Expand Down
85 changes: 85 additions & 0 deletions internal/memory/store/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading