crewforth 3.1.0-rc.4 → 3.1.0-rc.6
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.
- package/CHANGELOG.md +59 -0
- package/README.md +1 -1
- package/kit/AGENT_TEMPLATE.md +48 -19
- package/kit/CLAUDE.md +13 -0
- package/kit/agents/crew-backend-expert.md +8 -0
- package/kit/agents/crew-commit-agent.md +8 -0
- package/kit/agents/crew-database-expert.md +8 -0
- package/kit/agents/crew-devops-expert.md +8 -0
- package/kit/agents/crew-frontend-expert.md +8 -0
- package/kit/agents/crew-performance-expert.md +8 -0
- package/kit/agents/crew-planner.md +14 -0
- package/kit/agents/crew-privacy-agent.md +10 -2
- package/kit/agents/crew-review-agent.md +8 -0
- package/kit/agents/crew-security-expert.md +11 -4
- package/kit/agents/crew-session-manager.md +10 -3
- package/kit/agents/crew-test-expert.md +8 -0
- package/kit/eval/doctor.sh +16 -0
- package/kit/eval/smoke-test.sh +898 -11
- package/kit/hooks/agent-outcome.sh +416 -0
- package/kit/hooks/guard-agent-model.sh +819 -0
- package/kit/hooks/guard-bash.sh +26 -1
- package/kit/hooks/guard-schedule.sh +7 -2
- package/kit/hooks/guard-write.sh +31 -0
- package/kit/hooks/prompt-approval.sh +55 -5
- package/kit/settings.json +54 -0
- package/kit/skills/crew-loosen/SKILL.md +28 -0
- package/kit/studio/server/hooks/studio-gate.sh +90 -4
- package/kit/studio/server/hooks/studio-host.mjs +92 -0
- package/kit/studio/server/index.js +86 -7
- package/kit/studio/server/lib/agent-origin.js +44 -0
- package/kit/studio/server/lib/graph.js +142 -10
- package/kit/studio/server/lib/model-outcomes.js +287 -0
- package/kit/studio/server/lib/palette.js +5 -18
- package/kit/studio/server/lib/permissions.js +260 -17
- package/kit/studio/server/lib/session.js +41 -1
- package/kit/studio/server/lib/usage.js +103 -25
- package/kit/studio/web/app.js +251 -37
- package/kit/studio/web/approvals.js +53 -2
- package/kit/studio/web/ask.js +137 -0
- package/kit/studio/web/canvas.js +220 -59
- package/kit/studio/web/chat.js +19 -2
- package/kit/studio/web/convo.js +14 -1
- package/kit/studio/web/dock.js +30 -9
- package/kit/studio/web/graph-plan.js +66 -18
- package/kit/studio/web/icons/builtin.svg +1 -1
- package/kit/studio/web/index.html +11 -4
- package/kit/studio/web/inspect.js +33 -1
- package/kit/studio/web/keys.js +29 -0
- package/kit/studio/web/list-plan.js +8 -3
- package/kit/studio/web/list.js +35 -7
- package/kit/studio/web/models-plan.js +167 -0
- package/kit/studio/web/models.js +127 -0
- package/kit/studio/web/names.js +84 -0
- package/kit/studio/web/style.css +134 -15
- package/kit/studio/web/timeline-plan.js +8 -0
- package/kit/studio/web/timeline.js +36 -10
- package/kit/studio/web/usage.js +53 -12
- package/package.json +1 -1
package/kit/studio/web/style.css
CHANGED
|
@@ -434,6 +434,8 @@ mark { background: color-mix(in srgb, var(--studio-accent) 30%, transparent); co
|
|
|
434
434
|
font-size: 13px; color: var(--ink); overflow-wrap: anywhere;
|
|
435
435
|
}
|
|
436
436
|
.toast[hidden] { display: none; }
|
|
437
|
+
/* The one action a toast can carry. The toast takes clicks only where that button is. */
|
|
438
|
+
.toast-act { pointer-events: auto; border: 0; padding: 0; background: none; font: inherit; color: var(--studio-accent); cursor: pointer; text-decoration: underline; }
|
|
437
439
|
|
|
438
440
|
/* ============================================================== stage === */
|
|
439
441
|
|
|
@@ -545,6 +547,56 @@ mark { background: color-mix(in srgb, var(--studio-accent) 30%, transparent); co
|
|
|
545
547
|
|
|
546
548
|
/* ------------------------------------------------------------ the list ---
|
|
547
549
|
One column, in the order of what needs the reader. */
|
|
550
|
+
/* ------------------------------------------------------------- models ---
|
|
551
|
+
The fourth view: the project's record of which model each kind of task needs. One pane, a table that scrolls
|
|
552
|
+
sideways in a narrow window, and under it what could be lowered. */
|
|
553
|
+
.mv { flex: 1; min-height: 0; min-width: 0; overflow: auto; background: var(--ground); padding: var(--space-4); }
|
|
554
|
+
.mv[hidden] { display: none; }
|
|
555
|
+
.mv-empty { padding: var(--space-6); color: var(--ink-dim); font-size: 13px; }
|
|
556
|
+
.mv-pane { border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
|
|
557
|
+
.mv-head { display: flex; align-items: center; gap: var(--space-2); padding: 14px 18px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
|
|
558
|
+
.mv-title { margin: 0; font-size: 15px; font-weight: 700; }
|
|
559
|
+
.mv .sub { white-space: normal; flex: initial; min-width: 0; }
|
|
560
|
+
.mv-scroll { overflow-x: auto; }
|
|
561
|
+
.mv-table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
|
562
|
+
.mv-table th { font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-dim); text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
|
563
|
+
.mv-table td { padding: 10px 12px; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
|
564
|
+
.mv-table tbody tr:last-child td { border-bottom: 0; }
|
|
565
|
+
.mv-table .mv-num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
566
|
+
.mv-table td.mv-num { font-family: var(--font-mono); }
|
|
567
|
+
.mv-tag { display: inline-flex; align-items: center; height: 20px; padding: 0 8px; border-radius: var(--radius-sm); font-size: 11px; font-weight: 700; white-space: nowrap; background: var(--surface-raised); color: var(--ink-dim); }
|
|
568
|
+
.mv-tag[data-risk="critical"] { color: var(--status-fail); background: color-mix(in srgb, var(--status-fail) 14%, transparent); }
|
|
569
|
+
.mv-tag[data-kind="raised"] { margin-left: 6px; color: var(--status-waiting); background: color-mix(in srgb, var(--status-waiting) 14%, transparent); }
|
|
570
|
+
.mv-tag[data-kind="lowered"] { margin-left: 6px; color: var(--studio-accent); background: color-mix(in srgb, var(--studio-accent) 16%, transparent); }
|
|
571
|
+
.mv-raised .mv-tag[data-kind="lowered"] { margin-left: 0; }
|
|
572
|
+
.mv-tag[data-tone="pass"] { color: var(--status-good); background: color-mix(in srgb, var(--status-good) 14%, transparent); }
|
|
573
|
+
.mv-tag[data-tone="fail"] { color: var(--status-fail); background: color-mix(in srgb, var(--status-fail) 14%, transparent); }
|
|
574
|
+
.mv-tag[data-kind="escalated"] { color: var(--studio-accent); background: color-mix(in srgb, var(--studio-accent) 16%, transparent); }
|
|
575
|
+
.mv-table td[data-partial="true"] { color: var(--ink-dim); }
|
|
576
|
+
.mv-meter { display: inline-block; width: 72px; height: 6px; margin-left: 8px; border-radius: 999px; background: var(--surface-raised); overflow: hidden; vertical-align: middle; }
|
|
577
|
+
.mv-meter-fill { display: block; height: 6px; }
|
|
578
|
+
.mv-meter-fill[data-tone="good"] { background: var(--status-good); }
|
|
579
|
+
.mv-meter-fill[data-tone="waiting"] { background: var(--status-waiting); }
|
|
580
|
+
.mv-suggest { margin: 16px 18px; padding: 14px 16px; display: flex; flex-direction: column; gap: 8px; border: 1px solid color-mix(in srgb, var(--studio-accent) 45%, var(--line)); border-radius: var(--radius-md); background: color-mix(in srgb, var(--studio-accent) 9%, var(--surface)); }
|
|
581
|
+
.mv-suggest-h { margin: 0; font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--studio-accent); }
|
|
582
|
+
.mv-suggest-text { margin: 0; font-size: 14px; line-height: 21px; }
|
|
583
|
+
.mv-suggest-row { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
|
|
584
|
+
.mv-cmd { padding: 2px 6px; border-radius: var(--radius-sm); background: var(--surface-raised); font-size: 12px; user-select: all; }
|
|
585
|
+
.mv-raised { display: flex; align-items: baseline; gap: var(--space-2); padding: 0 18px 16px; }
|
|
586
|
+
.mv-raised .mv-tag { margin-left: 0; flex: none; }
|
|
587
|
+
|
|
588
|
+
/* The inspector's task card: the three lines as they were written, and under them how the work went. */
|
|
589
|
+
.icard { margin: 0; display: grid; grid-template-columns: 64px minmax(0, 1fr); gap: 4px 12px; font-size: 12px; line-height: 18px; }
|
|
590
|
+
.icard dt { color: var(--ink-faint); }
|
|
591
|
+
.icard dd { margin: 0; font-family: var(--font-mono); overflow-wrap: anywhere; }
|
|
592
|
+
.isec-h .mv-tag { float: right; text-transform: none; letter-spacing: 0; }
|
|
593
|
+
.isteps { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 6px; font-size: 12px; line-height: 20px; }
|
|
594
|
+
.isteps li { display: flex; gap: 10px; align-items: flex-start; }
|
|
595
|
+
.istep-n { flex: none; width: 20px; height: 20px; border-radius: 999px; background: var(--surface-raised); color: var(--ink-dim); font-size: 11px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; }
|
|
596
|
+
.istep-says { min-width: 0; overflow-wrap: anywhere; }
|
|
597
|
+
.istep-says .mv-tag { margin-left: 6px; }
|
|
598
|
+
.istep-note { margin: 8px 0 0; font-size: 12px; line-height: 16px; color: var(--ink-dim); }
|
|
599
|
+
|
|
548
600
|
.ls { flex: 1; min-height: 0; min-width: 0; overflow-y: auto; background: var(--ground); padding: var(--space-3) var(--space-4) var(--space-6); }
|
|
549
601
|
.ls[hidden] { display: none; }
|
|
550
602
|
.ls-sec { max-width: 760px; margin: 0 auto var(--space-4); }
|
|
@@ -742,6 +794,37 @@ button.tl-lab { cursor: pointer; }
|
|
|
742
794
|
}
|
|
743
795
|
.terminal-wait-note { flex: none; margin-left: auto; }
|
|
744
796
|
|
|
797
|
+
/* A question the model asks, or a plan it wants approved: a form in the dock, or on a card in the List. */
|
|
798
|
+
.ask { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; margin-top: var(--space-1); }
|
|
799
|
+
.ask-q { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
|
800
|
+
.ask-head { display: flex; align-items: baseline; gap: var(--space-2); min-width: 0; flex-wrap: wrap; }
|
|
801
|
+
.ask-tag { flex: none; padding: 0 6px; border-radius: var(--radius-sm); background: var(--surface-raised); font-size: 11px; color: var(--ink-dim); }
|
|
802
|
+
.ask-text { font-size: 13px; color: var(--ink); overflow-wrap: anywhere; }
|
|
803
|
+
.ask-opts { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
804
|
+
.ask-opt {
|
|
805
|
+
height: 28px; padding: 0 10px; border-radius: var(--radius-sm); border: 1px solid var(--line);
|
|
806
|
+
background: var(--surface); color: var(--ink); font: inherit; font-size: 12px; cursor: pointer;
|
|
807
|
+
}
|
|
808
|
+
.ask-opt:hover { border-color: var(--ink-faint); }
|
|
809
|
+
.ask-opt[aria-checked="true"] { border-color: var(--studio-accent); box-shadow: 0 0 0 1px var(--studio-accent); }
|
|
810
|
+
.ask-opt:disabled, .ask-own:disabled { opacity: 0.5; cursor: default; }
|
|
811
|
+
.ask-own {
|
|
812
|
+
height: 28px; padding: 0 8px; min-width: 0; max-width: 360px; border-radius: var(--radius-sm);
|
|
813
|
+
border: 1px solid var(--line); background: var(--ground); color: var(--ink); font: inherit; font-size: 12px;
|
|
814
|
+
}
|
|
815
|
+
.ask-plan {
|
|
816
|
+
margin: 0; padding: var(--space-2) var(--space-3); max-height: 220px; overflow: auto;
|
|
817
|
+
border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--ground);
|
|
818
|
+
font-family: var(--font-mono); font-size: 12px; line-height: 1.5; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere;
|
|
819
|
+
}
|
|
820
|
+
.ask-acts { display: flex; flex-wrap: wrap; gap: var(--space-2); justify-content: flex-end; }
|
|
821
|
+
.dock-ask[hidden] { display: none; }
|
|
822
|
+
/* The mode in the conversation's strip is a control: it looks like the text it sits among until it is hovered. */
|
|
823
|
+
.pane-mode { border: 0; padding: 0 4px; margin: 0 -4px; border-radius: var(--radius-sm); background: none; font: inherit; color: inherit; cursor: pointer; text-decoration: underline dotted; text-underline-offset: 3px; }
|
|
824
|
+
.pane-mode:hover { background: var(--surface-raised); color: var(--ink); }
|
|
825
|
+
/* With a form in it the dock is as tall as the form needs, and the countdown sits at its top. */
|
|
826
|
+
.dock[data-ask="questions"], .dock[data-ask="plan"] { align-items: start; }
|
|
827
|
+
|
|
745
828
|
/* -------------------------------------------------------------- dock ---
|
|
746
829
|
The approval dock: a row under the canvas, there only while a tool call is
|
|
747
830
|
waiting. It is part of the stage's column, so the canvas above it stays
|
|
@@ -846,6 +929,27 @@ button.tl-lab { cursor: pointer; }
|
|
|
846
929
|
.cv-edge-label { font-size: 10px; fill: var(--ink-faint); pointer-events: none; }
|
|
847
930
|
/* "#3 · 09:14": where a card stands in the order the session called its agents. */
|
|
848
931
|
.cv-under { display: flex; align-items: baseline; min-width: 0; }
|
|
932
|
+
/* The model a card ran on: a small badge, whole. What follows it gives way. */
|
|
933
|
+
.cv-model {
|
|
934
|
+
flex: none; padding: 0 5px; border-radius: var(--radius-sm); background: var(--surface-raised);
|
|
935
|
+
font-size: 10px; line-height: 16px; color: var(--ink-dim); white-space: nowrap;
|
|
936
|
+
}
|
|
937
|
+
.cv-model[hidden] { display: none; }
|
|
938
|
+
.cv-model[data-differs="true"] { color: var(--status-waiting); }
|
|
939
|
+
.cv-third { gap: 6px; }
|
|
940
|
+
.cv-facts { flex: 1 1 auto; min-width: 0; font-size: 11px; line-height: 16px; color: var(--ink-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
941
|
+
.cv-totals { font-variant-numeric: tabular-nums; }
|
|
942
|
+
/* The same badge outside the graph: a Timeline row, a List row, a delegation card. */
|
|
943
|
+
.tl-model, .ls-model, .deleg-model {
|
|
944
|
+
flex: none; padding: 0 5px; margin-right: 6px; border-radius: var(--radius-sm); background: var(--surface-raised);
|
|
945
|
+
font-size: 10px; line-height: 16px; color: var(--ink-dim); white-space: nowrap;
|
|
946
|
+
}
|
|
947
|
+
.tl-model[hidden] { display: none; }
|
|
948
|
+
.imodel { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 12px; }
|
|
949
|
+
.imodel dt { color: var(--ink-faint); }
|
|
950
|
+
.imodel dd { margin: 0; word-break: break-word; }
|
|
951
|
+
.ilink { border: 0; padding: 0; background: none; font: inherit; color: var(--studio-accent); cursor: pointer; text-decoration: underline; }
|
|
952
|
+
.cv-mix { color: var(--ink-faint); }
|
|
849
953
|
.cv-under .cv-task { flex: 1 1 auto; min-width: 0; }
|
|
850
954
|
.cv-order { flex: none; margin-left: 6px; font-family: var(--font-mono); font-size: 10px; color: var(--ink-faint); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
851
955
|
.cv-order[hidden] { display: none; }
|
|
@@ -961,7 +1065,7 @@ button.tl-lab { cursor: pointer; }
|
|
|
961
1065
|
.cv-row { display: flex; align-items: center; gap: var(--space-2); min-width: 0; }
|
|
962
1066
|
.cv-fill { flex: 1; min-width: 0; }
|
|
963
1067
|
.cv-indent { padding-left: 32px; }
|
|
964
|
-
.cv-title { font-size: 13px; font-weight: 600; }
|
|
1068
|
+
.cv-title { font-size: 13px; font-weight: 600; flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
965
1069
|
.cv-name {
|
|
966
1070
|
min-width: 0; font-family: var(--font-mono); font-size: 12px; line-height: 16px; color: var(--ink);
|
|
967
1071
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
@@ -970,7 +1074,12 @@ button.tl-lab { cursor: pointer; }
|
|
|
970
1074
|
min-width: 0; font-size: 13px; line-height: 18px; color: var(--ink-dim);
|
|
971
1075
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
972
1076
|
}
|
|
973
|
-
.
|
|
1077
|
+
/* Nothing a card says may run out of it. Every piece of text in a card is one line that gives way with an
|
|
1078
|
+
ellipsis; the pieces that must stay whole (the status pill, the tile, the order tag) are short by construction
|
|
1079
|
+
and do not shrink. */
|
|
1080
|
+
.cv-sub { font-size: 12px; line-height: 16px; color: var(--ink-dim); white-space: nowrap; flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
1081
|
+
.cv-sub[hidden] { display: none; }
|
|
1082
|
+
.cv-node > .cv-row, .cv-node > .cv-under, .cv-node > .cv-name, .cv-node > .cv-sub { max-width: 100%; }
|
|
974
1083
|
.cv-word { font-size: 11px; color: var(--ink-dim); white-space: nowrap; flex: none; }
|
|
975
1084
|
|
|
976
1085
|
/* The tile says whose agent it is. Crewforth's mark keeps its own colours; the
|
|
@@ -983,15 +1092,6 @@ button.tl-lab { cursor: pointer; }
|
|
|
983
1092
|
}
|
|
984
1093
|
.cv-tile svg { width: 64%; height: 64%; display: block; }
|
|
985
1094
|
.cv-tile-sm { width: 18px; height: 18px; }
|
|
986
|
-
/* A type nobody declared is marked as one. It is not quietly drawn as built-in. */
|
|
987
|
-
.cv-tile[data-source="unknown"]::after {
|
|
988
|
-
content: '?';
|
|
989
|
-
position: absolute; right: -5px; top: -5px;
|
|
990
|
-
width: 12px; height: 12px; border-radius: var(--radius-pill);
|
|
991
|
-
display: flex; align-items: center; justify-content: center;
|
|
992
|
-
background: var(--surface); border: 1px solid var(--line);
|
|
993
|
-
font-family: var(--font-mono); font-size: 9px; line-height: 1; color: var(--ink-dim);
|
|
994
|
-
}
|
|
995
1095
|
|
|
996
1096
|
.cv-pill {
|
|
997
1097
|
display: inline-flex; align-items: center; gap: 5px; flex: none;
|
|
@@ -1006,6 +1106,8 @@ button.tl-lab { cursor: pointer; }
|
|
|
1006
1106
|
.cv-node.cv-chip { flex-direction: row; align-items: center; gap: var(--space-2); padding: 0 10px; border-radius: 8px; }
|
|
1007
1107
|
.cv-chip .cv-name { flex: 0 1 auto; }
|
|
1008
1108
|
.cv-chip .cv-task { flex: 1 1 0; font-size: 12px; }
|
|
1109
|
+
/* On a chip the model is said in full and does not shrink; the task beside it does. */
|
|
1110
|
+
.cv-chip .cv-model { flex: none; }
|
|
1009
1111
|
|
|
1010
1112
|
/* The session: where everything starts. */
|
|
1011
1113
|
.cv-session { cursor: pointer; }
|
|
@@ -1090,10 +1192,27 @@ button.tl-lab { cursor: pointer; }
|
|
|
1090
1192
|
}
|
|
1091
1193
|
.cv-legend[hidden] { display: none; }
|
|
1092
1194
|
/* The session's summary is the same kind of box, in the opposite corner. */
|
|
1093
|
-
.cv-spend { right: auto; bottom: auto; left: var(--space-6); top: var(--space-6); width:
|
|
1094
|
-
.cv-spend-rows {
|
|
1095
|
-
.cv-spend-
|
|
1096
|
-
|
|
1195
|
+
.cv-spend { right: auto; bottom: auto; left: var(--space-6); top: var(--space-6); width: 240px; }
|
|
1196
|
+
.cv-spend-rows { display: flex; flex-direction: column; gap: 2px; font-size: 12px; min-width: 0; }
|
|
1197
|
+
.cv-spend-row {
|
|
1198
|
+
display: flex; align-items: baseline; gap: var(--space-3); min-width: 0; width: 100%;
|
|
1199
|
+
padding: 2px 4px; margin: 0 -4px; border: 0; border-radius: var(--radius-sm);
|
|
1200
|
+
background: none; font: inherit; color: inherit; text-align: left; cursor: default;
|
|
1201
|
+
}
|
|
1202
|
+
.cv-spend-label { flex: 1 1 auto; min-width: 0; color: var(--ink-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1203
|
+
.cv-spend-value { flex: none; font-family: var(--font-mono); font-variant-numeric: tabular-nums; color: var(--ink); white-space: nowrap; }
|
|
1204
|
+
/* A row that opens: it looks like something to press, and says so with a mark that turns. */
|
|
1205
|
+
.cv-spend-more { cursor: pointer; }
|
|
1206
|
+
.cv-spend-more:hover { background: var(--surface-raised); }
|
|
1207
|
+
.cv-spend-more .cv-spend-label::before { content: '\203A'; display: inline-block; width: 10px; color: var(--ink-faint); }
|
|
1208
|
+
.cv-spend-more[aria-expanded="true"] .cv-spend-label::before { rotate: 90deg; }
|
|
1209
|
+
.cv-spend-detail {
|
|
1210
|
+
margin: 0 0 var(--space-1) 10px; padding-left: var(--space-2); border-left: 1px solid var(--line);
|
|
1211
|
+
display: grid; grid-template-columns: 1fr auto; gap: 2px var(--space-3);
|
|
1212
|
+
}
|
|
1213
|
+
.cv-spend-detail[hidden] { display: none; }
|
|
1214
|
+
.cv-spend-detail dt { color: var(--ink-faint); }
|
|
1215
|
+
.cv-spend-detail dd { margin: 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; color: var(--ink-dim); }
|
|
1097
1216
|
.cv-legend-head { display: flex; align-items: center; justify-content: space-between; }
|
|
1098
1217
|
.cv-legend-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-1) var(--space-2); }
|
|
1099
1218
|
.cv-legend-item { display: flex; align-items: center; gap: var(--space-2); font-size: 12px; color: var(--ink-dim); white-space: nowrap; }
|
|
@@ -108,6 +108,14 @@ export function lanes(bars) {
|
|
|
108
108
|
return out[1].length ? out : [out[0]];
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Which groups are folded. A group is as the viewer left it (`hand`: id -> folded); one they have not touched
|
|
113
|
+
* follows the last "Expand all" (`all` true) or "Fold all" (`all` false), and before either it is open.
|
|
114
|
+
*/
|
|
115
|
+
export function foldedSet(ids, hand = new Map(), all = null) {
|
|
116
|
+
return new Set(ids.filter((id) => (hand.has(id) ? hand.get(id) : all === false)));
|
|
117
|
+
}
|
|
118
|
+
|
|
111
119
|
/** The groups of a session's agents under one grouping, in the order they first started. */
|
|
112
120
|
export function groupsOf(nodes, grouping) {
|
|
113
121
|
const agents = nodes.filter((n) => n.kind === 'agent');
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
// Which rows there are, where a bar starts and ends and what it says is decided in timeline-plan.js. This file
|
|
4
4
|
// draws that, and hands back what the viewer did: a row chosen, a group folded, the view moved.
|
|
5
5
|
import {
|
|
6
|
-
rows, groupsOf, extent, windowOf, place, ticks, clock, stepRange, waitNote, factsOf, xOf, RANGES, DEFAULT_RANGE,
|
|
6
|
+
rows, groupsOf, foldedSet, extent, windowOf, place, ticks, clock, stepRange, waitNote, factsOf, xOf, RANGES, DEFAULT_RANGE,
|
|
7
7
|
} from './timeline-plan.js';
|
|
8
8
|
import { Press } from './press.js';
|
|
9
|
-
import { shownType, hoverOf } from './names.js';
|
|
9
|
+
import { shownType, hoverOf, modelOf, modelFamily } from './names.js';
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
function mk(tag, cls, text) {
|
|
@@ -39,7 +39,8 @@ export class Timeline {
|
|
|
39
39
|
this.range = DEFAULT_RANGE;
|
|
40
40
|
this.follow = true;
|
|
41
41
|
this.end = null; // where the view ends when it is not following
|
|
42
|
-
this.
|
|
42
|
+
this.fold = new Map(); // group id -> folded, the viewer's own choices
|
|
43
|
+
this.all = null; // true after Expand all, false after Fold all: what an untouched group is
|
|
43
44
|
this.expanded = new Set();
|
|
44
45
|
this.waiting = new Set();
|
|
45
46
|
this.filter = null; // Set of statuses, or null
|
|
@@ -103,7 +104,8 @@ export class Timeline {
|
|
|
103
104
|
if (id === this.session) return;
|
|
104
105
|
this.session = id;
|
|
105
106
|
this.nodes = [];
|
|
106
|
-
this.
|
|
107
|
+
this.fold = new Map(); // group id -> folded, the viewer's own choices
|
|
108
|
+
this.all = null; // true after Expand all, false after Fold all: what an untouched group is
|
|
107
109
|
this.expanded = new Set();
|
|
108
110
|
this.selected = null;
|
|
109
111
|
this.follow = true;
|
|
@@ -113,9 +115,24 @@ export class Timeline {
|
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
setNodes(nodes) { this.nodes = nodes ?? []; this.render(); }
|
|
116
|
-
setGroup(g) { if (g === this.group) return; this.group = g; this.
|
|
118
|
+
setGroup(g) { if (g === this.group) return; this.group = g; this.fold = new Map(); this.all = null; this.expanded = new Set(); this.render(); }
|
|
119
|
+
|
|
120
|
+
/** Open every group and show every row of the long ones; a group that arrives later arrives open. */
|
|
121
|
+
expandAll() { this.all = true; this.fold = new Map(); this.render(); this.#changed(); }
|
|
122
|
+
/** Fold every group; one that arrives later arrives folded. */
|
|
123
|
+
foldAll() { this.all = false; this.fold = new Map(); this.expanded = new Set(); this.render(); this.#changed(); }
|
|
124
|
+
/** Whether there is anything left for each of the two to do. */
|
|
125
|
+
foldState() {
|
|
126
|
+
const heads = (this.lastRows ?? []).filter((r) => r.kind === 'group');
|
|
127
|
+
return {
|
|
128
|
+
groups: heads.length,
|
|
129
|
+
canExpand: heads.some((r) => r.folded) || (this.lastRows ?? []).some((r) => r.kind === 'more'),
|
|
130
|
+
canFold: heads.some((r) => !r.folded),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
117
133
|
setWaiting(ids) { this.waiting = new Set(ids ?? []); this.render(); }
|
|
118
134
|
setFilter(statuses, { keepWaiting = false } = {}) { this.filter = statuses ? new Set(statuses) : null; this.keepWaiting = keepWaiting; this.render(); }
|
|
135
|
+
setModelFilter(family) { this.modelFilter = family ?? null; this.render(); }
|
|
119
136
|
setOwned(summary) { this.owned = summary ?? null; this.render(); }
|
|
120
137
|
setLive(live) { if (live === this.live) return; this.live = live; this.render(); this.#changed(); }
|
|
121
138
|
|
|
@@ -150,7 +167,7 @@ export class Timeline {
|
|
|
150
167
|
if (id) {
|
|
151
168
|
for (const g of groupsOf(this.nodes, this.group)) {
|
|
152
169
|
if (!g.members.some((m) => m.id === id)) continue;
|
|
153
|
-
this.
|
|
170
|
+
this.fold.set(g.id, false);
|
|
154
171
|
this.expanded.add(g.id);
|
|
155
172
|
}
|
|
156
173
|
}
|
|
@@ -159,6 +176,7 @@ export class Timeline {
|
|
|
159
176
|
}
|
|
160
177
|
|
|
161
178
|
dimmed(n) {
|
|
179
|
+
if (this.modelFilter && modelFamily(n.model) !== this.modelFilter) return true;
|
|
162
180
|
if (!this.filter) return false;
|
|
163
181
|
if (this.keepWaiting && this.waiting.has(n.id)) return false;
|
|
164
182
|
return !this.filter.has(n.status);
|
|
@@ -210,10 +228,12 @@ export class Timeline {
|
|
|
210
228
|
// The rows are drawn again from the data each time; a keyboard reader's place among them is kept.
|
|
211
229
|
const focused = this.rowsEl.contains?.(document.activeElement) ? document.activeElement.dataset?.key ?? null : null;
|
|
212
230
|
const top = this.body.scrollTop;
|
|
231
|
+
const ids = groupsOf(this.nodes, this.group).map((g) => g.id);
|
|
213
232
|
const list = rows(this.nodes, {
|
|
214
|
-
group: this.group, folded: this.
|
|
233
|
+
group: this.group, folded: foldedSet(ids, this.fold, this.all), expanded: this.all === true ? new Set(ids) : this.expanded, waiting: this.waiting,
|
|
215
234
|
approvals: this.owned?.approvals ?? null, now,
|
|
216
235
|
});
|
|
236
|
+
this.lastRows = list;
|
|
217
237
|
this.rowsEl.replaceChildren(...list.map((r) => this.#row(r, win, width)));
|
|
218
238
|
this.body.scrollTop = top;
|
|
219
239
|
if (focused) [...this.rowsEl.querySelectorAll('[data-key]')].find((el) => el.dataset.key === focused)?.focus();
|
|
@@ -303,8 +323,9 @@ export class Timeline {
|
|
|
303
323
|
chev.innerHTML = '<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M6 4l4 4-4 4"/></svg>';
|
|
304
324
|
lab.append(chev, mk('span', 'tl-name', r.label), mk('span', 'row-fill'), mk('span', 'sub', r.sub));
|
|
305
325
|
lab.addEventListener('click', () => {
|
|
306
|
-
|
|
326
|
+
this.fold.set(r.id, !r.folded);
|
|
307
327
|
this.render();
|
|
328
|
+
this.#changed();
|
|
308
329
|
});
|
|
309
330
|
this.#bars(track, r.lanes, win, width);
|
|
310
331
|
} else if (r.kind === 'merged') {
|
|
@@ -320,8 +341,13 @@ export class Timeline {
|
|
|
320
341
|
if (tile) lab.append(tile);
|
|
321
342
|
const sub = mk('span', 'sub', r.sub);
|
|
322
343
|
if (r.tone) sub.dataset.tone = r.tone;
|
|
323
|
-
|
|
324
|
-
|
|
344
|
+
// The model the row's agent ran on, before its state.
|
|
345
|
+
const mdl = modelOf(r.node);
|
|
346
|
+
const badge = mk('span', 'tl-model', `${mdl.ran}${mdl.differs ? ' \u2260' : ''}`);
|
|
347
|
+
badge.hidden = !mdl.ran;
|
|
348
|
+
badge.title = mdl.title;
|
|
349
|
+
lab.append(mk('span', 'tl-name', r.label), mk('span', 'row-fill'), badge, sub);
|
|
350
|
+
lab.title = [hoverOf(r.node), mdl.ran ? mdl.title : null].filter(Boolean).join('\n');
|
|
325
351
|
// One listener, on the row: the label is a button inside it, and its click arrives here too. Two
|
|
326
352
|
// listeners chose the agent and un-chose it in the same click.
|
|
327
353
|
row.addEventListener('click', () => this.#choose(r.id));
|
package/kit/studio/web/usage.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// "~", and a note that says what it is. A session with tokens from a model the price table does not have gets
|
|
6
6
|
// a dash, not a smaller number.
|
|
7
7
|
|
|
8
|
+
import { modelName } from './names.js';
|
|
9
|
+
|
|
8
10
|
export const COST_NOTE = 'Estimated at API list price; on a subscription it is not what you are billed';
|
|
9
11
|
export const NO_COST = '—';
|
|
10
12
|
|
|
@@ -54,24 +56,63 @@ export function costNote(u) {
|
|
|
54
56
|
return `No estimate: the price table has no row for ${u.unpriced.join(', ')}`;
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
/**
|
|
58
|
-
export function
|
|
59
|
+
/** "40s", "4m 40s", "3h 5m": a span that is being counted, so the seconds show while they matter. */
|
|
60
|
+
export function fmtRunning(ms) {
|
|
61
|
+
if (ms == null || !Number.isFinite(ms) || ms < 0) return null;
|
|
62
|
+
const s = Math.floor(ms / 1000);
|
|
63
|
+
if (s < 60) return `${s}s`;
|
|
64
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
65
|
+
return fmtSpan(ms);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The navigator's one line: time · new tokens · ~cost. Null when nothing was read.
|
|
70
|
+
* @param live the session is running: its time is counted from its first record to now
|
|
71
|
+
*/
|
|
72
|
+
export function usageLine(u, { live = false, now = null } = {}) {
|
|
59
73
|
if (!u) return null;
|
|
60
74
|
const t = timeOf(u);
|
|
61
|
-
const
|
|
62
|
-
|
|
75
|
+
const span = live && u.startedAt != null && now != null ? fmtSpan(now - u.startedAt) : (t ? fmtSpan(t.ms) : null);
|
|
76
|
+
const tokens = typeof u.tokens === 'number' ? u.tokens : u.tokens?.fresh;
|
|
77
|
+
return [span, tokens ? fmtCount(tokens) : null, tokens ? fmtCost(u.cost) : null].filter(Boolean).join(' · ') || null;
|
|
63
78
|
}
|
|
64
79
|
|
|
65
|
-
|
|
66
|
-
|
|
80
|
+
const kinds = (k, cached = true) => [
|
|
81
|
+
['Input', fmtCount(k?.input ?? 0)], ['Output', fmtCount(k?.output ?? 0)], ['Cache write', fmtCount(k?.cacheWrite ?? 0)],
|
|
82
|
+
...(cached ? [['Cache read', fmtCount(k?.cacheRead ?? 0)]] : []),
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The summary box's rows: { label, value, note, detail }. `detail` is what a click on the row opens: the same
|
|
87
|
+
* number broken down, as [name, value] pairs.
|
|
88
|
+
*
|
|
89
|
+
* "Tokens" is what was new: input, output and cache writes. What was read back from the cache is its own row —
|
|
90
|
+
* it is the same context counted again on every response, and added in it made a session that had just opened
|
|
91
|
+
* read as millions.
|
|
92
|
+
*
|
|
93
|
+
* @param opts.live the session is running: its time is counted from its first record to `opts.now`
|
|
94
|
+
*/
|
|
95
|
+
export function summaryRows(u, { live = false, now = null } = {}) {
|
|
67
96
|
if (!u) return [];
|
|
68
|
-
const t = timeOf(u);
|
|
69
97
|
const k = u.tokens ?? {};
|
|
70
98
|
const rows = [];
|
|
71
|
-
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
99
|
+
const t = timeOf(u);
|
|
100
|
+
if (live && u.startedAt != null && now != null) {
|
|
101
|
+
rows.push({ label: 'Running for', value: fmtRunning(now - u.startedAt), note: 'From the session\'s first record to now' });
|
|
102
|
+
} else if (t) {
|
|
103
|
+
rows.push({ label: t.kind === 'worked' ? 'Working time' : 'First to last record', value: fmtSpan(t.ms), note: t.note });
|
|
104
|
+
if (t.kind === 'worked' && u.durationMs != null) rows.push({ label: 'First to last record', value: fmtSpan(u.durationMs), note: 'Pauses included; the agents\' transcripts too' });
|
|
105
|
+
}
|
|
106
|
+
rows.push({ label: 'Tokens', value: fmtCount(k.fresh ?? 0), note: 'New tokens: input, output and cache writes. Agents included, each response counted once', detail: kinds(k, false) });
|
|
107
|
+
rows.push({ label: 'From cache', value: fmtCount(k.cacheRead ?? 0), note: 'Read back from the prompt cache: the same context, counted on every response that reused it' });
|
|
108
|
+
const part = (label, p, note) => {
|
|
109
|
+
if (!p?.tokens?.total) return;
|
|
110
|
+
rows.push({ label, value: `${fmtCount(p.tokens.fresh)} · ${fmtCost(p.cost)}`, note: `${note}: new tokens and estimated cost\n${costNote(p)}`, detail: kinds(p.tokens) });
|
|
111
|
+
};
|
|
112
|
+
part('Session', u.parts?.session, 'The session\'s own transcript');
|
|
113
|
+
part('Agents', u.parts?.agents, 'Everything its agents did');
|
|
114
|
+
// Behind the cost: each model's new tokens and what they come to. A model with no price says so with a dash.
|
|
115
|
+
const perModel = Object.entries(u.byModel ?? {}).map(([model, t]) => [modelName(model), `${fmtCount(t.tokens.fresh)} \u00b7 ${fmtCost(t.cost)}`]);
|
|
116
|
+
rows.push({ label: 'Cost', value: k.total ? fmtCost(u.cost) : NO_COST, note: costNote(u), detail: perModel.length ? perModel : undefined });
|
|
76
117
|
return rows;
|
|
77
118
|
}
|
package/package.json
CHANGED