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
7 changes: 0 additions & 7 deletions cmd/gortex/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -453,13 +453,6 @@ func runDaemonStart(cmd *cobra.Command, _ []string) error {
}
defer stopSnapshotter()

// Periodic savings flush — 5 minute interval. Bounds on-crash data
// loss for the savings counters even when the call rate is too low
// to trip the every-N-observations flush. No-op when persistence
// isn't wired (e.g. cache dir unavailable).
stopSavingsFlush := state.mcpServer.StartPeriodicSavingsFlush(5 * time.Minute)
defer stopSavingsFlush()

// Periodic reconciliation — the "janitor". Walks each tracked repo
// and runs IncrementalReindex to evict files deleted offline and
// re-index files whose mtime changed. Insurance against gaps in
Expand Down
54 changes: 31 additions & 23 deletions cmd/gortex/gain.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
//
// gain vs savings:
// - savings — rear-looking: what HAPPENED across past MCP calls
// (your actual cumulative store + JSONL event log)
// (your actual cumulative store + JSONL event log)
// - gain — forward-looking: what gortex SAVES on a typical
// call (benchmark-derived, works on fresh installs
// with no history)
// call (benchmark-derived, works on fresh installs
// with no history)
package main

import (
Expand All @@ -25,6 +25,7 @@ import (

"github.com/spf13/cobra"

"github.com/zzet/gortex/internal/persistence"
"github.com/zzet/gortex/internal/savings"
)

Expand All @@ -50,8 +51,8 @@ Default behavior:
1. Find the most recent gortex bench tokens output (auto-discovery
under bench/results/, then a transparent re-run when none).
2. Render a USD-per-model card scaled to --responses-per-day.
3. Append a short "Your history" section from ~/.gortex/cache/savings.json
when --since's window has any tracked calls.
3. Append a short "Your history" section from the savings ledger
(~/.gortex/sidecar.sqlite) when --since's window has any tracked calls.

Flags:
--bench-result PATH specific bench tokens JSON to use (skip discovery)
Expand All @@ -60,7 +61,7 @@ Flags:
--since DURATION history window (e.g. 24h, 7d; default 7d)
--json emit machine-readable JSON
--no-history skip the cumulative-history section
--cache-dir DIR override savings cache (savings.json lives here)`,
--cache-dir DIR override the ledger directory (its sidecar.sqlite)`,
RunE: runGain,
}

Expand All @@ -71,7 +72,7 @@ func init() {
gainCmd.Flags().DurationVar(&gainSince, "since", 7*24*time.Hour, "history window for the cumulative-savings section (e.g. 24h, 7d)")
gainCmd.Flags().BoolVar(&gainJSON, "json", false, "emit machine-readable JSON")
gainCmd.Flags().BoolVar(&gainNoHistory, "no-history", false, "skip the cumulative-history section")
gainCmd.Flags().StringVar(&gainCacheDir, "cache-dir", "", "override graph cache directory (savings.json lives here)")
gainCmd.Flags().StringVar(&gainCacheDir, "cache-dir", "", "override the ledger directory (its sidecar.sqlite holds the savings ledger)")
rootCmd.AddCommand(gainCmd)
}

Expand Down Expand Up @@ -302,12 +303,12 @@ func renderGainProjection(w interface{ Write([]byte) (int, error) }, rows []toke
// constrained to the --since window. Zero-population when no calls
// fell inside the window.
type gainHistory struct {
Path string
Since time.Duration
Calls int64
Saved int64
Returned int64
Costs map[string]float64
Path string
Since time.Duration
Calls int64
Saved int64
Returned int64
Costs map[string]float64
}

func (h *gainHistory) toJSON() map[string]any {
Expand All @@ -321,25 +322,32 @@ func (h *gainHistory) toJSON() map[string]any {
}
}

// loadHistory loads the cumulative savings store, restricted to the
// since-window via the JSONL event log. Returns an error only on
// hard I/O failures; missing files / empty stores produce a populated
// gainHistory with Calls=0 so the caller can decide whether to render.
// loadHistory loads the cumulative savings ledger, restricted to the
// since-window via the event history. Returns an error only on hard
// I/O failures; an empty ledger produces a populated gainHistory with
// Calls=0 so the caller can decide whether to render.
func loadHistory(cacheDir string, since time.Duration) (*gainHistory, error) {
path := savings.DefaultPath()
path := savings.DefaultDBPath()
if cacheDir != "" {
path = filepath.Join(cacheDir, "savings.json")
path = persistence.DefaultSidecarPath(cacheDir)
}
store, err := savings.Open(path)
if err != nil {
return nil, err
}
snap := store.Snapshot()
eventsPath := savings.EventsPathFor(path)
// Same rule as `gortex savings`: the legacy import only runs against
// the default location — a --cache-dir read must not rename files.
if cacheDir == "" {
_ = store.ImportLegacy(savings.DefaultPath())
}
snap, serr := store.Snapshot()
if serr != nil {
fmt.Fprintf(os.Stderr, "[gortex gain] savings totals read failed: %v\n", serr)
}

if since <= 0 {
// --since 0 → entire-history view; just use the cumulative
// totals. No JSONL scan needed.
// totals. No event scan needed.
return &gainHistory{
Path: path,
Since: since,
Expand All @@ -351,7 +359,7 @@ func loadHistory(cacheDir string, since time.Duration) (*gainHistory, error) {
}

cutoff := time.Now().UTC().Add(-since)
events, err := savings.LoadEvents(eventsPath, cutoff)
events, err := store.EventsSince(cutoff)
if err != nil {
return nil, err
}
Expand Down
43 changes: 28 additions & 15 deletions cmd/gortex/gain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,17 @@ import (
"testing"
"time"

"github.com/zzet/gortex/internal/persistence"
"github.com/zzet/gortex/internal/savings"
)

func TestHumanDuration(t *testing.T) {
cases := map[time.Duration]string{
24 * time.Hour: "24h",
7 * 24 * time.Hour: "7d",
30 * 24 * time.Hour: "30d",
2 * time.Hour: "2h",
90 * time.Minute: "1h30m0s",
24 * time.Hour: "24h",
7 * 24 * time.Hour: "7d",
30 * 24 * time.Hour: "30d",
2 * time.Hour: "2h",
90 * time.Minute: "1h30m0s",
}
for in, want := range cases {
if got := humanDuration(in); got != want {
Expand Down Expand Up @@ -142,25 +143,38 @@ func TestLoadHistory_EmptyStore(t *testing.T) {
}

func TestLoadHistory_SinceZeroUsesCumulative(t *testing.T) {
// Populate a store with a known total, then call loadHistory with
// Populate a ledger with a known total, then call loadHistory with
// since=0. The result must come from the cumulative snapshot, not
// a JSONL scan.
// an event scan.
dir := t.TempDir()
path := filepath.Join(dir, "savings.json")
store, err := savings.Open(path)
store, err := savings.Open(persistence.DefaultSidecarPath(dir))
if err != nil {
t.Fatal(err)
}
store.AddObservation("/r", "go", "get_symbol_source", 50, 500)
if err := store.Flush(); err != nil {
t.Fatal(err)
}
store.AddObservation(savings.Observation{Repo: "/r", Language: "go", Tool: "get_symbol_source", Returned: 50, Saved: 500})
h, err := loadHistory(dir, 0)
if err != nil {
t.Fatal(err)
}
if h.Calls != 1 || h.Saved != 500 {
t.Errorf("since=0 should reflect cumulative store, got %+v", h)
t.Errorf("since=0 should reflect cumulative ledger, got %+v", h)
}
}

func TestLoadHistory_WindowFiltersEvents(t *testing.T) {
dir := t.TempDir()
store, err := savings.Open(persistence.DefaultSidecarPath(dir))
if err != nil {
t.Fatal(err)
}
store.AddObservation(savings.Observation{Repo: "/r", Language: "go", Tool: "read_file", Returned: 10, Saved: 90})

h, err := loadHistory(dir, 24*time.Hour)
if err != nil {
t.Fatal(err)
}
if h.Calls != 1 || h.Saved != 90 {
t.Errorf("fresh event should fall inside a 24h window, got %+v", h)
}
}

Expand Down Expand Up @@ -226,4 +240,3 @@ func TestGainCmd_Registered(t *testing.T) {
t.Errorf("rootCmd missing `gain`; have %v", subs)
}
}

19 changes: 8 additions & 11 deletions cmd/gortex/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import (
"github.com/zzet/gortex/internal/llm/conversationlog"
"github.com/zzet/gortex/internal/persistence"
"github.com/zzet/gortex/internal/platform"
"github.com/zzet/gortex/internal/savings"
"github.com/zzet/gortex/internal/server"
"github.com/zzet/gortex/internal/server/hub"
"github.com/zzet/gortex/internal/serverstack"
Expand Down Expand Up @@ -140,10 +139,14 @@ func runMCP(cmd *cobra.Command, args []string) error {
if sideStoreCacheDir == "" {
sideStoreCacheDir = platform.CacheDir()
}
savingsPath := savings.DefaultPath()
if mcpCacheDir != "" {
savingsPath = filepath.Join(mcpCacheDir, "savings.json")
}
// The savings ledger is machine-global — the same sidecar database
// every entry point writes and the `gortex savings` CLI reads.
// --cache-dir deliberately does NOT relocate it: users set that flag
// to move the graph cache, and quietly splitting the ledger away
// from the dashboard's default read path recreates the
// empty-dashboard failure mode. Isolation (tests, sandboxes) comes
// from XDG_DATA_HOME / XDG_CACHE_HOME, which both ledger paths
// honour.

ss, err := serverstack.NewSharedServer(serverstack.SharedServerConfig{
Lifecycle: serverstack.LifecycleOneshot,
Expand All @@ -166,7 +169,6 @@ func runMCP(cmd *cobra.Command, args []string) error {
FeedbackRepo: mcpIndex,
NotebookPath: mcpIndex,
},
SavingsPath: savingsPath,
SavingsRepo: mcpIndex,
})
if err != nil {
Expand Down Expand Up @@ -204,11 +206,6 @@ func runMCP(cmd *cobra.Command, args []string) error {
}
}

// Periodic savings flush. NewSharedServer flushes on Close (deferred
// above); this guards against a crash losing accumulated totals.
stopSavingsFlush := srv.StartPeriodicSavingsFlush(5 * time.Minute)
defer stopSavingsFlush()

fmt.Fprintf(os.Stderr, "[gortex] MCP server ready (transport: %s)\n", mcpTransport)

// Start server HTTP API if requested.
Expand Down
Loading
Loading