Skip to content
Merged
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
16 changes: 11 additions & 5 deletions cmd/remindb/inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -30,8 +32,6 @@ const (
inspectGlyphWidth = 2
inspectSubKeyPad = 14
inspectLabelPad = inspectBranchPad + inspectGlyphWidth + 1 + inspectSubKeyPad
hotThreshold = 0.5
coldThreshold = 0.1
gradientGreen = 60
)

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)},
}

Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion docs/resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
6 changes: 3 additions & 3 deletions integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
8 changes: 4 additions & 4 deletions pkg/compiler/compiler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
7 changes: 2 additions & 5 deletions pkg/inspect/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,6 @@ import (
)

const (
hotThreshold = 0.5
coldThreshold = 0.1

branchPad = 4
glyphWidth = 2
subKeyPad = 14
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions pkg/inspect/inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ type Stats struct {
SnapshotCount int
AvgTemp float64
MedianTemp float64
HotThreshold float64
ColdThreshold float64
HotCount int
ColdCount int
PinnedCount int
Expand All @@ -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)
}
Expand All @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions pkg/inspect/inspect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
4 changes: 3 additions & 1 deletion pkg/mcp/initial.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/mcp/resources/overview.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions pkg/mcp/resources/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions pkg/mcp/resources/temperature.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ import (

const TemperatureURI = "remindb://temperature"

const hotThreshold = 0.5

type tempSummary struct {
Avg float64 `json:"avg"`
Median float64 `json:"median"`
Expand All @@ -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)),
Expand Down Expand Up @@ -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)
}
Expand Down
25 changes: 19 additions & 6 deletions pkg/mcp/resources/temperature_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
Expand All @@ -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))
Expand All @@ -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)")
Expand All @@ -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)
}
}
4 changes: 3 additions & 1 deletion pkg/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand All @@ -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
}

Expand Down
2 changes: 2 additions & 0 deletions pkg/mcp/tools/deps.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ type Deps struct {
Logger *slog.Logger
SourceDir string
WorkspaceConfig config.Config
HotThreshold float64
ColdThreshold float64
SummarizeRebound float64
Notifier *notify.Publisher
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/mcp/tools/stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
6 changes: 3 additions & 3 deletions pkg/mcp/tools/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
Loading
Loading