diff --git a/cmd/remindb/inspect.go b/cmd/remindb/inspect.go index 0b360e9..a29773e 100644 --- a/cmd/remindb/inspect.go +++ b/cmd/remindb/inspect.go @@ -10,8 +10,10 @@ import ( "sort" "strings" + "github.com/radimsem/remindb/pkg/config" "github.com/radimsem/remindb/pkg/inspect" "github.com/radimsem/remindb/pkg/store" + "github.com/radimsem/remindb/pkg/temperature" "github.com/spf13/cobra" ) @@ -30,8 +32,6 @@ const ( inspectGlyphWidth = 2 inspectSubKeyPad = 14 inspectLabelPad = inspectBranchPad + inspectGlyphWidth + 1 + inspectSubKeyPad - hotThreshold = 0.5 - coldThreshold = 0.1 gradientGreen = 60 ) @@ -75,7 +75,13 @@ func runInspect(cmd *cobra.Command, _ []string) error { return fmt.Errorf("failed to migrate: %w", err) } - stats, err := inspect.Collect(ctx, st) + workspaceCfg, err := config.Load(filepath.Dir(dbPath)) + if err != nil { + return fmt.Errorf("failed to load: workspace config: %w", err) + } + tcfg := temperature.DefaultConfig().WithOverrides(workspaceCfg.Temperature) + + stats, err := inspect.Collect(ctx, st, tcfg.HotThreshold, tcfg.ColdThreshold) if err != nil { return fmt.Errorf("failed to collect stats: %w", err) } @@ -281,8 +287,8 @@ func printStats(w io.Writer, s *inspect.Stats) { tempBranches := []ttyBranch{ {key: "avg:", value: tempPaint(s.AvgTemp)}, {key: "median:", value: tempPaint(s.MedianTemp)}, - {key: fmt.Sprintf("hot (≥%.1f):", hotThreshold), value: num(s.HotCount)}, - {key: fmt.Sprintf("cold (<%.1f):", coldThreshold), value: num(s.ColdCount)}, + {key: fmt.Sprintf("hot (≥%.1f):", s.HotThreshold), value: num(s.HotCount)}, + {key: fmt.Sprintf("cold (<%.1f):", s.ColdThreshold), value: num(s.ColdCount)}, {key: "pinned:", value: num(s.PinnedCount)}, } diff --git a/docs/configuration.md b/docs/configuration.md index 827a819..2eb0f32 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -39,6 +39,7 @@ A single JSON object of feature blocks. Unknown top-level or nested keys are rej "enabled": true, "decay_rate": 0.03, "access_boost": 0.2, + "hot_threshold": 0.7, "cold_threshold": 0.08, "notify_threshold": 0.07, "summarize_rebound": 0.6, diff --git a/docs/resources.md b/docs/resources.md index 12fb3e1..244e4e4 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -206,7 +206,7 @@ The shape is **locked** — clients depend on these keys. Notes: ## The `temperature` envelope -`remindb://temperature` is the heatmap source for a UI that draws node attention. Every node lands in **one** `nodes` array — hot, cold, and pinned together, not split — so the renderer classifies each node itself from `temperature` against the cut points the `summary` echoes. The aggregate `summary` mirrors `MemoryStats`' temperature block (`avg`, `median`, `hot`, `cold`, `pinned`) with one deliberate difference: `cold` is counted against the **live configured** threshold (`.remindb/config.json` → `temperature.cold_threshold`, resolved through `temperature.Config`), not a hardcoded constant, so the heatmap and the cold-node notifier agree on what "cold" means. `hot_threshold` is the fixed `0.5` presentation cut — there is no configurable hot threshold (a future one would be a `pkg/temperature.Config` field, governed by the tune-temperature-policy skill). +`remindb://temperature` is the heatmap source for a UI that draws node attention. Every node lands in **one** `nodes` array — hot, cold, and pinned together, not split — so the renderer classifies each node itself from `temperature` against the cut points the `summary` echoes. The aggregate `summary` mirrors `MemoryStats`' temperature block (`avg`, `median`, `hot`, `cold`, `pinned`) with both thresholds sourced from the **live configured** values (`.remindb/config.json` → `temperature.cold_threshold` / `temperature.hot_threshold`, resolved through `temperature.Config`), not hardcoded constants — so the heatmap and the cold/hot counts always agree with what the notifier and inspector report. ```json { diff --git a/integration_test.go b/integration_test.go index 02937e8..34a2550 100644 --- a/integration_test.go +++ b/integration_test.go @@ -719,7 +719,7 @@ func TestPinnedSidecarWorkflow(t *testing.T) { t.Error("manually-unpinned node was re-pinned on default recompile (must preserve MemoryUnpin)") } - statsBefore, err := st.GetStats(ctx) + statsBefore, err := st.GetStats(ctx, 0.5, 0.1) if err != nil { t.Fatalf("GetStats before reseed: %v", err) } @@ -736,7 +736,7 @@ func TestPinnedSidecarWorkflow(t *testing.T) { t.Error("--reseed-pinned did not re-apply pin to manually-unpinned node") } - statsAfter, err := st.GetStats(ctx) + statsAfter, err := st.GetStats(ctx, 0.5, 0.1) if err != nil { t.Fatalf("GetStats after reseed: %v", err) } @@ -915,7 +915,7 @@ func TestCrossFormatSearch(t *testing.T) { logSearchResult(t, "cross-format remindb", nameResult) // Verify stats reflect all three formats. - stats, err := st.GetStats(ctx) + stats, err := st.GetStats(ctx, 0.5, 0.1) if err != nil { t.Fatalf("GetStats: %v", err) } diff --git a/pkg/compiler/compiler_test.go b/pkg/compiler/compiler_test.go index 595e535..c733b5b 100644 --- a/pkg/compiler/compiler_test.go +++ b/pkg/compiler/compiler_test.go @@ -832,7 +832,7 @@ func TestCompileDir_ReseedTemperatures_NoNewSnapshot(t *testing.T) { setAllTemps(t, ctx, st, "doc.md", 0.3) - before, err := st.GetStats(ctx) + before, err := st.GetStats(ctx, 0.5, 0.1) if err != nil { t.Fatalf("GetStats before: %v", err) } @@ -841,7 +841,7 @@ func TestCompileDir_ReseedTemperatures_NoNewSnapshot(t *testing.T) { t.Fatalf("CompileDir v2: %v", err) } - after, err := st.GetStats(ctx) + after, err := st.GetStats(ctx, 0.5, 0.1) if err != nil { t.Fatalf("GetStats after: %v", err) } @@ -997,7 +997,7 @@ func TestCompileDir_ReseedPinned_NoNewSnapshot(t *testing.T) { setNodePinned(t, ctx, st, n.ID, false) } - before, err := st.GetStats(ctx) + before, err := st.GetStats(ctx, 0.5, 0.1) if err != nil { t.Fatalf("GetStats before: %v", err) } @@ -1006,7 +1006,7 @@ func TestCompileDir_ReseedPinned_NoNewSnapshot(t *testing.T) { t.Fatalf("CompileDir v2: %v", err) } - after, err := st.GetStats(ctx) + after, err := st.GetStats(ctx, 0.5, 0.1) if err != nil { t.Fatalf("GetStats after: %v", err) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 3eec2e7..3e23c0a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -98,6 +98,7 @@ type TemperatureConfig struct { Enabled *bool `json:"enabled,omitempty"` DecayRate *float64 `json:"decay_rate,omitempty"` AccessBoost *float64 `json:"access_boost,omitempty"` + HotThreshold *float64 `json:"hot_threshold,omitempty"` ColdThreshold *float64 `json:"cold_threshold,omitempty"` NotifyThreshold *float64 `json:"notify_threshold,omitempty"` SummarizeRebound *float64 `json:"summarize_rebound,omitempty"` diff --git a/pkg/inspect/format.go b/pkg/inspect/format.go index 2182651..2c23449 100644 --- a/pkg/inspect/format.go +++ b/pkg/inspect/format.go @@ -8,9 +8,6 @@ import ( ) const ( - hotThreshold = 0.5 - coldThreshold = 0.1 - branchPad = 4 glyphWidth = 2 subKeyPad = 14 @@ -71,8 +68,8 @@ func writeTemperature(b *strings.Builder, s *Stats) { branches := []branch{ {key: "avg:", value: fmt.Sprintf("%.2f", s.AvgTemp)}, {key: "median:", value: fmt.Sprintf("%.2f", s.MedianTemp)}, - {key: fmt.Sprintf("hot (>=%.1f):", hotThreshold), value: fmt.Sprintf("%d", s.HotCount)}, - {key: fmt.Sprintf("cold (<%.1f):", coldThreshold), value: fmt.Sprintf("%d", s.ColdCount)}, + {key: fmt.Sprintf("hot (>=%.1f):", s.HotThreshold), value: fmt.Sprintf("%d", s.HotCount)}, + {key: fmt.Sprintf("cold (<%.1f):", s.ColdThreshold), value: fmt.Sprintf("%d", s.ColdCount)}, {key: "pinned:", value: fmt.Sprintf("%d", s.PinnedCount)}, } writeBranches(b, branches) diff --git a/pkg/inspect/inspect.go b/pkg/inspect/inspect.go index a2eedb3..a57472d 100644 --- a/pkg/inspect/inspect.go +++ b/pkg/inspect/inspect.go @@ -24,6 +24,8 @@ type Stats struct { SnapshotCount int AvgTemp float64 MedianTemp float64 + HotThreshold float64 + ColdThreshold float64 HotCount int ColdCount int PinnedCount int @@ -37,8 +39,8 @@ type Stats struct { } // Collect every stat surfaced by inspect and MemoryStats from the given store. -func Collect(ctx context.Context, st *store.Store) (*Stats, error) { - core, err := st.GetStats(ctx) +func Collect(ctx context.Context, st *store.Store, hotThreshold, coldThreshold float64) (*Stats, error) { + core, err := st.GetStats(ctx, hotThreshold, coldThreshold) if err != nil { return nil, fmt.Errorf("failed to get: stats: %w", err) } @@ -65,6 +67,8 @@ func Collect(ctx context.Context, st *store.Store) (*Stats, error) { SnapshotCount: core.SnapshotCount, AvgTemp: core.AvgTemp, MedianTemp: core.MedianTemp, + HotThreshold: hotThreshold, + ColdThreshold: coldThreshold, HotCount: core.HotCount, ColdCount: core.ColdCount, PinnedCount: core.PinnedCount, diff --git a/pkg/inspect/inspect_test.go b/pkg/inspect/inspect_test.go index 66038c2..4e63394 100644 --- a/pkg/inspect/inspect_test.go +++ b/pkg/inspect/inspect_test.go @@ -23,7 +23,7 @@ func TestCollect_Empty(t *testing.T) { st := testutil.OpenTestDB(t) ctx := context.Background() - s, err := inspect.Collect(ctx, st) + s, err := inspect.Collect(ctx, st, 0.5, 0.1) if err != nil { t.Fatalf("Collect: %v", err) } @@ -69,7 +69,7 @@ func TestCollect_PopulatesAllFields(t *testing.T) { t.Fatalf("UpsertRelation: %v", err) } - s, err := inspect.Collect(ctx, st) + s, err := inspect.Collect(ctx, st, 0.5, 0.1) if err != nil { t.Fatalf("Collect: %v", err) } @@ -125,7 +125,7 @@ func TestCollect_RelationCountIncludesPending(t *testing.T) { } } - s, err := inspect.Collect(ctx, st) + s, err := inspect.Collect(ctx, st, 0.5, 0.1) if err != nil { t.Fatalf("Collect: %v", err) } diff --git a/pkg/mcp/initial.go b/pkg/mcp/initial.go index 61956a6..4012787 100644 --- a/pkg/mcp/initial.go +++ b/pkg/mcp/initial.go @@ -8,13 +8,15 @@ import ( "github.com/radimsem/remindb/internal/loghelper" "github.com/radimsem/remindb/pkg/compiler" "github.com/radimsem/remindb/pkg/store" + "github.com/radimsem/remindb/pkg/temperature" ) // Run an initial compile when the store is empty; no-op otherwise. func MaybeInitialCompile(ctx context.Context, st *store.Store, dir string, logger *slog.Logger) error { logger = loghelper.OrDiscard(logger) - stats, err := st.GetStats(ctx) + dflt := temperature.DefaultConfig() + stats, err := st.GetStats(ctx, dflt.HotThreshold, dflt.ColdThreshold) if err != nil { return fmt.Errorf("failed to stat: %w", err) } diff --git a/pkg/mcp/resources/overview.go b/pkg/mcp/resources/overview.go index 8185038..d8a4639 100644 --- a/pkg/mcp/resources/overview.go +++ b/pkg/mcp/resources/overview.go @@ -91,7 +91,7 @@ func newOverviewEnvelope(s *inspect.Stats) overviewEnvelope { } func (d *Deps) HandleOverview(ctx context.Context, _ *gomcp.ReadResourceRequest) (*gomcp.ReadResourceResult, error) { - stats, err := inspect.Collect(ctx, d.Store) + stats, err := inspect.Collect(ctx, d.Store, d.HotThreshold, d.ColdThreshold) if err != nil { return nil, fmt.Errorf("failed to collect: stats: %w", err) } diff --git a/pkg/mcp/resources/resources.go b/pkg/mcp/resources/resources.go index 5b155d2..0340e41 100644 --- a/pkg/mcp/resources/resources.go +++ b/pkg/mcp/resources/resources.go @@ -24,6 +24,7 @@ var Subscribable = map[string]string{ type Deps struct { Store *store.Store + HotThreshold float64 ColdThreshold float64 LogBuffer *logbuf.Buffer Sessions *session.Registry diff --git a/pkg/mcp/resources/temperature.go b/pkg/mcp/resources/temperature.go index 9803e24..18ad16e 100644 --- a/pkg/mcp/resources/temperature.go +++ b/pkg/mcp/resources/temperature.go @@ -12,8 +12,6 @@ import ( const TemperatureURI = "remindb://temperature" -const hotThreshold = 0.5 - type tempSummary struct { Avg float64 `json:"avg"` Median float64 `json:"median"` @@ -36,7 +34,7 @@ type temperatureResourceEnvelope struct { Nodes []tempNode `json:"nodes"` } -func newTemperatureEnvelope(all []*store.Node, coldThreshold float64) temperatureResourceEnvelope { +func newTemperatureEnvelope(all []*store.Node, coldThreshold, hotThreshold float64) temperatureResourceEnvelope { env := temperatureResourceEnvelope{ Summary: tempSummary{ColdThreshold: coldThreshold, HotThreshold: hotThreshold}, Nodes: make([]tempNode, 0, len(all)), @@ -81,7 +79,7 @@ func (d *Deps) HandleTemperature(ctx context.Context, _ *gomcp.ReadResourceReque return nil, fmt.Errorf("failed to get: temperature nodes: %w", err) } - body, err := json.Marshal(newTemperatureEnvelope(all, d.ColdThreshold)) + body, err := json.Marshal(newTemperatureEnvelope(all, d.ColdThreshold, d.HotThreshold)) if err != nil { return nil, fmt.Errorf("failed to marshal: temperature: %w", err) } diff --git a/pkg/mcp/resources/temperature_test.go b/pkg/mcp/resources/temperature_test.go index 9979ee6..189ce89 100644 --- a/pkg/mcp/resources/temperature_test.go +++ b/pkg/mcp/resources/temperature_test.go @@ -20,7 +20,7 @@ func temperatureFixture() []*store.Node { func eqf(a, b float64) bool { return math.Abs(a-b) < 1e-9 } func TestNewTemperatureEnvelope_SummaryMirrorsStats(t *testing.T) { - env := newTemperatureEnvelope(temperatureFixture(), 0.1) + env := newTemperatureEnvelope(temperatureFixture(), 0.1, 0.5) s := env.Summary if !eqf(s.Avg, 0.2925) { @@ -47,7 +47,7 @@ func TestNewTemperatureEnvelope_SummaryMirrorsStats(t *testing.T) { // The configured cold threshold must flow through, not a hardcoded 0.1. func TestNewTemperatureEnvelope_ColdThresholdIsConfigurable(t *testing.T) { - env := newTemperatureEnvelope(temperatureFixture(), 0.4) + env := newTemperatureEnvelope(temperatureFixture(), 0.4, 0.5) if env.Summary.Cold != 3 { t.Errorf("cold=%d, want 3 (temp < 0.4: 0.02, 0.05, 0.30)", env.Summary.Cold) @@ -57,8 +57,21 @@ func TestNewTemperatureEnvelope_ColdThresholdIsConfigurable(t *testing.T) { } } +// The configured hot threshold must flow through, not a hardcoded 0.5. +func TestNewTemperatureEnvelope_HotThresholdIsConfigurable(t *testing.T) { + env := newTemperatureEnvelope(temperatureFixture(), 0.1, 0.7) + + // temps: 0.02, 0.05, 0.30, 0.80 → only 0.80 >= 0.7 + if env.Summary.Hot != 1 { + t.Errorf("hot=%d, want 1 (temp >= 0.7: only 0.80)", env.Summary.Hot) + } + if !eqf(env.Summary.HotThreshold, 0.7) { + t.Errorf("hot_threshold=%v, want 0.7 (configured value)", env.Summary.HotThreshold) + } +} + func TestNewTemperatureEnvelope_NodesUnifiedAndComplete(t *testing.T) { - env := newTemperatureEnvelope(temperatureFixture(), 0.1) + env := newTemperatureEnvelope(temperatureFixture(), 0.1, 0.5) if len(env.Nodes) != 4 { t.Fatalf("len(nodes)=%d, want 4 (hot, cold, pinned all in one array)", len(env.Nodes)) @@ -80,7 +93,7 @@ func TestNewTemperatureEnvelope_NodesUnifiedAndComplete(t *testing.T) { } func TestNewTemperatureEnvelope_Empty(t *testing.T) { - env := newTemperatureEnvelope(nil, 0.1) + env := newTemperatureEnvelope(nil, 0.1, 0.5) if env.Nodes == nil { t.Error("nodes must be non-nil (marshals as [], not null)") @@ -91,7 +104,7 @@ func TestNewTemperatureEnvelope_Empty(t *testing.T) { if !eqf(env.Summary.Avg, 0) || !eqf(env.Summary.Median, 0) { t.Errorf("empty summary must be zero: %+v", env.Summary) } - if !eqf(env.Summary.ColdThreshold, 0.1) { - t.Errorf("cold_threshold=%v, want 0.1 even when empty", env.Summary.ColdThreshold) + if !eqf(env.Summary.ColdThreshold, 0.1) || !eqf(env.Summary.HotThreshold, 0.5) { + t.Errorf("thresholds echoed wrong on empty: cold=%v hot=%v", env.Summary.ColdThreshold, env.Summary.HotThreshold) } } diff --git a/pkg/mcp/server.go b/pkg/mcp/server.go index b637862..67d8674 100644 --- a/pkg/mcp/server.go +++ b/pkg/mcp/server.go @@ -181,6 +181,8 @@ func NewServer(st *store.Store, tracker *temperature.Tracker, cfg temperature.Co Logger: logger, SourceDir: o.sourceDir, WorkspaceConfig: o.workspaceConfig, + HotThreshold: cfg.HotThreshold, + ColdThreshold: cfg.ColdThreshold, SummarizeRebound: cfg.SummarizeRebound, Notifier: pub, } @@ -191,7 +193,7 @@ func NewServer(st *store.Store, tracker *temperature.Tracker, cfg temperature.Co } registerTools(s.mcp, deps) - resources.Register(s.mcp, &resources.Deps{Store: st, ColdThreshold: cfg.ColdThreshold, LogBuffer: o.logBuffer, Sessions: sessions, Ledger: sessLedger, RescanStatus: o.rescanStatus, SessionLogDir: sessionLogDir}) + resources.Register(s.mcp, &resources.Deps{Store: st, HotThreshold: cfg.HotThreshold, ColdThreshold: cfg.ColdThreshold, LogBuffer: o.logBuffer, Sessions: sessions, Ledger: sessLedger, RescanStatus: o.rescanStatus, SessionLogDir: sessionLogDir}) return s, nil } diff --git a/pkg/mcp/tools/deps.go b/pkg/mcp/tools/deps.go index a7c1b7d..ea7408b 100644 --- a/pkg/mcp/tools/deps.go +++ b/pkg/mcp/tools/deps.go @@ -29,6 +29,8 @@ type Deps struct { Logger *slog.Logger SourceDir string WorkspaceConfig config.Config + HotThreshold float64 + ColdThreshold float64 SummarizeRebound float64 Notifier *notify.Publisher } diff --git a/pkg/mcp/tools/stats.go b/pkg/mcp/tools/stats.go index 5cbeeb6..f562122 100644 --- a/pkg/mcp/tools/stats.go +++ b/pkg/mcp/tools/stats.go @@ -14,7 +14,7 @@ type StatsInput struct{} func (d *Deps) HandleStats(ctx context.Context, _ *gomcp.CallToolRequest, _ StatsInput) (_ *gomcp.CallToolResult, _ any, err error) { defer d.logCall(ctx, "MemoryStats", &err, time.Now()) - stats, err := inspect.Collect(ctx, d.Store) + stats, err := inspect.Collect(ctx, d.Store, d.HotThreshold, d.ColdThreshold) if err != nil { return nil, nil, fmt.Errorf("failed to collect: stats: %w", err) } diff --git a/pkg/mcp/tools/tools_test.go b/pkg/mcp/tools/tools_test.go index fe673a0..533657b 100644 --- a/pkg/mcp/tools/tools_test.go +++ b/pkg/mcp/tools/tools_test.go @@ -377,7 +377,7 @@ func TestHandleWrite_ScrubsSecret(t *testing.T) { t.Fatal("empty content — tool should still succeed on redaction") } - stats, err := st.GetStats(ctx) + stats, err := st.GetStats(ctx, 0.5, 0.1) if err != nil { t.Fatalf("GetStats: %v", err) } @@ -468,7 +468,7 @@ func TestHandleCompile_AnchorsToSourceDir(t *testing.T) { t.Fatalf("initial CompileDir: %v", err) } - before, err := st.GetStats(ctx) + before, err := st.GetStats(ctx, 0.5, 0.1) if err != nil { t.Fatalf("GetStats before: %v", err) } @@ -486,7 +486,7 @@ func TestHandleCompile_AnchorsToSourceDir(t *testing.T) { t.Fatalf("HandleCompile: %v", err) } - after, err := st.GetStats(ctx) + after, err := st.GetStats(ctx, 0.5, 0.1) if err != nil { t.Fatalf("GetStats after: %v", err) } diff --git a/pkg/store/stats.go b/pkg/store/stats.go index de4f421..8e3d9e0 100644 --- a/pkg/store/stats.go +++ b/pkg/store/stats.go @@ -2,11 +2,6 @@ package store import "context" -const ( - hotThreshold = 0.5 - coldThreshold = 0.1 -) - type Stats struct { NodeCount int SnapshotCount int @@ -20,7 +15,7 @@ type Stats struct { PendingRelationCount int } -func (s *Store) GetStats(ctx context.Context) (*Stats, error) { +func (s *Store) GetStats(ctx context.Context, hotThreshold, coldThreshold float64) (*Stats, error) { var st Stats err := s.db.QueryRowContext(ctx, qSelectStats, hotThreshold, coldThreshold). Scan( diff --git a/pkg/store/store_test.go b/pkg/store/store_test.go index 11fca67..e9b5918 100644 --- a/pkg/store/store_test.go +++ b/pkg/store/store_test.go @@ -1052,7 +1052,7 @@ func TestGetStats(t *testing.T) { ctx := context.Background() // Empty DB. - stats, err := st.GetStats(ctx) + stats, err := st.GetStats(ctx, 0.5, 0.1) if err != nil { t.Fatalf("GetStats: %v", err) } @@ -1072,7 +1072,7 @@ func TestGetStats(t *testing.T) { must(t, st.UpdateTemperature(ctx, "cccccccc", 0.4)) must(t, st.SetPinned(ctx, "aaaaaaaa", true, nil)) - stats, err = st.GetStats(ctx) + stats, err = st.GetStats(ctx, 0.5, 0.1) if err != nil { t.Fatalf("GetStats: %v", err) } diff --git a/pkg/temperature/config.go b/pkg/temperature/config.go index 1199d2e..eed3f94 100644 --- a/pkg/temperature/config.go +++ b/pkg/temperature/config.go @@ -10,6 +10,7 @@ import ( type Config struct { DecayRate float64 AccessBoost float64 + HotThreshold float64 ColdThreshold float64 NotifyThreshold float64 SummarizeRebound float64 @@ -23,6 +24,7 @@ func DefaultConfig() Config { return Config{ DecayRate: 0.05, AccessBoost: 0.15, + HotThreshold: 0.5, ColdThreshold: 0.1, NotifyThreshold: 0.1, SummarizeRebound: 0.5, @@ -44,6 +46,9 @@ func (c Config) WithOverrides(o config.TemperatureConfig) Config { if o.AccessBoost != nil { c.AccessBoost = *o.AccessBoost } + if o.HotThreshold != nil { + c.HotThreshold = *o.HotThreshold + } if o.ColdThreshold != nil { c.ColdThreshold = *o.ColdThreshold } @@ -77,6 +82,12 @@ func (c Config) Validate() error { if !inUnit(c.AccessBoost) { return fmt.Errorf("AccessBoost must be in [0, 1], got %g", c.AccessBoost) } + if !inUnit(c.HotThreshold) { + return fmt.Errorf("HotThreshold must be in [0, 1], got %g", c.HotThreshold) + } + if c.HotThreshold <= c.ColdThreshold { + return fmt.Errorf("HotThreshold must be > ColdThreshold, got hot=%g cold=%g", c.HotThreshold, c.ColdThreshold) + } if !inUnit(c.ColdThreshold) { return fmt.Errorf("ColdThreshold must be in [0, 1], got %g", c.ColdThreshold) } diff --git a/pkg/temperature/config_test.go b/pkg/temperature/config_test.go index da49a76..dec8901 100644 --- a/pkg/temperature/config_test.go +++ b/pkg/temperature/config_test.go @@ -25,6 +25,10 @@ func TestValidate_Rejects(t *testing.T) { {"negative DecayRate", func(c *Config) { c.DecayRate = -0.01 }}, {"negative AccessBoost", func(c *Config) { c.AccessBoost = -0.01 }}, {"AccessBoost above 1", func(c *Config) { c.AccessBoost = 1.01 }}, + {"negative HotThreshold", func(c *Config) { c.HotThreshold = -0.01 }}, + {"HotThreshold above 1", func(c *Config) { c.HotThreshold = 1.01 }}, + {"HotThreshold equal to ColdThreshold", func(c *Config) { c.HotThreshold = c.ColdThreshold }}, + {"HotThreshold below ColdThreshold", func(c *Config) { c.HotThreshold = c.ColdThreshold - 0.01 }}, {"negative ColdThreshold", func(c *Config) { c.ColdThreshold = -0.01 }}, {"ColdThreshold above 1", func(c *Config) { c.ColdThreshold = 1.01 }}, {"negative NotifyThreshold", func(c *Config) { c.NotifyThreshold = -0.01 }}, @@ -81,6 +85,7 @@ func TestWithOverrides_AllFields(t *testing.T) { Enabled: ptr(false), DecayRate: ptr(0.03), AccessBoost: ptr(0.2), + HotThreshold: ptr(0.7), ColdThreshold: ptr(0.08), NotifyThreshold: ptr(0.07), SummarizeRebound: ptr(0.6), @@ -94,6 +99,7 @@ func TestWithOverrides_AllFields(t *testing.T) { want := Config{ DecayRate: 0.03, AccessBoost: 0.2, + HotThreshold: 0.7, ColdThreshold: 0.08, NotifyThreshold: 0.07, SummarizeRebound: 0.6, diff --git a/skills/remind/SKILL.md b/skills/remind/SKILL.md index b6d894c..dac1e48 100644 --- a/skills/remind/SKILL.md +++ b/skills/remind/SKILL.md @@ -37,7 +37,7 @@ The smallest unit of memory is a **node**: - `label` — scannable title (first meaningful line, ≤80 chars). - `node_type` — `heading`, `list`, `kv`, `table`, `preamble`, `text`, `code`, `embed`. Hints shape, not behavior. `embed` = external HTML resource (image/video/audio/iframe). Inline ``/`` → `code` with `format` = tag name. MathML → `code` with `format` = `latex` (converted) or `mathml` (raw kept). The `format` column records the medium. - `token_count` — estimated cl100k-base tokens; the query layer honors budgets by it. Already reflects automatic per-node compaction (TOON for uniform data, LaTeX for MathML — see `memoize`), so a node can cost far fewer tokens than its raw bytes. That's compaction, not truncation — content is whole. -- `temperature` ∈ `[0.0, 1.0]` — warmth. Reads boost `+0.15` (capped at 1.0). A tick (default 5 min) decays everything by `factor = exp(-0.05 × elapsed_hours)` (~5%/hr). Two thresholds, both default `0.1`, **independent knobs**: `ColdThreshold` drives the cold-set *query* + search ranking floor; `NotifyThreshold` drives the cold-node *push*. A deployment can tune them separately. +- `temperature` ∈ `[0.0, 1.0]` — warmth. Reads boost `+0.15` (capped at 1.0). A tick (default 5 min) decays everything by `factor = exp(-0.05 × elapsed_hours)` (~5%/hr). Three configurable thresholds: `HotThreshold` (default `0.5`) marks nodes as hot for heatmap/stats; `ColdThreshold` (default `0.1`) drives the cold-set *query* + search ranking floor; `NotifyThreshold` (default `0.1`) drives the cold-node *push*. All three can be tuned independently via `.remindb/config.json` → `temperature`. `HotThreshold` must be > `ColdThreshold`. ### Snapshots @@ -288,7 +288,7 @@ All three keys are always present (`{"nodes":[],"edges":[],"pending":[]}` on an `parent_id` is `null` for a root snapshot (never `0`); at most one snapshot is `is_head`. `snapshots`/`diffs` are always present (`[]` on an empty DB); a bad `{id}` or non-positive `?limit` is an error, not an empty body. It mirrors `MemoryHistory`/`MemoryDelta` for rendering — use those tools when you want the access to warm nodes. -`remindb://temperature` — the heatmap view: every node in one `nodes` array (hot, cold, pinned all together — the renderer classifies from `temperature` vs the echoed cut points), plus an aggregate `summary`. Hot/cold counts mirror `MemoryStats`, except `cold` uses the **live configured** `cold_threshold` (`.remindb/config.json` → `temperature.cold_threshold`), not a hardcoded one; `hot_threshold` is the fixed `0.5` presentation cut: +`remindb://temperature` — the heatmap view: every node in one `nodes` array (hot, cold, pinned all together — the renderer classifies from `temperature` vs the echoed cut points), plus an aggregate `summary`. Both thresholds are sourced from the **live configured** values (`.remindb/config.json` → `temperature.cold_threshold` / `temperature.hot_threshold`), not hardcoded constants: ```json { "summary": { "avg": 0.29, "median": 0.30, "hot": 1, "cold": 2, "pinned": 1,