Skip to content
Open
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
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_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 |

---

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_PRUNE_MIN_AGE` | (无) | 新洞察可被自动清理前的保护期(Go 时长格式,例如 `24h`);创建时间不足该时长的洞察不会被自动清理 |

---

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