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
90 changes: 90 additions & 0 deletions internal/memory/index_bounded.go
Original file line number Diff line number Diff line change
@@ -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
}
193 changes: 193 additions & 0 deletions internal/memory/index_bounded_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 3 additions & 2 deletions internal/memory/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"strings"
"time"

"reasonix/internal/instruction"
)
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions internal/memory/memory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down