cardinal-ai 0.2.13 → 0.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2d68fd5849d2147217f98894631cb725af6c4dd786bf4e2c5438f374101f0e34
4
- data.tar.gz: 905b5c3af9bd9fdad3aa2c5f19b69968b495ff2e2788a9ca04567c0e2be15e6a
3
+ metadata.gz: b30a673a110becb4019a1d537443a14ec37ebeb326c29ff310c41edd3dc59dee
4
+ data.tar.gz: ca0b8933f66d185cf62b30e9b32fc6b9e6217045654bc2dc61f884be82ff212c
5
5
  SHA512:
6
- metadata.gz: 184cbda0e27ce7e49f5b921e39b8e532a77ec27e80105f186161f2d01197ad36a5da1fdf549c2636f31b09b318459dcd9f3b19f4a74ffadd17834c54fbeb04f2
7
- data.tar.gz: 82e3aeb820bbb9252304147f37fd57bb50cb65d40e3ae709b7a9c8b911bb5b51930a19233a528eda0caec09e86181c4d95391349bf70f03bac8791aefa320ce4
6
+ metadata.gz: 585edbeff0fc41cef7ad16fcb76bb61c5bd041fc44a51f0ce3ca71d7dfcfa22144951638f0f9c3f400821fda0a5ffb84eaff2bde32d2e29ecf7448783c689672
7
+ data.tar.gz: d00bca1a2862b301a4204545ff0d79dfd14855f36a8a45e538e595a7eb7e1e81983e6aeeda3c0806fce789d7fea69f0fbb2690b8ef6e97e59dfc6774dd20036d
@@ -108,6 +108,8 @@ a { color: var(--blue); text-decoration: none; }
108
108
  .col-search.open { display: block; }
109
109
  .filter-hidden { display: none !important; }
110
110
 
111
+ .assistant-spend { margin-left: auto; font-size: 11px; color: var(--text-dim); cursor: default; }
112
+
111
113
  .pull-form { display: inline; }
112
114
  #repo-pull-status { font-size: 0.85rem; }
113
115
  #repo-pull-status .pull-ok { color: var(--green); }
@@ -17,7 +17,8 @@ class AiTaskJob < ApplicationJob
17
17
  text = ClaudeCli.prompt(
18
18
  prompt,
19
19
  system: "You are a maintenance agent on a Cardinal board performing one bounded task on card ##{card.number}. Be concise; your output is posted directly to the card's timeline.",
20
- model: model.presence || AssistantReplyJob::FALLBACK_MODEL
20
+ model: model.presence || AssistantReplyJob::FALLBACK_MODEL,
21
+ ledger: { kind: "ai_task", card: card }
21
22
  )
22
23
  card.log!("assistant_message", actor: "assistant", text: text) if text.present?
23
24
  rescue ClaudeCli::Error => e
@@ -57,6 +57,7 @@ class AssistantReplyJob < ApplicationJob
57
57
  # affirming reply ("yes, that approach") must not escalate the
58
58
  # planner into implementing.
59
59
  return ClaudeCli.prompt(unheard_messages(card), resume: card.assistant_session_id,
60
+ ledger: { kind: "assistant", card: card },
60
61
  system: ROLE_REMINDER, **common)
61
62
  rescue ClaudeCli::Error
62
63
  card.update!(assistant_session_id: nil) # stale/expired — start fresh below
@@ -64,7 +65,7 @@ class AssistantReplyJob < ApplicationJob
64
65
  end
65
66
 
66
67
  opener = kickoff ? kickoff_prompt(card) : transcript_prompt(card)
67
- ClaudeCli.prompt(opener, system: system_prompt(card), **common)
68
+ ClaudeCli.prompt(opener, system: system_prompt(card), ledger: { kind: "assistant", card: card }, **common)
68
69
  end
69
70
 
70
71
  def kickoff_prompt(card)
@@ -28,7 +28,8 @@ class CompactJob < ApplicationJob
28
28
  return clear_working(card) unless ClaudeCli.available?
29
29
 
30
30
  model = card.board.columns.find_by(archetype: "planning")&.model.presence || FALLBACK_MODEL
31
- compact = ClaudeCli.prompt(build_prompt(card), system: SYSTEM, model: model, max_turns: 1)
31
+ compact = ClaudeCli.prompt(build_prompt(card), system: SYSTEM, model: model, max_turns: 1,
32
+ ledger: { kind: "compact", card: card })
32
33
 
33
34
  card.update!(compact: compact.to_s.strip, compact_generated_at: Time.current, compact_status: nil)
34
35
  card.broadcast_replace_to card, target: "card_compact",
@@ -44,7 +44,8 @@ class DeepDiveJob < ApplicationJob
44
44
  sha = board.head_sha
45
45
  model = board.columns.find_by(archetype: "planning")&.model.presence || FALLBACK_MODEL
46
46
 
47
- brief = ClaudeCli.prompt(PROMPT, model:, tools: "Read,Glob,Grep", cwd: repo, max_turns: MAX_TURNS)
47
+ brief = ClaudeCli.prompt(PROMPT, model:, tools: "Read,Glob,Grep", cwd: repo, max_turns: MAX_TURNS,
48
+ ledger: { kind: "deep_dive" })
48
49
 
49
50
  File.write(board.brief_path, brief.to_s)
50
51
  board.update!(brief_sha: sha, brief_generated_at: Time.current,
@@ -22,7 +22,8 @@ class SummaryJob < ApplicationJob
22
22
  return clear_working(card) unless ClaudeCli.available?
23
23
 
24
24
  model = card.board.columns.find_by(archetype: "planning")&.model.presence || FALLBACK_MODEL
25
- summary = ClaudeCli.prompt(build_prompt(card), system: SYSTEM, model: model, max_turns: 1)
25
+ summary = ClaudeCli.prompt(build_prompt(card), system: SYSTEM, model: model, max_turns: 1,
26
+ ledger: { kind: "summary", card: card })
26
27
 
27
28
  card.update!(summary: summary.to_s.strip, summary_generated_at: Time.current, summary_status: nil)
28
29
  card.broadcast_replace_to card, target: "card_summary",
@@ -0,0 +1,10 @@
1
+ # One row per one-shot AI call (§ money honesty): what it was for, what it
2
+ # cost. Worker runs keep their usage on Run; everything that goes through
3
+ # ClaudeCli lands here — including board-level calls with no card (deep dive).
4
+ class AiCall < ApplicationRecord
5
+ KINDS = %w[assistant ai_task deep_dive summary compact rules_compile].freeze
6
+
7
+ belongs_to :card, optional: true
8
+
9
+ validates :kind, inclusion: { in: KINDS }
10
+ end
data/app/models/card.rb CHANGED
@@ -23,6 +23,7 @@ class Card < ApplicationRecord
23
23
  dependent: :nullify, inverse_of: :parent
24
24
  has_many :events, -> { order(:created_at, :id) }, dependent: :destroy
25
25
  has_many :agent_sessions, dependent: :destroy
26
+ has_many :ai_calls, dependent: :delete_all
26
27
  has_many :runs, through: :agent_sessions
27
28
 
28
29
  # Card-face status glyphs. Keyed on status, except `ready_for_approval?`
@@ -101,8 +102,12 @@ class Card < ApplicationRecord
101
102
 
102
103
  # Running tally across every run on the card — the closed-card cost footer
103
104
  # (card #20). Sums stopped/restarted segments so the total reflects real spend.
104
- def total_cost = runs.sum(:cost)
105
- def total_output_tokens = runs.sum(:output_tokens)
105
+ # Honest money: worker runs PLUS every one-shot call made on this card's
106
+ # behalf (planning assistant, ai_task, summary/compact) — see AiCall.
107
+ def total_cost = runs.sum(:cost) + ai_calls.sum(:cost)
108
+ def total_output_tokens = runs.sum(:output_tokens) + ai_calls.sum(:output_tokens)
109
+
110
+ def assistant_cost = ai_calls.where(kind: "assistant").sum(:cost)
106
111
 
107
112
  # Is the planning assistant expected to post next? True right after entering
108
113
  # a planning column (kickoff inspection pending) or after a user message.
data/app/models/column.rb CHANGED
@@ -134,9 +134,10 @@ class Column < ApplicationRecord
134
134
  def footer_value(compute)
135
135
  case compute.to_s
136
136
  when "sum_cost"
137
- "$%.2f" % column_runs.sum(:cost)
137
+ "$%.2f" % (column_runs.sum(:cost) + column_ai_calls.sum(:cost))
138
138
  when "sum_tokens"
139
- ActiveSupport::NumberHelper.number_to_delimited(column_runs.sum("input_tokens + output_tokens"))
139
+ ActiveSupport::NumberHelper.number_to_delimited(
140
+ column_runs.sum("input_tokens + output_tokens") + column_ai_calls.sum("input_tokens + output_tokens"))
140
141
  when "count_cards"
141
142
  cards.count.to_s
142
143
  when "model"
@@ -196,4 +197,9 @@ class Column < ApplicationRecord
196
197
  def column_runs
197
198
  Run.joins(agent_session: :card).where(cards: { column_id: id })
198
199
  end
200
+
201
+ # One-shot AI spend (assistant/ai_task/summary/…) of this column's cards.
202
+ def column_ai_calls
203
+ AiCall.where(card_id: cards.select(:id))
204
+ end
199
205
  end
@@ -34,11 +34,15 @@ module ClaudeCli
34
34
  # resume: continue an existing claude session (context carries over).
35
35
  # with_session: return [text, session_id] instead of just text, so callers
36
36
  # can keep a continuing conversation (the planning assistant does).
37
+ # ledger: { kind:, card: } — record this call's tokens/cost as an AiCall
38
+ # (§ money honesty: planning conversations and maintenance calls spend real
39
+ # dollars; only worker runs used to be counted).
37
40
  def self.prompt(text, system: nil, model: nil, tools: nil, cwd: nil, max_turns: 1,
38
- resume: nil, with_session: false)
41
+ resume: nil, with_session: false, ledger: nil)
39
42
  raise Error.new("claude CLI not found on PATH") unless available?
40
43
 
41
44
  json = invoke(text, system:, model:, tools:, cwd:, max_turns:, resume:)
45
+ record_usage!(json, ledger, model)
42
46
  if success?(json)
43
47
  return with_session ? [json["result"].to_s, json["session_id"]] : json["result"].to_s
44
48
  end
@@ -47,6 +51,7 @@ module ClaudeCli
47
51
  # force an answer from the context it already gathered.
48
52
  if json["subtype"] == "error_max_turns" && json["session_id"].present?
49
53
  wrapped = invoke(WRAP_UP, model:, cwd:, tools: "", max_turns: 2, resume: json["session_id"])
54
+ record_usage!(wrapped, ledger, model)
50
55
  if success?(wrapped)
51
56
  return with_session ? [wrapped["result"].to_s, wrapped["session_id"] || json["session_id"]] : wrapped["result"].to_s
52
57
  end
@@ -57,6 +62,23 @@ module ClaudeCli
57
62
  raise Error.new(friendly_failure(json), detail: json.to_json)
58
63
  end
59
64
 
65
+ # Best-effort by design: a ledger failure must never break the AI call that
66
+ # already succeeded. Failed calls are recorded too — they cost money.
67
+ def self.record_usage!(json, ledger, model)
68
+ return unless ledger.is_a?(Hash) && ledger[:kind].present?
69
+ usage = json["usage"] || {}
70
+ AiCall.create!(
71
+ card: ledger[:card],
72
+ kind: ledger[:kind].to_s,
73
+ model: json["model"] || model,
74
+ input_tokens: usage["input_tokens"].to_i,
75
+ output_tokens: usage["output_tokens"].to_i,
76
+ cost: json["total_cost_usd"].to_f
77
+ )
78
+ rescue StandardError => e
79
+ Rails.logger.warn("AiCall ledger write failed: #{e.class}: #{e.message}")
80
+ end
81
+
60
82
  def self.success?(json)
61
83
  json["subtype"] == "success" && !json["is_error"]
62
84
  end
@@ -22,6 +22,7 @@ module Rules
22
22
 
23
23
  raw = ClaudeCli.prompt(
24
24
  text,
25
+ ledger: { kind: "rules_compile" },
25
26
  model: AssistantReplyJob::FALLBACK_MODEL,
26
27
  system: <<~SYS
27
28
  You compile plain-English descriptions of Kanban column automation into JSON rule
@@ -14,8 +14,7 @@ module RunSweeper
14
14
  def self.fail_dead_runs
15
15
  Run.where(status: %w[queued running]).find_each do |run|
16
16
  next if alive?(run)
17
- next if run.heartbeat_at && run.heartbeat_at > HEARTBEAT_GRACE.ago
18
- next if run.heartbeat_at.nil? && run.created_at > HEARTBEAT_GRACE.ago
17
+ next if recently_active?(run)
19
18
 
20
19
  run.update!(status: "failed", finished_at: Time.current,
21
20
  result_summary: "Runner died without finishing (swept)")
@@ -32,7 +31,11 @@ module RunSweeper
32
31
  def self.repair_stuck_cards
33
32
  Card.where(status: "working").find_each do |card|
34
33
  next unless card.column.ai? # non-AI columns: "working" means a human is
35
- next if card.runs.where(status: %w[queued running needs_input]).any? { |r| r.needs_input? || alive?(r) }
34
+ # Same grace as fail_dead_runs: a freshly started run has no pid until
35
+ # AFTER workspace provisioning (clone/fetch) — recency is its proof of
36
+ # life, or every just-dragged card risks a bogus "stuck" verdict.
37
+ next if card.runs.where(status: %w[queued running needs_input])
38
+ .any? { |r| r.needs_input? || alive?(r) || recently_active?(r) }
36
39
  card.update!(status: "failed")
37
40
  card.log!("error", text: "Card was stuck working with no live run; marked failed.")
38
41
  end
@@ -42,6 +45,12 @@ module RunSweeper
42
45
  Column.where(archetype: "execution").find_each(&:kick_queue)
43
46
  end
44
47
 
48
+ # Between state writes (provisioning, spawn) a live run has no pid yet —
49
+ # a recent heartbeat or recent birth counts as alive.
50
+ def self.recently_active?(run)
51
+ (run.heartbeat_at || run.created_at) > HEARTBEAT_GRACE.ago
52
+ end
53
+
45
54
  def self.alive?(run)
46
55
  pid = run.agent_session&.config&.dig("pid")
47
56
  return false if pid.blank?
@@ -18,6 +18,8 @@
18
18
  Generated <%= @board.brief_generated_at&.strftime("%b %-d, %H:%M") %>
19
19
  from <code><%= @board.brief_sha&.first(7) %></code>
20
20
  with <%= @board.brief_model %>
21
+ <% dive_cost = AiCall.where(kind: "deep_dive").order(:id).last&.cost.to_f %>
22
+ <% if dive_cost.positive? %> for $<%= sprintf("%.2f", dive_cost) %><% end %>
21
23
  <% behind = @board.commits_behind_brief %>
22
24
  <% if behind&.positive? %>
23
25
  · <span class="brief-behind"><%= pluralize(behind, "commit") %> behind HEAD</span>
@@ -35,6 +35,9 @@
35
35
  <%= link_to zoom.capitalize, card_path(@card, zoom: zoom),
36
36
  class: ("active" if @zoom == zoom) %>
37
37
  <% end %>
38
+ <% if (spend = @card.assistant_cost).positive? %>
39
+ <span class="assistant-spend" title="What this card's planning conversation has cost so far (assistant replies — separate from agent runs)">🪶 $<%= sprintf("%.2f", spend) %></span>
40
+ <% end %>
38
41
  </nav>
39
42
 
40
43
  <% if @zoom == "summary" %>
@@ -0,0 +1,17 @@
1
+ # Usage ledger for every one-shot AI call (card #-less deep dives included).
2
+ # Worker runs track their own usage on Run; this covers the ClaudeCli tier:
3
+ # planning assistant, ai_task rules, deep dive, summary/compact, compiler.
4
+ class CreateAiCalls < ActiveRecord::Migration[8.1]
5
+ def change
6
+ create_table :ai_calls do |t|
7
+ t.references :card, foreign_key: true, null: true
8
+ t.string :kind, null: false
9
+ t.string :model
10
+ t.integer :input_tokens, default: 0, null: false
11
+ t.integer :output_tokens, default: 0, null: false
12
+ t.decimal :cost, precision: 10, scale: 6, default: 0, null: false
13
+ t.datetime :created_at, null: false
14
+ end
15
+ add_index :ai_calls, :kind
16
+ end
17
+ end
@@ -1,3 +1,3 @@
1
1
  module Cardinal
2
- VERSION = "0.2.13"
2
+ VERSION = "0.2.14"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cardinal-ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.13
4
+ version: 0.2.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jason Ellis
@@ -196,6 +196,7 @@ files:
196
196
  - app/jobs/summary_job.rb
197
197
  - app/mailers/application_mailer.rb
198
198
  - app/models/agent_session.rb
199
+ - app/models/ai_call.rb
199
200
  - app/models/application_record.rb
200
201
  - app/models/artifact.rb
201
202
  - app/models/board.rb
@@ -267,6 +268,7 @@ files:
267
268
  - db/migrate/20260704130000_add_summary_to_cards.rb
268
269
  - db/migrate/20260704140000_add_compact_to_cards.rb
269
270
  - db/migrate/20260704231436_add_model_and_effort_to_cards.rb
271
+ - db/migrate/20260705120000_create_ai_calls.rb
270
272
  - db/queue_schema.rb
271
273
  - db/seeds.rb
272
274
  - docker/agent/Dockerfile