From 4b9d6e5466a83611a8f7bc31a11b66f58dd4d15e Mon Sep 17 00:00:00 2001 From: Tars Date: Wed, 19 Aug 2026 00:42:26 -0700 Subject: [PATCH] feat(memory): add a minimum-age grace period before auto-prune AutoPrune's capacity check runs on every remember and import, so a store that sits over capacity prunes on every write. An insight written into such a store can be soft-deleted in the same second it is created, before anything has read it once -- and access_count, the only protection a low-importance insight has besides importance itself, cannot rise until something does. MNEMON_PRUNE_MIN_AGE sets a grace period (Go duration) below which an insight is never auto-pruned, regardless of importance, access count, or effective_importance. Unset or zero is the default and preserves current behavior exactly; an unparseable or negative value warns on stderr and is ignored rather than widening what auto-prune may take. Verified with make test, including new store tests covering the spared young insight, the unset-default path, and env resolution. --- docs/USAGE.md | 1 + docs/zh/USAGE.md | 1 + internal/memory/store/node.go | 47 +++++++++++++++++- internal/memory/store/store_test.go | 75 +++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 2 deletions(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index a1d894a0..dd1c8403 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_PRUNE_MIN_AGE` | (none) | Grace period before a new insight may be auto-pruned (Go duration, e.g., `24h`); insights younger than this are never auto-pruned | --- diff --git a/docs/zh/USAGE.md b/docs/zh/USAGE.md index 9078b8f9..29bac207 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_PRUNE_MIN_AGE` | (无) | 新洞察可被自动清理前的保护期(Go 时长格式,例如 `24h`);创建时间不足该时长的洞察不会被自动清理 | --- diff --git a/internal/memory/store/node.go b/internal/memory/store/node.go index a7b9b0cb..f0b5f095 100644 --- a/internal/memory/store/node.go +++ b/internal/memory/store/node.go @@ -23,8 +23,41 @@ const ( // PruneBatchSize is how many excess insights to prune at once. PruneBatchSize = 10 + + // DefaultPruneMinAge is the default grace period AutoPrune gives a newly + // created insight. Zero preserves the historical behavior: an insight is + // prunable the moment it is written. + DefaultPruneMinAge time.Duration = 0 ) +// PruneMinAge returns the minimum age an insight must reach before AutoPrune +// may soft-delete it. An insight younger than this is spared regardless of +// importance, access count, or effective_importance. +// +// Resolution: MNEMON_PRUNE_MIN_AGE > DefaultPruneMinAge. The value is a Go +// duration string ("30m", "24h", "168h"); empty or zero means no grace period. +// An unparseable or negative value is reported on stderr and ignored, so a +// typo cannot silently widen what auto-prune is allowed to take. +// +// 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 PruneMinAge() time.Duration { + raw := strings.TrimSpace(os.Getenv("MNEMON_PRUNE_MIN_AGE")) + if raw == "" { + return DefaultPruneMinAge + } + d, err := time.ParseDuration(raw) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: invalid MNEMON_PRUNE_MIN_AGE %q: %v (ignored)\n", raw, err) + return DefaultPruneMinAge + } + if d < 0 { + fmt.Fprintf(os.Stderr, "warning: negative MNEMON_PRUNE_MIN_AGE %q (ignored)\n", raw) + return DefaultPruneMinAge + } + return d +} + // InsertInsight inserts a new insight into the database. func (db *DB) InsertInsight(i *model.Insight) error { _, err := db.execer().Exec( @@ -443,13 +476,23 @@ func (db *DB) autoPrune(maxInsights int, excludeIDs []string) (int, error) { } excludeClause = fmt.Sprintf("AND id NOT IN (%s)", strings.Join(placeholders, ",")) } + // Spare insights inside the grace period. The capacity check runs on every + // write, so in a store that sits over capacity an insight can otherwise be + // created and reaped before anything has had the chance to read it once -- + // and access_count, the only other protection a low-importance insight has, + // cannot rise until something does. Age is what says "not yet". + var minAgeClause string + if minAge := PruneMinAge(); minAge > 0 { + minAgeClause = "AND created_at <= ?" + args = append(args, time.Now().UTC().Add(-minAge).Format(time.RFC3339)) + } args = append(args, excess) // Collect candidate IDs first (close cursor before writing to avoid single-conn deadlock) rows, err := ex.Query( fmt.Sprintf(`SELECT id FROM insights - WHERE deleted_at IS NULL AND importance < 4 AND access_count < 3 %s - ORDER BY effective_importance ASC LIMIT ?`, excludeClause), args...) + WHERE deleted_at IS NULL AND importance < 4 AND access_count < 3 %s %s + ORDER BY effective_importance ASC LIMIT ?`, excludeClause, minAgeClause), args...) if err != nil { return 0, fmt.Errorf("query prune candidates: %w", err) } diff --git a/internal/memory/store/store_test.go b/internal/memory/store/store_test.go index e2813f42..8615c7fe 100644 --- a/internal/memory/store/store_test.go +++ b/internal/memory/store/store_test.go @@ -740,6 +740,81 @@ func TestAutoPrune_RespectsExcludeIDs(t *testing.T) { } } +// The grace period exists because the capacity check fires on every write: in +// a store that sits permanently over capacity, an insight can be created and +// reaped in the same second, before anything has read it once. Below the +// configured age, importance and access_count must not decide the question. +func TestAutoPrune_SparesInsightsYoungerThanMinAge(t *testing.T) { + t.Setenv("MNEMON_PRUNE_MIN_AGE", "1h") + db := testDB(t) + + aged := makeInsight("aged-1", "written yesterday", 1) + aged.CreatedAt = time.Now().UTC().Add(-24 * time.Hour) + aged.UpdatedAt = aged.CreatedAt + if err := db.InsertInsight(aged); err != nil { + t.Fatalf("insert aged: %v", err) + } + if err := db.InsertInsight(makeInsight("fresh-1", "written just now", 1)); err != nil { + t.Fatalf("insert fresh: %v", err) + } + + // Max=0 asks auto-prune to take everything it is allowed to take. + pruned, err := db.AutoPrune(0, nil) + if err != nil { + t.Fatalf("auto prune: %v", err) + } + if pruned != 1 { + t.Fatalf("want 1 pruned (only the aged insight), got %d", pruned) + } + if _, err := db.GetInsightByID("fresh-1"); err != nil { + t.Errorf("insight younger than the grace period must survive: %v", err) + } + if _, err := db.GetInsightByID("aged-1"); err == nil { + t.Error("insight older than the grace period should have been pruned") + } +} + +// Unset must mean exactly what it meant before the grace period existed. +func TestAutoPrune_MinAgeUnsetLeavesFreshInsightsPrunable(t *testing.T) { + t.Setenv("MNEMON_PRUNE_MIN_AGE", "") + db := testDB(t) + if err := db.InsertInsight(makeInsight("nograce-1", "written just now", 1)); err != nil { + t.Fatalf("insert: %v", err) + } + + pruned, err := db.AutoPrune(0, nil) + if err != nil { + t.Fatalf("auto prune: %v", err) + } + if pruned != 1 { + t.Errorf("want 1 pruned with no grace period configured, got %d", pruned) + } +} + +func TestPruneMinAge(t *testing.T) { + tests := []struct { + name string + env string + want time.Duration + }{ + {"unset", "", DefaultPruneMinAge}, + {"duration", "36h", 36 * time.Hour}, + {"padded", " 30m ", 30 * time.Minute}, + {"zero", "0s", 0}, + // A typo must not silently widen what auto-prune may take. + {"unparseable", "24 hours", DefaultPruneMinAge}, + {"negative", "-1h", DefaultPruneMinAge}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("MNEMON_PRUNE_MIN_AGE", tt.env) + if got := PruneMinAge(); got != tt.want { + t.Errorf("PruneMinAge() with %q = %v, want %v", tt.env, got, tt.want) + } + }) + } +} + func TestAutoPrune_NothingToPrune(t *testing.T) { db := testDB(t) db.InsertInsight(makeInsight("ok-1", "content", 3))