diff --git a/internal/memory/index_bounded.go b/internal/memory/index_bounded.go new file mode 100644 index 0000000000..f4dbe10485 --- /dev/null +++ b/internal/memory/index_bounded.go @@ -0,0 +1,90 @@ +package memory + +import ( + "fmt" + "sort" + "strings" + "time" +) + +// defaultPrefixIndexMaxChars bounds the index projection rendered into the +// cached system-prompt prefix. The on-disk MEMORY.md stays complete; only the +// prefix-facing projection is capped, keeping every-turn prefix tokens bounded +// as memories accumulate. Roughly 300 tokens at 4 chars/token. +const defaultPrefixIndexMaxChars = 1200 + +// IndexBounded renders the index projection that loads into the cached prefix: +// stale facts (per memoryFreshness) fold into a summary line and the rest are +// ordered by recency, capped to a soft budget of maxChars (at least one line +// always renders). Deterministic in the memory set and now, so the prefix +// stays byte-stable within a session. +func (s Store) IndexBounded(now time.Time, maxChars int) string { + memories := s.ListAll() + if len(memories) == 0 { + return "" + } + return renderBoundedIndex(memories, now, maxChars) +} + +// renderBoundedIndex is the deterministic core of IndexBounded, separated out +// for direct testing with a fixed clock and memory set. +func renderBoundedIndex(memories []Memory, now time.Time, maxChars int) string { + // Shadowed globals (project overrides same-name global, #7995) are + // represented by their project winner; the shadowed global is dropped so + // recency can never surface it as authoritative when the winner was cut. + shadowed := map[string]bool{} + for _, o := range FindOverrides(memories) { + shadowed[o.Global.ID] = true + } + active := make([]Memory, 0, len(memories)) + folded := 0 // stale + hard-expired facts + suppressed := 0 // shadowed globals represented by their project winner + for _, m := range memories { + freshness := memoryFreshness(m, now) + if freshness == FreshnessStale || freshness == FreshnessExpired { + folded++ + continue + } + if shadowed[m.ID] { + suppressed++ + continue + } + active = append(active, m) + } + // Most recently updated first; equal timestamps keep the stable name order + // ListAll() already produced. + sort.SliceStable(active, func(i, j int) bool { + return memoryUpdatedAt(active[i]).After(memoryUpdatedAt(active[j])) + }) + var b strings.Builder + kept := 0 + for _, m := range active { + line := renderQualifiedIndexLine(m) + "\n" + if kept > 0 && b.Len()+len(line) > maxChars { + break + } + b.WriteString(line) + kept++ + } + omitted := len(active) - kept + suppressed + switch { + case omitted > 0: + fmt.Fprintf(&b, "- +%d more facts", omitted) + if folded > 0 { + fmt.Fprintf(&b, " (%d stale)", folded) + } + b.WriteString(" — search with the memory tool\n") + case folded > 0: + fmt.Fprintf(&b, "- %d stale facts hidden — search with the memory tool\n", folded) + } + return b.String() +} + +// memoryUpdatedAt returns the timestamp a fact is ordered by: UpdatedAt, +// falling back to CreatedAt when zero. +func memoryUpdatedAt(m Memory) time.Time { + if !m.UpdatedAt.IsZero() { + return m.UpdatedAt + } + return m.CreatedAt +} diff --git a/internal/memory/index_bounded_test.go b/internal/memory/index_bounded_test.go new file mode 100644 index 0000000000..3cc91a2fc4 --- /dev/null +++ b/internal/memory/index_bounded_test.go @@ -0,0 +1,193 @@ +package memory + +import ( + "strings" + "testing" + "time" +) + +// TestIndexBoundedOrdersByRecency verifies the prefix index projection orders +// active facts by most-recently-updated first. +func TestIndexBoundedOrdersByRecency(t *testing.T) { + now := time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC) + memories := []Memory{ + {Name: "oldest", Description: "old", Type: TypeProject, UpdatedAt: now.Add(-90 * 24 * time.Hour)}, + {Name: "newest", Description: "new", Type: TypeProject, UpdatedAt: now.Add(-2 * 24 * time.Hour)}, + {Name: "middle", Description: "mid", Type: TypeProject, UpdatedAt: now.Add(-30 * 24 * time.Hour)}, + } + got := renderBoundedIndex(memories, now, defaultPrefixIndexMaxChars) + pos := map[string]int{} + for _, name := range []string{"newest", "middle", "oldest"} { + pos[name] = strings.Index(got, "(project/"+name+".md)") + if pos[name] < 0 { + t.Fatalf("index missing %q:\n%s", name, got) + } + } + if !(pos["newest"] < pos["middle"] && pos["middle"] < pos["oldest"]) { + t.Fatalf("recency order wrong: %v\n%s", pos, got) + } + if strings.Contains(got, "more facts") || strings.Contains(got, "stale facts") { + t.Fatalf("unexpected fold line:\n%s", got) + } +} + +// TestIndexBoundedFoldsStale verifies stale facts are hidden from the prefix +// projection and counted on a summary line instead of occupying budget. +func TestIndexBoundedFoldsStale(t *testing.T) { + now := time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC) + memories := []Memory{ + {Name: "stale-one", Description: "old", Type: TypeProject, UpdatedAt: now.Add(-400 * 24 * time.Hour)}, // >180d → stale + {Name: "stale-two", Description: "older", Type: TypeProject, UpdatedAt: now.Add(-300 * 24 * time.Hour)}, + {Name: "active", Description: "new", Type: TypeProject, UpdatedAt: now.Add(-1 * 24 * time.Hour)}, + } + got := renderBoundedIndex(memories, now, defaultPrefixIndexMaxChars) + if strings.Contains(got, "stale-one") || strings.Contains(got, "stale-two") { + t.Fatalf("stale facts leaked into index:\n%s", got) + } + if !strings.Contains(got, "2 stale facts hidden") { + t.Fatalf("missing stale fold line:\n%s", got) + } + if !strings.Contains(got, "active") { + t.Fatalf("active fact missing:\n%s", got) + } +} + +// TestIndexBoundedAllStale verifies a fully stale set renders only the summary +// line, never an empty block. +func TestIndexBoundedAllStale(t *testing.T) { + now := time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC) + memories := []Memory{ + {Name: "a", Description: "old", Type: TypeProject, UpdatedAt: now.Add(-400 * 24 * time.Hour)}, + {Name: "b", Description: "older", Type: TypeProject, UpdatedAt: now.Add(-300 * 24 * time.Hour)}, + } + got := renderBoundedIndex(memories, now, defaultPrefixIndexMaxChars) + if strings.Contains(got, ".md)") { + t.Fatalf("stale index lines rendered:\n%s", got) + } + if !strings.Contains(got, "2 stale facts hidden") { + t.Fatalf("missing stale fold line:\n%s", got) + } +} + +// TestIndexBoundedTruncatesByBudget verifies the character budget drops the +// tail while always keeping at least one line, and reports the omitted count. +func TestIndexBoundedTruncatesByBudget(t *testing.T) { + now := time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC) + memories := []Memory{ + {Name: "first", Description: "newest fact", Type: TypeProject, UpdatedAt: now.Add(-1 * 24 * time.Hour)}, + {Name: "second", Description: "another fact", Type: TypeProject, UpdatedAt: now.Add(-2 * 24 * time.Hour)}, + {Name: "third", Description: "yet another", Type: TypeProject, UpdatedAt: now.Add(-3 * 24 * time.Hour)}, + } + got := renderBoundedIndex(memories, now, 40) // room for roughly one line + lines := strings.Count(got, "\n") + if lines != 2 { // one index line + fold line + t.Fatalf("expected 1 kept line + fold, got %d lines:\n%s", lines, got) + } + if !strings.Contains(got, "first") || strings.Contains(got, "second") || strings.Contains(got, "third") { + t.Fatalf("budget kept wrong lines:\n%s", got) + } + if !strings.Contains(got, "+2 more facts") { + t.Fatalf("missing omitted count:\n%s", got) + } +} + +// TestIndexBoundedDeterministic verifies identical inputs produce byte-identical +// output, which the cache-stable prefix depends on. +func TestIndexBoundedDeterministic(t *testing.T) { + now := time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC) + memories := []Memory{ + {Name: "alpha", Description: "a", Type: TypeProject, UpdatedAt: now.Add(-1 * 24 * time.Hour)}, + {Name: "beta", Description: "b", Type: TypeProject, UpdatedAt: now.Add(-2 * 24 * time.Hour)}, + } + first := renderBoundedIndex(memories, now, 200) + second := renderBoundedIndex(memories, now, 200) + if first != second { + t.Fatalf("nondeterministic index:\n%q\nvs\n%q", first, second) + } +} + +// TestIndexBoundedMixedFold verifies the combined fold line when both stale +// facts and budget-omitted facts exist. +func TestIndexBoundedMixedFold(t *testing.T) { + now := time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC) + memories := []Memory{ + {Name: "stale", Description: "old", Type: TypeProject, UpdatedAt: now.Add(-400 * 24 * time.Hour)}, + {Name: "keep", Description: "newest fact", Type: TypeProject, UpdatedAt: now.Add(-1 * 24 * time.Hour)}, + {Name: "cut", Description: "second fact", Type: TypeProject, UpdatedAt: now.Add(-2 * 24 * time.Hour)}, + {Name: "cut2", Description: "third fact", Type: TypeProject, UpdatedAt: now.Add(-3 * 24 * time.Hour)}, + } + got := renderBoundedIndex(memories, now, 40) // room for ~1 active line + if !strings.Contains(got, "+2 more facts (1 stale)") { + t.Fatalf("missing combined fold line:\n%s", got) + } + if strings.Contains(got, "stale facts hidden") { + t.Fatalf("wrong fold branch chosen:\n%s", got) + } +} + +// TestIndexBoundedZeroTimestampFallback verifies facts with a zero UpdatedAt +// are ordered by CreatedAt instead of sinking to the bottom. +func TestIndexBoundedZeroTimestampFallback(t *testing.T) { + now := time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC) + memories := []Memory{ + {Name: "no-updated", Description: "created recently", Type: TypeProject, CreatedAt: now.Add(-1 * 24 * time.Hour)}, // UpdatedAt zero + {Name: "updated", Description: "updated long ago", Type: TypeProject, UpdatedAt: now.Add(-90 * 24 * time.Hour)}, + } + got := renderBoundedIndex(memories, now, defaultPrefixIndexMaxChars) + if strings.Index(got, "(no-updated.md)") > strings.Index(got, "(updated.md)") { + t.Fatalf("CreatedAt fallback ordering wrong:\n%s", got) + } +} + +// TestFoldLineNotManagedIndexLine pins that fold lines never match indexLineRe, +// so reindex/Delete keep treating them as unmanaged (and they never reach disk). +func TestFoldLineNotManagedIndexLine(t *testing.T) { + for _, line := range []string{ + "- +9 more facts (3 stale) — search with the memory tool", + "- 3 stale facts hidden — search with the memory tool", + } { + if indexLineRe.MatchString(line) { + t.Fatalf("fold line %q matches managed index regex", line) + } + } +} + +// TestIndexBoundedSuppressesShadowedGlobals verifies a global fact shadowed by +// a same-name project fact never appears alone in the bounded view: the project +// winner represents it, so a recency order cannot surface the shadowed global +// as authoritative when the winner was budget-cut or folded. +func TestIndexBoundedSuppressesShadowedGlobals(t *testing.T) { + now := time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC) + memories := []Memory{ + {ID: "g-dup", Name: "dup", Description: "global side", Type: TypeProject, Scope: FactScopeGlobal, UpdatedAt: now.Add(-1 * 24 * time.Hour)}, + {ID: "p-dup", Name: "dup", Description: "project winner", Type: TypeProject, Scope: FactScopeProject, UpdatedAt: now.Add(-2 * 24 * time.Hour)}, + {ID: "p-own", Name: "own", Description: "unrelated", Type: TypeProject, Scope: FactScopeProject, UpdatedAt: now.Add(-3 * 24 * time.Hour)}, + } + got := renderBoundedIndex(memories, now, defaultPrefixIndexMaxChars) + if strings.Contains(got, "global side") { + t.Fatalf("shadowed global leaked into the bounded view:\n%s", got) + } + if !strings.Contains(got, "project winner") { + t.Fatalf("project winner missing from the bounded view:\n%s", got) + } + if !strings.Contains(got, "+1 more facts") { + t.Fatalf("fold line must count the shadowed global:\n%s", got) + } +} + +// TestIndexBoundedFoldsExpired verifies hard-expired facts (explicit expires_at +// in the past) are folded like stale ones instead of rendering as active lines. +func TestIndexBoundedFoldsExpired(t *testing.T) { + now := time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC) + memories := []Memory{ + {Name: "gone", Description: "expired fact", Type: TypeProject, Scope: FactScopeProject, ExpiresAt: now.Add(-1 * time.Hour)}, + {Name: "alive", Description: "active fact", Type: TypeProject, Scope: FactScopeProject, UpdatedAt: now.Add(-1 * 24 * time.Hour)}, + } + got := renderBoundedIndex(memories, now, defaultPrefixIndexMaxChars) + if strings.Contains(got, "expired fact") { + t.Fatalf("expired fact rendered as active:\n%s", got) + } + if !strings.Contains(got, "1 stale facts hidden") { + t.Fatalf("missing expired fold line:\n%s", got) + } +} diff --git a/internal/memory/memory.go b/internal/memory/memory.go index 3a191047fa..96b3bdc8be 100644 --- a/internal/memory/memory.go +++ b/internal/memory/memory.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strings" + "time" "reasonix/internal/instruction" ) @@ -18,7 +19,7 @@ type Set struct { Docs []Source // REASONIX.md / AGENTS.md, ascending precedence PinnedGuidance []Memory // stable snapshot of pinned fact bodies (incl. legacy global user/feedback) Store Store // auto-memory store (may be a zero/disabled Store) - Index string // MEMORY.md contents at load time + Index string // bounded index projection folded into the prefix at load time CWD string // project working dir used for discovery UserDir string // user config root (may be "") InstructionDiagnostics []instruction.Diagnostic @@ -57,7 +58,7 @@ func Load(opts Options) *Set { Docs: resolved.Documents, PinnedGuidance: store.pinnedGuidanceForProject(), Store: store, - Index: store.Index(), + Index: store.IndexBounded(time.Now(), defaultPrefixIndexMaxChars), CWD: cwd, UserDir: opts.UserDir, InstructionDiagnostics: resolved.Diagnostics, diff --git a/internal/memory/memory_test.go b/internal/memory/memory_test.go index 00aa978b34..a3984e76ec 100644 --- a/internal/memory/memory_test.go +++ b/internal/memory/memory_test.go @@ -57,6 +57,34 @@ func TestBlockSeparatesStandingInstructionsFromBackgroundMemory(t *testing.T) { } } +// TestLoadBoundsPrefixIndex verifies the Set.Index projection respects the +// prefix budget even when the full store holds many facts, and that a fold +// line tells the model more memories exist. +func TestLoadBoundsPrefixIndex(t *testing.T) { + root := t.TempDir() + user := filepath.Join(root, "user") + proj := filepath.Join(root, "project") + mustMkdir(t, proj) + store := StoreFor(user, proj) + names := []string{"alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", "iota", "kappa", "lambda", "mu"} + for _, name := range names { + desc := strings.Repeat("description text ", 6) // ~120 chars per line + if _, err := store.Save(Memory{Name: name, Description: desc, Type: TypeProject, Body: "body"}); err != nil { + t.Fatal(err) + } + } + set := Load(Options{CWD: proj, UserDir: user}) + if set.Index == "" { + t.Fatal("expected a bounded index projection") + } + if len(set.Index) > defaultPrefixIndexMaxChars+200 { + t.Fatalf("prefix index %d chars exceeds budget %d:\n%s", len(set.Index), defaultPrefixIndexMaxChars, set.Index) + } + if !strings.Contains(set.Index, "more facts") { + t.Fatalf("expected a fold line:\n%s", set.Index) + } +} + func TestLoadIncludesStableGlobalPreferencesAndFeedback(t *testing.T) { root := t.TempDir() user := filepath.Join(root, "user")