Class: KairosMcp::Daemon::Budget

Inherits:
Object
  • Object
show all
Defined in:
lib/kairos_mcp/daemon/budget.rb

Overview

Budget — daily LLM usage ledger (P2.8).

Design (v0.2 P2.8):

* One file, .kairos/state/budget.json, tracks calls and tokens
  consumed today against a configurable daily limit.
* `record_usage` increments counters. `exceeded?` gates further
  OODA cycles. `reset_if_new_day!` rolls the counter over at
  midnight in daemon-local TZ.

The “limit” is on call count (not tokens) because call-level throttling is what the daemon actually needs: a single run-away cognitive loop is bounded by calls-per-day, not tokens-per-day. Token counters are kept for observability.

Atomicity:

Writes use tmp → rename. A crash mid-write leaves the previous
ledger intact.

Thread safety:

Budget is meant to be consulted from the daemon's single event
loop thread. Concurrent callers must synchronize externally.

Constant Summary collapse

DEFAULT_PATH =
'.kairos/state/budget.json'
DEFAULT_LIMIT =

calls/day

10_000

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(path: DEFAULT_PATH, limit: DEFAULT_LIMIT, clock: nil) ⇒ Budget

Returns a new instance of Budget.

Parameters:

  • path (String) (defaults to: DEFAULT_PATH)

    absolute path to budget.json

  • limit (Integer) (defaults to: DEFAULT_LIMIT)

    daily call ceiling

  • clock (#call, nil) (defaults to: nil)

    returns current Time



40
41
42
43
44
45
# File 'lib/kairos_mcp/daemon/budget.rb', line 40

def initialize(path: DEFAULT_PATH, limit: DEFAULT_LIMIT, clock: nil)
  @path  = path
  @limit = Integer(limit)
  @clock = clock || -> { Time.now }
  @data  = nil
end

Instance Attribute Details

#limitObject (readonly)

Returns the value of attribute limit.



35
36
37
# File 'lib/kairos_mcp/daemon/budget.rb', line 35

def limit
  @limit
end

#pathObject (readonly)

Returns the value of attribute path.



35
36
37
# File 'lib/kairos_mcp/daemon/budget.rb', line 35

def path
  @path
end

Instance Method Details

#dataObject



57
58
59
60
# File 'lib/kairos_mcp/daemon/budget.rb', line 57

def data
  @data ||= fresh_record
  @data
end

#dateObject



74
75
76
# File 'lib/kairos_mcp/daemon/budget.rb', line 74

def date
  data['date']
end

#exceeded?Boolean

True iff llm_calls has met or exceeded the configured daily limit.

Returns:

  • (Boolean)


89
90
91
# File 'lib/kairos_mcp/daemon/budget.rb', line 89

def exceeded?
  data['llm_calls'] >= @limit
end

#input_tokensObject



66
67
68
# File 'lib/kairos_mcp/daemon/budget.rb', line 66

def input_tokens
  data['input_tokens']
end

#llm_callsObject



62
63
64
# File 'lib/kairos_mcp/daemon/budget.rb', line 62

def llm_calls
  data['llm_calls']
end

#load(path = nil) ⇒ Object

Load existing ledger from disk, or initialize a fresh one for today. Returns self so callers can chain: Budget.new(…).load(path).



49
50
51
52
53
54
55
# File 'lib/kairos_mcp/daemon/budget.rb', line 49

def load(path = nil)
  @path = path if path
  @data = read_file || fresh_record
  # Guard: if the file exists but is for a previous day, roll it over.
  reset_if_new_day!
  self
end

#output_tokensObject



70
71
72
# File 'lib/kairos_mcp/daemon/budget.rb', line 70

def output_tokens
  data['output_tokens']
end

#record_usage(input_tokens: 0, output_tokens: 0, calls: 1) ⇒ Object

Increment usage counters. Does NOT save — save explicitly after a logical unit of work completes (so partial saves are rare).



80
81
82
83
84
85
86
# File 'lib/kairos_mcp/daemon/budget.rb', line 80

def record_usage(input_tokens: 0, output_tokens: 0, calls: 1)
  d = data
  d['llm_calls']     += Integer(calls)
  d['input_tokens']  += Integer(input_tokens)
  d['output_tokens'] += Integer(output_tokens)
  d
end

#reset_if_new_day!Object

If today’s date differs from the ledger’s date, zero the counters and stamp the new date. Returns true if a reset occurred.



95
96
97
98
99
100
101
102
103
104
105
# File 'lib/kairos_mcp/daemon/budget.rb', line 95

def reset_if_new_day!
  today = current_date_str
  d = data
  return false if d['date'] == today

  d['date']          = today
  d['llm_calls']     = 0
  d['input_tokens']  = 0
  d['output_tokens'] = 0
  true
end

#saveObject

Atomic write (tmp → rename).



108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/kairos_mcp/daemon/budget.rb', line 108

def save
  FileUtils.mkdir_p(File.dirname(@path))
  tmp = "#{@path}.tmp.#{$$}"
  begin
    File.open(tmp, 'w', 0o600) do |f|
      f.write(JSON.generate(data))
      f.flush
      begin
        f.fsync
      rescue StandardError
        # best-effort
      end
    end
    File.rename(tmp, @path)
    true
  ensure
    begin
      File.unlink(tmp) if tmp && File.exist?(tmp)
    rescue StandardError
      # cleanup must not raise
    end
  end
end