space-architect 9.0.0 → 9.1.0
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/README.md +32 -267
- data/lib/space_architect/architect_project.rb +90 -39
- data/lib/space_architect/cli/architect.rb +169 -4
- data/lib/space_architect/cli/research.rb +1 -1
- data/lib/space_architect/loop_cost.rb +592 -0
- data/lib/space_architect/research/supervisor.rb +9 -2
- data/lib/space_architect/routing.rb +149 -0
- data/lib/space_architect/version.rb +1 -1
- data/lib/space_architect.rb +1 -0
- metadata +3 -1
|
@@ -4,6 +4,7 @@ require "json"
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require_relative "../jobs_client"
|
|
6
6
|
require_relative "../conversations_client"
|
|
7
|
+
require_relative "../loop_cost"
|
|
7
8
|
require_relative "../session_sync"
|
|
8
9
|
|
|
9
10
|
module Space::Architect
|
|
@@ -165,6 +166,10 @@ module Space::Architect
|
|
|
165
166
|
project = ArchitectProject.new(space: sp)
|
|
166
167
|
info = project.status
|
|
167
168
|
block = info[:block]
|
|
169
|
+
# Legacy lanes store no model — resolve the config layer once so the
|
|
170
|
+
# display shows what they would actually route, and mark the bare
|
|
171
|
+
# constant as the last resort it is, not ordinary behavior.
|
|
172
|
+
config_model = Routing.for_space(sp.path)&.dig("builder", "model")
|
|
168
173
|
|
|
169
174
|
terminal.say "Project status: #{block['status'] || '(none)'}"
|
|
170
175
|
terminal.say "Current iteration: #{block['current_iteration'] || '(none)'}"
|
|
@@ -178,7 +183,7 @@ module Space::Architect
|
|
|
178
183
|
lane_list = s["lanes"] || []
|
|
179
184
|
lanes_str = lane_list.map do |l|
|
|
180
185
|
h = l["harness"] || "pi"
|
|
181
|
-
m = l["model"]
|
|
186
|
+
m = l["model"] || config_model || "#{Harness::DEFAULT_MODEL} (last resort)"
|
|
182
187
|
eff = l["effort"] ? "·#{l['effort']}" : ""
|
|
183
188
|
"#{l['name']}(#{l['repo']}·#{h}·#{m}#{eff})"
|
|
184
189
|
end.join(", ")
|
|
@@ -484,7 +489,7 @@ module Space::Architect
|
|
|
484
489
|
argument :lane, required: true, desc: "Lane name"
|
|
485
490
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
486
491
|
option :prompt, default: nil, desc: "Read the lane prompt from this file (copied byte-for-byte to build/<id>-<lane>/prompt.md)"
|
|
487
|
-
option :model, default: nil, desc: "Builder model to pin (default: the lane's stored model, else space.yaml project.model, else the pi default #{Harness::DEFAULT_MODEL}). Any provider/tier; pin a full id, not a floating alias"
|
|
492
|
+
option :model, default: nil, desc: "Builder model to pin (default: the lane's stored model, else architect.yaml builder role, else space.yaml project.model, else the pi default #{Harness::DEFAULT_MODEL} — a loudly-informed last resort). Any provider/tier; pin a full id, not a floating alias"
|
|
488
493
|
option :max_turns, default: "200", desc: "Max turns for the builder"
|
|
489
494
|
option :harness, default: nil, desc: "Harness (pi)"
|
|
490
495
|
option :effort, default: nil, desc: "Thinking/reasoning effort level — alias for --thinking/--reasoning (off, minimal, low, medium, high, xhigh, max)"
|
|
@@ -808,6 +813,165 @@ module Space::Architect
|
|
|
808
813
|
end
|
|
809
814
|
end
|
|
810
815
|
|
|
816
|
+
# Read-only loop accounting: spend and speed from the pi session
|
|
817
|
+
# artifacts already on disk (run metas + interactive/runner session
|
|
818
|
+
# files), stdout only. All view math lives in LoopCost; this class only
|
|
819
|
+
# resolves the space and renders — text tables and --json come from the
|
|
820
|
+
# same report hash so the two surfaces can never disagree.
|
|
821
|
+
class Cost < BaseCommand
|
|
822
|
+
desc "Account loop cost and latency from pi session artifacts (read-only)"
|
|
823
|
+
phase 54, "Project"
|
|
824
|
+
# The three view flags declare dry-cli's :flag type, not :boolean —
|
|
825
|
+
# the frozen cost-flags gate pins the literal `--json`-style tokens in
|
|
826
|
+
# --help, which :boolean would render as `--[no-]json`.
|
|
827
|
+
option :json, type: :flag, default: false, desc: "Emit the frozen JSON contract instead of tables"
|
|
828
|
+
option :space, desc: "Space identifier or path (default: nearest ancestor with space.yaml)"
|
|
829
|
+
option :sessions_root, desc: "pi sessions root (default: ~/.pi/agent/sessions via SessionSync)"
|
|
830
|
+
option :since, desc: "Only artifacts starting at/after this ISO-8601 timestamp or YYYY-MM-DD date"
|
|
831
|
+
option :by_iteration, type: :flag, default: false, desc: "Split the cost table into per-iteration windows"
|
|
832
|
+
option :latency, type: :flag, default: false, desc: "Per-model latency: round wall, turn vs generation tok/s, tool wait share"
|
|
833
|
+
option :precise, type: :flag, default: false, desc: "Exact token counts with thousands separators (default: 3 significant figures)"
|
|
834
|
+
|
|
835
|
+
def call(json: false, space: nil, sessions_root: nil, since: nil,
|
|
836
|
+
by_iteration: false, latency: false, precise: false, **opts)
|
|
837
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
838
|
+
handle_errors do
|
|
839
|
+
if by_iteration && latency
|
|
840
|
+
raise Space::Core::Error, "--by-iteration and --latency are mutually exclusive — one view per invocation"
|
|
841
|
+
end
|
|
842
|
+
render(store.find(space)) do |sp|
|
|
843
|
+
report = LoopCost.report(
|
|
844
|
+
space: sp,
|
|
845
|
+
sessions_root: sessions_root || SessionSync.default_pi_root,
|
|
846
|
+
since: since,
|
|
847
|
+
by_iteration: by_iteration,
|
|
848
|
+
latency: latency
|
|
849
|
+
)
|
|
850
|
+
terminal.say(json ? JSON.pretty_generate(report) : render_cost_text(report, by_iteration, latency, precise))
|
|
851
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
852
|
+
end
|
|
853
|
+
end
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
private
|
|
857
|
+
|
|
858
|
+
def render_cost_text(report, by_iteration, latency, precise)
|
|
859
|
+
if latency
|
|
860
|
+
latency_table(report["latency"], precise)
|
|
861
|
+
elsif by_iteration
|
|
862
|
+
by_iteration_text(report, precise)
|
|
863
|
+
else
|
|
864
|
+
cost_table(report["rows"], report["totals"], precise)
|
|
865
|
+
end
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
def cost_table(rows, totals, precise)
|
|
869
|
+
headers = %w[Agent Model Runs Input Output CacheRead CacheWrite $ Turns Hit%]
|
|
870
|
+
body = rows.map { |row| cost_cells(row, precise) }
|
|
871
|
+
body << totals_cells(totals, precise)
|
|
872
|
+
render_table(headers, body)
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
def by_iteration_text(report, precise)
|
|
876
|
+
report["iterations"].flat_map do |bucket|
|
|
877
|
+
label = bucket["starts_at"] || "-"
|
|
878
|
+
[
|
|
879
|
+
"—— #{bucket['name']} (starts_at #{label})",
|
|
880
|
+
cost_table(bucket["rows"], bucket["totals"], precise),
|
|
881
|
+
""
|
|
882
|
+
]
|
|
883
|
+
end + ["Grand total", totals_table(report["grand_total"], precise)]
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
def latency_table(rows, precise)
|
|
887
|
+
headers = %w[Model Rounds Mean\ wall\ (s) Turn\ tok/s Gen\ tok/s Wait%]
|
|
888
|
+
body = rows.map do |row|
|
|
889
|
+
[row["model"], count_label(row["rounds"], precise), format("%.2f", row["mean_wall_s"]),
|
|
890
|
+
format("%.2f", row["output_toks_per_s"]), format("%.2f", row["gen_toks_per_s"]),
|
|
891
|
+
format("%.1f%%", row["wait_share"] * 100)]
|
|
892
|
+
end
|
|
893
|
+
render_table(headers, body)
|
|
894
|
+
end
|
|
895
|
+
|
|
896
|
+
def totals_table(totals, precise)
|
|
897
|
+
render_table(%w[Runs Input Output CacheRead CacheWrite $ Turns Hit%], [totals_cells(totals, precise)[2..]])
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
def totals_cells(totals, precise)
|
|
901
|
+
["TOTAL", "", count_label(totals["runs"], precise), count_label(totals["input"], precise),
|
|
902
|
+
count_label(totals["output"], precise), count_label(totals["cache_read"], precise),
|
|
903
|
+
count_label(totals["cache_write"], precise), format("%.4f", totals["cost"]),
|
|
904
|
+
count_label(totals["turns"], precise), hit_label(totals["cache_hit_ratio"])]
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
def cost_cells(row, precise)
|
|
908
|
+
[row["agent"], row["model"], count_label(row["runs"], precise), count_label(row["input"], precise),
|
|
909
|
+
count_label(row["output"], precise), count_label(row["cache_read"], precise),
|
|
910
|
+
count_label(row["cache_write"], precise), format("%.4f", row["cost"]),
|
|
911
|
+
count_label(row["turns"], precise), hit_label(row["cache_hit_ratio"])]
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
def hit_label(ratio)
|
|
915
|
+
format("%.1f%%", ratio * 100)
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
# Token counts are unreadable as raw digit strings at loop scale
|
|
919
|
+
# (1,833,889,482 cache-read tokens): 3 significant figures with an SI
|
|
920
|
+
# suffix by default, exact thousands-separated integers for --precise.
|
|
921
|
+
SIG_UNITS = [["T", 1e12], ["B", 1e9], ["M", 1e6], ["K", 1e3]].freeze
|
|
922
|
+
private_constant :SIG_UNITS
|
|
923
|
+
|
|
924
|
+
def count_label(value, precise)
|
|
925
|
+
integer = value.to_i
|
|
926
|
+
return integer.to_s if integer < 1000
|
|
927
|
+
# stdlib has no thousands-separator format (the %'d flag is glibc, not Ruby)
|
|
928
|
+
return integer.to_s.reverse.scan(/\d{1,3}/).join(",").reverse if precise
|
|
929
|
+
|
|
930
|
+
SIG_UNITS.each_with_index do |(suffix, scale), index|
|
|
931
|
+
next unless integer >= scale
|
|
932
|
+
mantissa = integer.fdiv(scale)
|
|
933
|
+
decimals = mantissa >= 100 ? 0 : mantissa >= 10 ? 1 : 2
|
|
934
|
+
text = format("%.#{decimals}f", mantissa)
|
|
935
|
+
# Rounding overflowed the mantissa (999,500 → "1000K") — bump one
|
|
936
|
+
# scale up (units run large → small).
|
|
937
|
+
return "1#{SIG_UNITS[index - 1][0]}" if text.to_f >= 1000 && index.positive?
|
|
938
|
+
return "#{text}#{suffix}"
|
|
939
|
+
end
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
# Colors on the human views: header and TOTAL rows bold, the $ column
|
|
943
|
+
# yellow. terminal.table cannot do this — it pads by raw length before
|
|
944
|
+
# styling, so a pre-colored cell would skew alignment (and its by-header
|
|
945
|
+
# color dispatch belongs to space-cadet) — so the cost views pad from
|
|
946
|
+
# plain strings here, then style through terminal.pastel: the
|
|
947
|
+
# --color/--colors gating is inherited unchanged.
|
|
948
|
+
def render_table(headers, rows)
|
|
949
|
+
widths = headers.each_index.map do |index|
|
|
950
|
+
([headers[index]] + rows.map { |row| row[index].to_s }).map(&:length).max
|
|
951
|
+
end
|
|
952
|
+
lines = [table_line(headers, widths, bold: true)]
|
|
953
|
+
cost_index = headers.index("$")
|
|
954
|
+
rows.each do |row|
|
|
955
|
+
lines << table_line(row, widths, bold: row.first.to_s == "TOTAL", color_index: cost_index)
|
|
956
|
+
end
|
|
957
|
+
lines.join("\n")
|
|
958
|
+
end
|
|
959
|
+
|
|
960
|
+
def table_line(cells, widths, bold: false, color_index: nil)
|
|
961
|
+
cells.each_with_index.map do |cell, index|
|
|
962
|
+
text = cell.to_s
|
|
963
|
+
styled = if bold
|
|
964
|
+
terminal.pastel.bold(text)
|
|
965
|
+
elsif index == color_index
|
|
966
|
+
terminal.pastel.yellow(text)
|
|
967
|
+
else
|
|
968
|
+
text
|
|
969
|
+
end
|
|
970
|
+
"#{styled}#{' ' * (widths[index] - text.length)}"
|
|
971
|
+
end.join(" ").rstrip
|
|
972
|
+
end
|
|
973
|
+
end
|
|
974
|
+
|
|
811
975
|
class InstallSkills < BaseCommand
|
|
812
976
|
desc "Install bundled skills (architect, architect-research, architect-vocabulary) for a harness"
|
|
813
977
|
phase 53, "Project"
|
|
@@ -840,7 +1004,7 @@ module Space::Architect
|
|
|
840
1004
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
841
1005
|
option :base, default: nil, desc: "Base ref (default: HEAD of repo)"
|
|
842
1006
|
option :harness, default: nil, desc: "Harness (pi; default: space.yaml project.harness, else pi)"
|
|
843
|
-
option :model, default: nil, desc: "Model; a trailing :<level> suffix (e.g. foo:high) is parsed into --effort (default: space.yaml project.model, else the pi default #{Harness::DEFAULT_MODEL})"
|
|
1007
|
+
option :model, default: nil, desc: "Model; a trailing :<level> suffix (e.g. foo:high) is parsed into --effort (default: architect.yaml builder role, else space.yaml project.model, else the pi default #{Harness::DEFAULT_MODEL} — a loudly-informed last resort)"
|
|
844
1008
|
option :effort, default: nil, desc: "Thinking/reasoning effort level — alias for --thinking/--reasoning (off, minimal, low, medium, high, xhigh, max)"
|
|
845
1009
|
option :thinking, default: nil, desc: "Thinking/reasoning effort level — alias for --effort/--reasoning (off, minimal, low, medium, high, xhigh, max)"
|
|
846
1010
|
option :reasoning, default: nil, desc: "Thinking/reasoning effort level — alias for --effort/--thinking (off, minimal, low, medium, high, xhigh, max)"
|
|
@@ -923,7 +1087,7 @@ module Space::Architect
|
|
|
923
1087
|
argument :repo, required: true, desc: "Repo name (under repos/)"
|
|
924
1088
|
argument :iteration, required: true, desc: "Iteration name"
|
|
925
1089
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
926
|
-
option :pairs, required: true, desc: "Comma-separated harness[:model] pairs (e.g. pi,pi
|
|
1090
|
+
option :pairs, required: true, desc: "Comma-separated harness[:model] pairs (e.g. \"pi,pi:provider/model-id:high\"); a pair without a model resolves via the lane model chain (see worktree add)"
|
|
927
1091
|
option :base, default: nil, desc: "Base ref (default: HEAD of repo)"
|
|
928
1092
|
option :prompt, default: nil, desc: "Prompt file to fan-out byte-identical to each variant"
|
|
929
1093
|
|
|
@@ -1288,6 +1452,7 @@ Space::Architect::CLI::Registry.register "integrate", Space::Architect::CLI::Arc
|
|
|
1288
1452
|
Space::Architect::CLI::Registry.register "gate", Space::Architect::CLI::Architect::Gate
|
|
1289
1453
|
Space::Architect::CLI::Registry.register "install-skills", Space::Architect::CLI::Architect::InstallSkills
|
|
1290
1454
|
Space::Architect::CLI::Registry.register "bug-report", Space::Architect::CLI::Architect::BugReport
|
|
1455
|
+
Space::Architect::CLI::Registry.register "cost", Space::Architect::CLI::Architect::Cost
|
|
1291
1456
|
Space::Architect::CLI::Registry.register "brief" do |b|
|
|
1292
1457
|
b.register "new", Space::Architect::CLI::Architect::Brief::New
|
|
1293
1458
|
end
|
|
@@ -8,7 +8,7 @@ module Space::Architect
|
|
|
8
8
|
desc "Dispatch detached read-only research lanes (one per prompt file)"
|
|
9
9
|
argument :prompts, type: :array, required: true,
|
|
10
10
|
desc: "Prompt file(s) to dispatch (space-separated paths)"
|
|
11
|
-
option :model, default: nil, desc: "Researcher model override (
|
|
11
|
+
option :model, default: nil, desc: "Researcher model override (researchers are not architect.yaml-routed; without it the pi default #{Space::Architect::Harness::DEFAULT_MODEL} is used — a loudly-informed last resort)"
|
|
12
12
|
option :max_turns, default: "40", desc: "Max turns per researcher"
|
|
13
13
|
|
|
14
14
|
def call(prompts:, model: nil, max_turns: "40", **opts)
|