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 +4 -4
- data/app/assets/stylesheets/cardinal.css +2 -0
- data/app/jobs/ai_task_job.rb +2 -1
- data/app/jobs/assistant_reply_job.rb +2 -1
- data/app/jobs/compact_job.rb +2 -1
- data/app/jobs/deep_dive_job.rb +2 -1
- data/app/jobs/summary_job.rb +2 -1
- data/app/models/ai_call.rb +10 -0
- data/app/models/card.rb +7 -2
- data/app/models/column.rb +8 -2
- data/app/services/claude_cli.rb +23 -1
- data/app/services/rules/compiler.rb +1 -0
- data/app/services/run_sweeper.rb +12 -3
- data/app/views/boards/brief.html.erb +2 -0
- data/app/views/cards/_detail.html.erb +3 -0
- data/db/migrate/20260705120000_create_ai_calls.rb +17 -0
- data/lib/cardinal/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b30a673a110becb4019a1d537443a14ec37ebeb326c29ff310c41edd3dc59dee
|
|
4
|
+
data.tar.gz: ca0b8933f66d185cf62b30e9b32fc6b9e6217045654bc2dc61f884be82ff212c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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); }
|
data/app/jobs/ai_task_job.rb
CHANGED
|
@@ -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)
|
data/app/jobs/compact_job.rb
CHANGED
|
@@ -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",
|
data/app/jobs/deep_dive_job.rb
CHANGED
|
@@ -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,
|
data/app/jobs/summary_job.rb
CHANGED
|
@@ -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
|
-
|
|
105
|
-
|
|
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(
|
|
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
|
data/app/services/claude_cli.rb
CHANGED
|
@@ -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
|
data/app/services/run_sweeper.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
data/lib/cardinal/version.rb
CHANGED
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.
|
|
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
|