@rulvar/rulvar 1.20.0 → 1.22.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.
- package/README.md +10 -1
- package/dist/index.d.ts +65 -2
- package/dist/index.js +598 -2
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -2,11 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
The batteries-included Rulvar install: re-exports the entire
|
|
4
4
|
`@rulvar/core` surface plus both first-class adapters (`anthropic`,
|
|
5
|
-
`openai`),
|
|
5
|
+
`openai`), two terminal progress renderers, and `recommendedDefaults`,
|
|
6
6
|
the only place the project names strong default models for the
|
|
7
7
|
orchestrate and plan roles. Also installable through the unscoped alias
|
|
8
8
|
package `rulvar`, which re-exports this one.
|
|
9
9
|
|
|
10
|
+
The renderers are `progress()`, the live view (one row per agent with a
|
|
11
|
+
status glyph, a running timer, token counts, and USD, per-role
|
|
12
|
+
sub-timings when one call spans several invocation phases, the run
|
|
13
|
+
header with spend against the ceiling, and a final per-role cost
|
|
14
|
+
summary; repaints in place on a TTY and degrades to append-only lines in
|
|
15
|
+
pipes and CI), and `renderProgress()`, the minimal one line per
|
|
16
|
+
lifecycle fact. Both consume the public `WorkflowEvent` stream and
|
|
17
|
+
nothing else.
|
|
18
|
+
|
|
10
19
|
Part of [Rulvar](https://rulvar.com), an embeddable TypeScript engine
|
|
11
20
|
for durable, budget-bounded multi-agent LLM workflows, where a completed
|
|
12
21
|
LLM call is never paid for twice. Full documentation:
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { InvocationRole, ModelSpec, QualityFloors, WorkflowEvent } from "@rulvar/core";
|
|
1
|
+
import { InvocationRole, ModelSpec, QualityFloors, RunHandle, WorkflowEvent } from "@rulvar/core";
|
|
2
2
|
import { ANTHROPIC_MODELS, AnthropicAdapterOptions, anthropic } from "@rulvar/anthropic";
|
|
3
3
|
import { OPENAI_MODELS, OpenAiAdapterOptions, openai } from "@rulvar/openai";
|
|
4
4
|
export * from "@rulvar/core";
|
|
@@ -16,6 +16,69 @@ interface RenderProgressOptions {
|
|
|
16
16
|
*/
|
|
17
17
|
declare function renderProgress(events: AsyncIterable<WorkflowEvent>, options?: RenderProgressOptions): Promise<void>;
|
|
18
18
|
//#endregion
|
|
19
|
+
//#region src/live-progress.d.ts
|
|
20
|
+
/** Raw output sink; chunks may contain ANSI and partial lines. */
|
|
21
|
+
interface ProgressSink {
|
|
22
|
+
write(chunk: string): void;
|
|
23
|
+
isTTY?: boolean;
|
|
24
|
+
columns?: number;
|
|
25
|
+
rows?: number;
|
|
26
|
+
}
|
|
27
|
+
/** Injectable time source; every() returns a cancel function. */
|
|
28
|
+
interface ProgressClock {
|
|
29
|
+
now(): number;
|
|
30
|
+
every(ms: number, fn: () => void): () => void;
|
|
31
|
+
}
|
|
32
|
+
type ProgressMode = "auto" | "tty" | "lines" | "off";
|
|
33
|
+
interface ProgressOptions {
|
|
34
|
+
/** Defaults to process.stderr so application stdout stays clean. */
|
|
35
|
+
sink?: ProgressSink;
|
|
36
|
+
/** Defaults to a monotonic clock (performance.now) plus setInterval. */
|
|
37
|
+
clock?: ProgressClock;
|
|
38
|
+
/**
|
|
39
|
+
* 'auto' (default) picks 'tty' when the sink reports a TTY and the
|
|
40
|
+
* environment is not CI or TERM=dumb, else 'lines'.
|
|
41
|
+
*/
|
|
42
|
+
mode?: ProgressMode;
|
|
43
|
+
/** Repaints per second in tty mode, clamped to 1..30. Default 10. */
|
|
44
|
+
fps?: number;
|
|
45
|
+
/** SGR colors. Default: true in tty mode unless NO_COLOR is set. */
|
|
46
|
+
color?: boolean;
|
|
47
|
+
/** Column override. Default sink.columns, else 80. */
|
|
48
|
+
width?: number;
|
|
49
|
+
/** Body rows before the oldest completed rows collapse. Default 24. */
|
|
50
|
+
maxRows?: number;
|
|
51
|
+
/** Header title. Default: the workflow name from run:start. */
|
|
52
|
+
title?: string;
|
|
53
|
+
}
|
|
54
|
+
interface ProgressHandle {
|
|
55
|
+
/** The resolved mode after auto detection. */
|
|
56
|
+
readonly mode: "tty" | "lines" | "off";
|
|
57
|
+
/** Settles after the final frame is written; never rejects. */
|
|
58
|
+
readonly done: Promise<void>;
|
|
59
|
+
/** Force an immediate repaint outside the tick (tests, custom pacing). */
|
|
60
|
+
render(): void;
|
|
61
|
+
/**
|
|
62
|
+
* Idempotent. final=true (default) paints the settle frame; false
|
|
63
|
+
* freezes the current frame in scrollback. Always restores the cursor
|
|
64
|
+
* and resolves `done`.
|
|
65
|
+
*/
|
|
66
|
+
stop(final?: boolean): void;
|
|
67
|
+
}
|
|
68
|
+
type ProgressSource = RunHandle<unknown> | Promise<RunHandle<unknown>> | AsyncIterable<WorkflowEvent>;
|
|
69
|
+
/**
|
|
70
|
+
* Attaches a live progress view to a run and returns its handle. Accepts
|
|
71
|
+
* a RunHandle (subscribes through `on()`, leaving `handle.events` free
|
|
72
|
+
* for the host, and enriches the final frame from `RunOutcome.cost`;
|
|
73
|
+
* `orchestrate` returns exactly such a handle, so
|
|
74
|
+
* `progress(orchestrate(...))` composes directly), a promise resolving
|
|
75
|
+
* to a handle (for wrappers that construct one asynchronously), or a
|
|
76
|
+
* raw WorkflowEvent iterable (the gapless path for resumes:
|
|
77
|
+
* `progress(resumed.events)`; note it consumes that one-shot iterable).
|
|
78
|
+
* The view auto-stops when the run settles.
|
|
79
|
+
*/
|
|
80
|
+
declare function progress(source: ProgressSource, options?: ProgressOptions): ProgressHandle;
|
|
81
|
+
//#endregion
|
|
19
82
|
//#region src/defaults.d.ts
|
|
20
83
|
/**
|
|
21
84
|
* Drop-in engine defaults: `createEngine({ ..., defaults: { routing:
|
|
@@ -31,4 +94,4 @@ declare const recommendedDefaults: {
|
|
|
31
94
|
floors: QualityFloors;
|
|
32
95
|
};
|
|
33
96
|
//#endregion
|
|
34
|
-
export { ANTHROPIC_MODELS, type AnthropicAdapterOptions, OPENAI_MODELS, type OpenAiAdapterOptions, type RenderProgressOptions, anthropic, openai, recommendedDefaults, renderProgress };
|
|
97
|
+
export { ANTHROPIC_MODELS, type AnthropicAdapterOptions, OPENAI_MODELS, type OpenAiAdapterOptions, type ProgressClock, type ProgressHandle, type ProgressMode, type ProgressOptions, type ProgressSink, type ProgressSource, type RenderProgressOptions, anthropic, openai, progress, recommendedDefaults, renderProgress };
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
|
+
import { sanitizeTerminalText } from "@rulvar/core";
|
|
1
2
|
import { ANTHROPIC_MODELS, anthropic } from "@rulvar/anthropic";
|
|
2
3
|
import { OPENAI_MODELS, openai } from "@rulvar/openai";
|
|
3
4
|
export * from "@rulvar/core";
|
|
4
5
|
//#region src/render-progress.ts
|
|
6
|
+
/**
|
|
7
|
+
* Minimal terminal progress renderer (M1-T10): consumes the WorkflowEvent
|
|
8
|
+
* stream of a RunHandle and writes one line per lifecycle fact. Plain
|
|
9
|
+
* lines, no cursor control: readable in CI logs and pipes as well as TTYs.
|
|
10
|
+
*
|
|
11
|
+
* Event stream contract: https://docs.rulvar.com/guide/observability
|
|
12
|
+
* (the terminal progress renderer is one of the four stream consumers).
|
|
13
|
+
*
|
|
14
|
+
* Every emitted line passes through the shared terminal sanitizer before
|
|
15
|
+
* it reaches the sink, so an untrusted provider/tool/log string can never
|
|
16
|
+
* inject a control sequence or a second physical line (v1.21.0 review
|
|
17
|
+
* P2-1).
|
|
18
|
+
*/
|
|
5
19
|
function usd(amount) {
|
|
6
20
|
return `${amount.toFixed(4)} USD`;
|
|
7
21
|
}
|
|
@@ -10,9 +24,10 @@ function usd(amount) {
|
|
|
10
24
|
* the final run:end line.
|
|
11
25
|
*/
|
|
12
26
|
async function renderProgress(events, options) {
|
|
13
|
-
const
|
|
27
|
+
const sink = options?.write ?? ((line) => {
|
|
14
28
|
process.stderr.write(`${line}\n`);
|
|
15
29
|
});
|
|
30
|
+
const write = (line) => sink(sanitizeTerminalText(line));
|
|
16
31
|
const logs = options?.logs ?? true;
|
|
17
32
|
for await (const event of events) switch (event.type) {
|
|
18
33
|
case "run:start":
|
|
@@ -49,6 +64,587 @@ async function renderProgress(events, options) {
|
|
|
49
64
|
}
|
|
50
65
|
}
|
|
51
66
|
//#endregion
|
|
67
|
+
//#region src/live-progress.ts
|
|
68
|
+
/**
|
|
69
|
+
* Live terminal progress view (v1.21.0): a claude-workflows-style tree
|
|
70
|
+
* over the WorkflowEvent stream, one row per agent with a status glyph,
|
|
71
|
+
* a running timer, token counts, and USD, plus per-role sub-timings when
|
|
72
|
+
* one agent call spans several invocation phases (loop, summarize,
|
|
73
|
+
* finalize, extract). The minimal line-per-event `renderProgress` stays
|
|
74
|
+
* untouched next door; this renderer is the rich, cursor-addressed
|
|
75
|
+
* sibling with an append-only fallback for pipes and CI.
|
|
76
|
+
*
|
|
77
|
+
* Honesty rules inherited from the event contract
|
|
78
|
+
* (https://docs.rulvar.com/guide/observability): exact token counts
|
|
79
|
+
* exist only at `agent:end`, so running rows show elapsed time and a
|
|
80
|
+
* tilde-marked character estimate from `agent:stream` deltas; run-level
|
|
81
|
+
* USD is live through `budget:update`; per-role dollars appear in the
|
|
82
|
+
* final summary only when the source is a RunHandle (they come from
|
|
83
|
+
* `RunOutcome.cost.byRole`, not from any event). Replayed lifecycle
|
|
84
|
+
* events render dim with a `replay` tag, never spin, and never add to
|
|
85
|
+
* totals: the authoritative money numbers are `budget:update.spentUsd`
|
|
86
|
+
* and `run:end.totalUsd`, which are immune to replay double counting.
|
|
87
|
+
*
|
|
88
|
+
* The reducer is defensive by contract: unknown event types are ignored
|
|
89
|
+
* and every dynamic field, including the required ones, is read
|
|
90
|
+
* defensively (optional chaining with fallbacks), so a malformed event
|
|
91
|
+
* degrades a row rather than throwing and stopping the view; an unknown
|
|
92
|
+
* parent span attaches at the root, and a mid-run attach synthesizes a
|
|
93
|
+
* root instead of failing.
|
|
94
|
+
*/
|
|
95
|
+
/**
|
|
96
|
+
* Positive-integer option normalization (v1.21.0 review P3-2): a
|
|
97
|
+
* non-finite or below-minimum caller value falls back rather than
|
|
98
|
+
* poisoning the geometry (a NaN width breaks the clip, a NaN fps yields
|
|
99
|
+
* a NaN interval). Fractions floor.
|
|
100
|
+
*/
|
|
101
|
+
function posIntOption(value, fallback, min) {
|
|
102
|
+
if (value === void 0 || !Number.isFinite(value)) return fallback;
|
|
103
|
+
return Math.max(min, Math.floor(value));
|
|
104
|
+
}
|
|
105
|
+
function fmtDuration(ms) {
|
|
106
|
+
if (!Number.isFinite(ms) || ms < 0) ms = 0;
|
|
107
|
+
const s = ms / 1e3;
|
|
108
|
+
if (s < 10) return `${s.toFixed(1)}s`;
|
|
109
|
+
if (s < 60) return `${String(Math.floor(s))}s`;
|
|
110
|
+
const minutes = Math.floor(s / 60);
|
|
111
|
+
if (minutes < 60) return `${String(minutes)}m ${String(Math.floor(s % 60)).padStart(2, "0")}s`;
|
|
112
|
+
return `${String(Math.floor(minutes / 60))}h ${String(minutes % 60).padStart(2, "0")}m`;
|
|
113
|
+
}
|
|
114
|
+
function fmtTokens(count) {
|
|
115
|
+
if (count < 1e3) return String(count);
|
|
116
|
+
if (count < 1e4) return `${(count / 1e3).toFixed(1)}k`;
|
|
117
|
+
if (count < 1e6) return `${String(Math.round(count / 1e3))}k`;
|
|
118
|
+
return `${(count / 1e6).toFixed(1)}M`;
|
|
119
|
+
}
|
|
120
|
+
function fmtUsd(amount) {
|
|
121
|
+
if (amount === 0) return "$0";
|
|
122
|
+
if (amount >= 100) return `$${String(Math.round(amount))}`;
|
|
123
|
+
if (amount >= 1) return `$${amount.toFixed(2)}`;
|
|
124
|
+
if (amount >= .01) return `$${amount.toFixed(3)}`;
|
|
125
|
+
return `$${amount.toFixed(4)}`;
|
|
126
|
+
}
|
|
127
|
+
function bar(spent, ceiling, cells) {
|
|
128
|
+
const ratio = ceiling <= 0 ? 1 : Math.min(1, spent / ceiling);
|
|
129
|
+
const filled = Math.round(ratio * cells);
|
|
130
|
+
return "#".repeat(filled) + ".".repeat(cells - filled);
|
|
131
|
+
}
|
|
132
|
+
const SPINNER = [
|
|
133
|
+
"|",
|
|
134
|
+
"/",
|
|
135
|
+
"-",
|
|
136
|
+
"\\"
|
|
137
|
+
];
|
|
138
|
+
const SGR_STRIP = /* @__PURE__ */ new RegExp("\\u001B\\[[0-9;]*m", "gu");
|
|
139
|
+
function newState(title) {
|
|
140
|
+
return {
|
|
141
|
+
...title === void 0 ? {} : { title },
|
|
142
|
+
resumed: false,
|
|
143
|
+
status: "running",
|
|
144
|
+
spentUsd: 0,
|
|
145
|
+
nodes: /* @__PURE__ */ new Map(),
|
|
146
|
+
roots: [],
|
|
147
|
+
notices: [],
|
|
148
|
+
wakes: 0,
|
|
149
|
+
admitted: 0,
|
|
150
|
+
rejected: 0,
|
|
151
|
+
dirty: true
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function nodeOf(state, event, kind, title) {
|
|
155
|
+
const existing = state.nodes.get(event.spanId);
|
|
156
|
+
if (existing !== void 0) return existing;
|
|
157
|
+
const node = {
|
|
158
|
+
spanId: event.spanId,
|
|
159
|
+
...event.parentSpanId === void 0 ? {} : { parentSpanId: event.parentSpanId },
|
|
160
|
+
kind,
|
|
161
|
+
title,
|
|
162
|
+
status: "running",
|
|
163
|
+
replayed: event.replayed === true,
|
|
164
|
+
roles: [],
|
|
165
|
+
streamedChars: 0,
|
|
166
|
+
toolCount: 0,
|
|
167
|
+
children: [],
|
|
168
|
+
startedAt: 0
|
|
169
|
+
};
|
|
170
|
+
state.nodes.set(event.spanId, node);
|
|
171
|
+
const parent = event.parentSpanId === void 0 || event.parentSpanId === event.spanId ? void 0 : state.nodes.get(event.parentSpanId);
|
|
172
|
+
if (parent !== void 0) parent.children.push(event.spanId);
|
|
173
|
+
else state.roots.push(event.spanId);
|
|
174
|
+
return node;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Untrusted wire strings (model ids, tool names, error messages, log
|
|
178
|
+
* text, workflow and label metadata) may carry control characters and
|
|
179
|
+
* escape sequences that break the repaint arithmetic or leak terminal
|
|
180
|
+
* control. The shared core sanitizer strips C0, DEL, C1, and whole
|
|
181
|
+
* ESC-initiated CSI/OSC/DCS sequences before interpolation; the
|
|
182
|
+
* renderer's own SGR is added by paint() afterward (v1.21.0 review
|
|
183
|
+
* P2-1).
|
|
184
|
+
*/
|
|
185
|
+
const scrub = sanitizeTerminalText;
|
|
186
|
+
function agentTitle(event) {
|
|
187
|
+
const base = event.agentType === void 0 || event.agentType === "" ? "agent" : scrub(event.agentType);
|
|
188
|
+
return event.label === void 0 || event.label === "" ? base : `${base} (${scrub(event.label)})`;
|
|
189
|
+
}
|
|
190
|
+
/** The tool event's span is the agent's own span or a child of it. */
|
|
191
|
+
function toolTarget(state, event) {
|
|
192
|
+
return state.nodes.get(event.spanId) ?? (event.parentSpanId === void 0 ? void 0 : state.nodes.get(event.parentSpanId));
|
|
193
|
+
}
|
|
194
|
+
function applyEvent(state, event, now) {
|
|
195
|
+
state.dirty = true;
|
|
196
|
+
switch (event.type) {
|
|
197
|
+
case "run:start":
|
|
198
|
+
state.runId = event.runId;
|
|
199
|
+
state.resumed = event.resumed === true;
|
|
200
|
+
state.startedAt ??= now;
|
|
201
|
+
state.title ??= scrub(event.workflow);
|
|
202
|
+
break;
|
|
203
|
+
case "run:end":
|
|
204
|
+
state.status = event.status;
|
|
205
|
+
state.totalUsd = event.totalUsd;
|
|
206
|
+
state.endedAt = now;
|
|
207
|
+
for (const node of state.nodes.values()) {
|
|
208
|
+
if (node.endedAt === void 0 && node.kind !== "phase") {
|
|
209
|
+
node.endedAt = now;
|
|
210
|
+
if (node.status === "running" || node.status === "queued") node.status = "interrupted";
|
|
211
|
+
}
|
|
212
|
+
const open = node.roles.at(-1);
|
|
213
|
+
if (open !== void 0 && open.endedAt === void 0) open.endedAt = now;
|
|
214
|
+
}
|
|
215
|
+
break;
|
|
216
|
+
case "phase:start": {
|
|
217
|
+
const node = nodeOf(state, event, "phase", scrub(event.phase));
|
|
218
|
+
node.startedAt = now;
|
|
219
|
+
node.status = "ok";
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
case "budget:update":
|
|
223
|
+
state.spentUsd = event.spentUsd;
|
|
224
|
+
state.ceilingUsd = event.remainingUsd === null ? void 0 : event.spentUsd + event.remainingUsd;
|
|
225
|
+
break;
|
|
226
|
+
case "log":
|
|
227
|
+
if (event.level === "warn" || event.level === "error") {
|
|
228
|
+
state.notices.push(scrub(`${event.level}: ${event.msg}`));
|
|
229
|
+
if (state.notices.length > 2) state.notices.shift();
|
|
230
|
+
}
|
|
231
|
+
break;
|
|
232
|
+
case "external:waiting":
|
|
233
|
+
state.banner = scrub(`waiting on external: ${event.key}`);
|
|
234
|
+
break;
|
|
235
|
+
case "approval:pending":
|
|
236
|
+
state.banner = scrub(`approval pending: ${event.toolName}`);
|
|
237
|
+
break;
|
|
238
|
+
case "child:start": {
|
|
239
|
+
const node = nodeOf(state, event, "child", scrub(`${event.workflow} (${event.scope})`));
|
|
240
|
+
node.startedAt = now;
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
case "child:end": {
|
|
244
|
+
const node = state.nodes.get(event.spanId);
|
|
245
|
+
if (node !== void 0) {
|
|
246
|
+
node.status = event.status;
|
|
247
|
+
node.endedAt = now;
|
|
248
|
+
}
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
case "agent:queued": {
|
|
252
|
+
const node = nodeOf(state, event, "agent", agentTitle(event));
|
|
253
|
+
node.status = "queued";
|
|
254
|
+
node.startedAt = now;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
case "agent:start": {
|
|
258
|
+
state.banner = void 0;
|
|
259
|
+
const node = nodeOf(state, event, "agent", agentTitle(event));
|
|
260
|
+
if (node.status === "queued" || node.roles.length === 0) node.startedAt = now;
|
|
261
|
+
node.status = "running";
|
|
262
|
+
node.model = scrub(event.model);
|
|
263
|
+
if (event.replayed === true) node.replayed = true;
|
|
264
|
+
const open = node.roles.at(-1);
|
|
265
|
+
if (open !== void 0 && open.endedAt === void 0) open.endedAt = now;
|
|
266
|
+
node.roles.push({
|
|
267
|
+
role: event.role,
|
|
268
|
+
startedAt: now
|
|
269
|
+
});
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
case "agent:stream": {
|
|
273
|
+
const node = state.nodes.get(event.spanId);
|
|
274
|
+
if (node !== void 0) node.streamedChars += event.delta.length;
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
case "agent:error": {
|
|
278
|
+
const node = state.nodes.get(event.spanId);
|
|
279
|
+
if (node !== void 0) node.badge = event.willRetry ? "retry" : scrub(`error: ${event.error?.message ?? ""}`);
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
case "agent:schema-retry": {
|
|
283
|
+
const node = state.nodes.get(event.spanId);
|
|
284
|
+
if (node !== void 0) node.badge = `schema ${String(event.attempt)}/${String(event.maxAttempts)}`;
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
case "agent:end": {
|
|
288
|
+
state.banner = void 0;
|
|
289
|
+
const node = nodeOf(state, event, "agent", agentTitle(event));
|
|
290
|
+
node.status = event.status;
|
|
291
|
+
node.endedAt = now;
|
|
292
|
+
node.usage = {
|
|
293
|
+
input: event.usage?.inputTokens ?? 0,
|
|
294
|
+
output: event.usage?.outputTokens ?? 0
|
|
295
|
+
};
|
|
296
|
+
node.costUsd = event.costUsd;
|
|
297
|
+
if (event.replayed === true) node.replayed = true;
|
|
298
|
+
if (node.status === "ok") node.badge = void 0;
|
|
299
|
+
const open = node.roles.at(-1);
|
|
300
|
+
if (open !== void 0 && open.endedAt === void 0) open.endedAt = now;
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
case "tool:start": {
|
|
304
|
+
const node = toolTarget(state, event);
|
|
305
|
+
if (node !== void 0) node.toolActive = scrub(event.toolName);
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
case "tool:end": {
|
|
309
|
+
const node = toolTarget(state, event);
|
|
310
|
+
if (node !== void 0) {
|
|
311
|
+
node.toolActive = void 0;
|
|
312
|
+
node.toolCount += 1;
|
|
313
|
+
if (event.outcome === "denied") node.badge = scrub(`tool ${event.toolName} denied`);
|
|
314
|
+
}
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
case "orchestrator:woke":
|
|
318
|
+
state.wakes += 1;
|
|
319
|
+
break;
|
|
320
|
+
case "spawn:admitted":
|
|
321
|
+
state.admitted += 1;
|
|
322
|
+
break;
|
|
323
|
+
case "spawn:rejected":
|
|
324
|
+
state.rejected += 1;
|
|
325
|
+
state.notices.push(`spawn rejected: ${event.code}`);
|
|
326
|
+
if (state.notices.length > 2) state.notices.shift();
|
|
327
|
+
break;
|
|
328
|
+
default: break;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const GLYPHS = {
|
|
332
|
+
ok: "*",
|
|
333
|
+
running: "",
|
|
334
|
+
queued: "o",
|
|
335
|
+
error: "x",
|
|
336
|
+
cancelled: "x",
|
|
337
|
+
exhausted: "x",
|
|
338
|
+
interrupted: "x",
|
|
339
|
+
escalated: "!"
|
|
340
|
+
};
|
|
341
|
+
function paint(text, code, style) {
|
|
342
|
+
return style.color ? `[${code}m${text}[0m` : text;
|
|
343
|
+
}
|
|
344
|
+
function nodeRow(node, now, spinner, style) {
|
|
345
|
+
const running = node.endedAt === void 0 && node.status === "running";
|
|
346
|
+
const glyph = running ? node.replayed ? "*" : spinner : GLYPHS[node.status] ?? "*";
|
|
347
|
+
const parts = [glyph === "" ? spinner : glyph, node.title];
|
|
348
|
+
if (node.model !== void 0) parts.push(paint(node.model, "2", style));
|
|
349
|
+
const currentRole = node.roles.at(-1)?.role;
|
|
350
|
+
if (currentRole !== void 0 && currentRole !== "loop") parts.push(paint(`> ${currentRole}`, "36", style));
|
|
351
|
+
if (node.replayed) parts.push(paint("replay", "2", style));
|
|
352
|
+
else if (node.startedAt > 0 && node.kind !== "phase") {
|
|
353
|
+
const elapsed = (node.endedAt ?? now) - node.startedAt;
|
|
354
|
+
parts.push(fmtDuration(elapsed));
|
|
355
|
+
}
|
|
356
|
+
if (node.usage !== void 0) parts.push(`in ${fmtTokens(node.usage.input)} out ${fmtTokens(node.usage.output)}`);
|
|
357
|
+
else if (running && node.streamedChars > 0) parts.push(paint(`~${fmtTokens(Math.ceil(node.streamedChars / 4))} out`, "2", style));
|
|
358
|
+
if (node.costUsd !== void 0 && !node.replayed) parts.push(fmtUsd(node.costUsd));
|
|
359
|
+
if (node.toolActive !== void 0) parts.push(paint(`tool: ${node.toolActive}`, "33", style));
|
|
360
|
+
else if (node.toolCount > 0) parts.push(paint(`${String(node.toolCount)} tool${node.toolCount === 1 ? "" : "s"}`, "2", style));
|
|
361
|
+
if (node.badge !== void 0) parts.push(paint(node.badge, node.badge.startsWith("error") ? "31" : "33", style));
|
|
362
|
+
if (node.status === "queued") parts.push(paint("queued", "2", style));
|
|
363
|
+
return parts.join(" ");
|
|
364
|
+
}
|
|
365
|
+
function roleRow(node, now) {
|
|
366
|
+
if (node.roles.length < 2) return;
|
|
367
|
+
return `roles: ${node.roles.map((slice) => {
|
|
368
|
+
const elapsed = (slice.endedAt ?? now) - slice.startedAt;
|
|
369
|
+
return `${slice.role} ${fmtDuration(elapsed)}${slice.endedAt === void 0 ? ".." : ""}`;
|
|
370
|
+
}).join(" · ")}`;
|
|
371
|
+
}
|
|
372
|
+
function composeTree(state, now, spinner, style) {
|
|
373
|
+
const lines = [];
|
|
374
|
+
const visited = /* @__PURE__ */ new Set();
|
|
375
|
+
const walk = (spanId, depth) => {
|
|
376
|
+
const node = state.nodes.get(spanId);
|
|
377
|
+
if (node === void 0 || visited.has(spanId)) return;
|
|
378
|
+
visited.add(spanId);
|
|
379
|
+
const indent = " ".repeat(depth + 1);
|
|
380
|
+
if (node.kind === "phase") lines.push(`${indent}${paint(`[${node.title}]`, "1", style)}`);
|
|
381
|
+
else {
|
|
382
|
+
lines.push(indent + nodeRow(node, now, spinner, style));
|
|
383
|
+
const roles = roleRow(node, now);
|
|
384
|
+
if (roles !== void 0) lines.push(indent + " " + paint(roles, "2", style));
|
|
385
|
+
}
|
|
386
|
+
for (const child of node.children) walk(child, depth + 1);
|
|
387
|
+
};
|
|
388
|
+
for (const root of state.roots) walk(root, 0);
|
|
389
|
+
return lines;
|
|
390
|
+
}
|
|
391
|
+
function composeFrame(state, now, tick, style, width, maxRows, maxHeight) {
|
|
392
|
+
const spinner = SPINNER[tick % SPINNER.length];
|
|
393
|
+
const lines = [];
|
|
394
|
+
const running = state.endedAt === void 0;
|
|
395
|
+
const head = [running ? spinner : state.status === "ok" ? "*" : "x", state.title ?? "run"];
|
|
396
|
+
if (state.runId !== void 0) head.push(paint(`run ${state.runId}`, "2", style));
|
|
397
|
+
if (state.resumed) head.push(paint("(resumed)", "2", style));
|
|
398
|
+
if (state.startedAt !== void 0) head.push(fmtDuration((state.endedAt ?? now) - state.startedAt));
|
|
399
|
+
const spent = state.totalUsd ?? state.spentUsd;
|
|
400
|
+
if (state.ceilingUsd !== void 0) {
|
|
401
|
+
head.push(`${fmtUsd(spent)} / ${fmtUsd(state.ceilingUsd)}`);
|
|
402
|
+
head.push(bar(spent, state.ceilingUsd, 12));
|
|
403
|
+
} else if (spent > 0) head.push(fmtUsd(spent));
|
|
404
|
+
lines.push(head.join(" "));
|
|
405
|
+
let body = composeTree(state, now, spinner, style);
|
|
406
|
+
if (body.length > maxRows) {
|
|
407
|
+
const hidden = body.length - maxRows;
|
|
408
|
+
body = [paint(` ... ${String(hidden)} earlier rows hidden`, "2", style), ...body.slice(hidden)];
|
|
409
|
+
}
|
|
410
|
+
lines.push(...body);
|
|
411
|
+
if (state.wakes + state.admitted + state.rejected > 0) lines.push(" " + paint(`orch: wakes ${String(state.wakes)} · spawns ${String(state.admitted)} admitted, ${String(state.rejected)} rejected`, "2", style));
|
|
412
|
+
if (state.banner !== void 0 && running) lines.push(" " + paint(state.banner, "33", style));
|
|
413
|
+
for (const notice of state.notices) lines.push(" " + paint(notice, "33", style));
|
|
414
|
+
if (!running) {
|
|
415
|
+
const roleCosts = state.cost?.byRole;
|
|
416
|
+
if (roleCosts !== void 0) {
|
|
417
|
+
const nonzero = Object.entries(roleCosts).filter(([, value]) => value > 0);
|
|
418
|
+
if (nonzero.length > 0) lines.push(" " + nonzero.map(([role, value]) => `${role} ${fmtUsd(value)}`).join(" · "));
|
|
419
|
+
}
|
|
420
|
+
lines.push(`${state.status === "ok" ? "*" : "x"} ${state.status}` + (state.totalUsd === void 0 ? "" : ` total ${fmtUsd(state.totalUsd)}`));
|
|
421
|
+
}
|
|
422
|
+
let clamped = lines;
|
|
423
|
+
if (maxHeight !== void 0 && lines.length > maxHeight && maxHeight >= 3) {
|
|
424
|
+
const keepTail = Math.min(running ? 1 : 3, maxHeight - 2);
|
|
425
|
+
const hidden = lines.length - 1 - keepTail - 1;
|
|
426
|
+
clamped = [
|
|
427
|
+
lines[0] ?? "",
|
|
428
|
+
paint(` ... ${String(hidden)} lines hidden (terminal too short)`, "2", style),
|
|
429
|
+
...lines.slice(lines.length - keepTail)
|
|
430
|
+
];
|
|
431
|
+
}
|
|
432
|
+
const limit = Math.max(0, width - 1);
|
|
433
|
+
return clamped.map((line) => {
|
|
434
|
+
const plain = line.replace(SGR_STRIP, "");
|
|
435
|
+
if (plain.length <= limit) return line;
|
|
436
|
+
return limit >= 4 ? plain.slice(0, limit - 3) + "..." : plain.slice(0, limit);
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
function defaultSink() {
|
|
440
|
+
return process.stderr;
|
|
441
|
+
}
|
|
442
|
+
function defaultClock() {
|
|
443
|
+
return {
|
|
444
|
+
now: () => performance.now(),
|
|
445
|
+
every: (ms, fn) => {
|
|
446
|
+
const timer = setInterval(fn, ms);
|
|
447
|
+
return () => clearInterval(timer);
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
function resolveMode(option, sink) {
|
|
452
|
+
if (option !== void 0 && option !== "auto") return option;
|
|
453
|
+
const dumb = process.env.TERM === "dumb" || process.env.CI !== void 0;
|
|
454
|
+
return sink.isTTY === true && !dumb ? "tty" : "lines";
|
|
455
|
+
}
|
|
456
|
+
function isHandle(source) {
|
|
457
|
+
return typeof source.on === "function" && typeof source.result?.then === "function";
|
|
458
|
+
}
|
|
459
|
+
/** Every event type the reducer renders; handle mode subscribes per type. */
|
|
460
|
+
const CONSUMED_TYPES = [
|
|
461
|
+
"run:start",
|
|
462
|
+
"run:end",
|
|
463
|
+
"phase:start",
|
|
464
|
+
"budget:update",
|
|
465
|
+
"log",
|
|
466
|
+
"external:waiting",
|
|
467
|
+
"approval:pending",
|
|
468
|
+
"child:start",
|
|
469
|
+
"child:end",
|
|
470
|
+
"agent:queued",
|
|
471
|
+
"agent:start",
|
|
472
|
+
"agent:stream",
|
|
473
|
+
"agent:error",
|
|
474
|
+
"agent:schema-retry",
|
|
475
|
+
"agent:end",
|
|
476
|
+
"tool:start",
|
|
477
|
+
"tool:end",
|
|
478
|
+
"orchestrator:woke",
|
|
479
|
+
"spawn:admitted",
|
|
480
|
+
"spawn:rejected"
|
|
481
|
+
];
|
|
482
|
+
/**
|
|
483
|
+
* Attaches a live progress view to a run and returns its handle. Accepts
|
|
484
|
+
* a RunHandle (subscribes through `on()`, leaving `handle.events` free
|
|
485
|
+
* for the host, and enriches the final frame from `RunOutcome.cost`;
|
|
486
|
+
* `orchestrate` returns exactly such a handle, so
|
|
487
|
+
* `progress(orchestrate(...))` composes directly), a promise resolving
|
|
488
|
+
* to a handle (for wrappers that construct one asynchronously), or a
|
|
489
|
+
* raw WorkflowEvent iterable (the gapless path for resumes:
|
|
490
|
+
* `progress(resumed.events)`; note it consumes that one-shot iterable).
|
|
491
|
+
* The view auto-stops when the run settles.
|
|
492
|
+
*/
|
|
493
|
+
function progress(source, options) {
|
|
494
|
+
const sink = options?.sink ?? defaultSink();
|
|
495
|
+
const clock = options?.clock ?? defaultClock();
|
|
496
|
+
const mode = resolveMode(options?.mode, sink);
|
|
497
|
+
const style = { color: options?.color ?? (mode === "tty" && process.env.NO_COLOR === void 0) };
|
|
498
|
+
const columns = posIntOption(sink.columns, 80, 1);
|
|
499
|
+
const rows = posIntOption(sink.rows, 32, 3);
|
|
500
|
+
const width = posIntOption(options?.width, columns, 1);
|
|
501
|
+
const maxRows = posIntOption(options?.maxRows, Math.max(6, Math.min(24, rows - 8)), 1);
|
|
502
|
+
const fps = Math.min(30, posIntOption(options?.fps, 10, 1));
|
|
503
|
+
const state = newState(options?.title === void 0 ? void 0 : scrub(options.title));
|
|
504
|
+
let settled = false;
|
|
505
|
+
let resolveDone = () => void 0;
|
|
506
|
+
const done = new Promise((resolve) => {
|
|
507
|
+
resolveDone = resolve;
|
|
508
|
+
});
|
|
509
|
+
if (mode === "off") {
|
|
510
|
+
if (!isHandle(source) && typeof source.then === "function") source.catch(() => void 0);
|
|
511
|
+
settled = true;
|
|
512
|
+
resolveDone();
|
|
513
|
+
return {
|
|
514
|
+
mode,
|
|
515
|
+
done,
|
|
516
|
+
render: () => void 0,
|
|
517
|
+
stop: () => void 0
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
let tick = 0;
|
|
521
|
+
let paintedLines = 0;
|
|
522
|
+
let lastLinesBudgetAt;
|
|
523
|
+
const paintFrame = () => {
|
|
524
|
+
const rawRows = sink.rows;
|
|
525
|
+
const maxHeight = typeof rawRows === "number" && Number.isFinite(rawRows) ? Math.max(3, Math.floor(rawRows) - 1) : void 0;
|
|
526
|
+
const frame = composeFrame(state, clock.now(), tick, style, width, maxRows, maxHeight);
|
|
527
|
+
const erase = paintedLines > 0 ? `[${String(paintedLines)}A[0J` : "";
|
|
528
|
+
sink.write(erase + frame.join("\n") + "\n");
|
|
529
|
+
paintedLines = frame.length;
|
|
530
|
+
state.dirty = false;
|
|
531
|
+
};
|
|
532
|
+
const lineFor = (event, now) => {
|
|
533
|
+
switch (event.type) {
|
|
534
|
+
case "run:start": return `run ${event.runId} started: ${event.workflow}${event.resumed ? " (resumed)" : ""}`;
|
|
535
|
+
case "phase:start": return `phase: ${event.phase}`;
|
|
536
|
+
case "agent:start": {
|
|
537
|
+
const node = state.nodes.get(event.spanId);
|
|
538
|
+
const inner = node !== void 0 && node.roles.length > 1;
|
|
539
|
+
return `agent ${agentTitle(event)} -> ${event.model} (${event.role})${inner ? " [inner phase]" : ""}`;
|
|
540
|
+
}
|
|
541
|
+
case "agent:end": {
|
|
542
|
+
const node = state.nodes.get(event.spanId);
|
|
543
|
+
const elapsed = node === void 0 || node.replayed || node.startedAt <= 0 ? "" : ` in ${fmtDuration((node.endedAt ?? now) - node.startedAt)}`;
|
|
544
|
+
const roles = node !== void 0 && node.roles.length > 1 ? ` [${node.roles.map((slice) => slice.role).join(" > ")}]` : "";
|
|
545
|
+
return `agent ${agentTitle(event)} ${event.status}${elapsed}: in ${fmtTokens(event.usage?.inputTokens ?? 0)} out ${fmtTokens(event.usage?.outputTokens ?? 0)}, ${fmtUsd(event.costUsd)}${roles}${event.replayed === true ? " (replay)" : ""}`;
|
|
546
|
+
}
|
|
547
|
+
case "agent:error": return `agent ${agentTitle(event)} error: ${event.error?.message ?? ""}` + (event.willRetry ? " (will retry)" : "");
|
|
548
|
+
case "budget:update":
|
|
549
|
+
if (lastLinesBudgetAt !== void 0 && now - lastLinesBudgetAt < 1e3) return;
|
|
550
|
+
lastLinesBudgetAt = now;
|
|
551
|
+
return `budget: ${fmtUsd(event.spentUsd)}` + (event.remainingUsd === null ? "" : ` of ${fmtUsd(event.spentUsd + event.remainingUsd)}`);
|
|
552
|
+
case "log": return event.level === "warn" || event.level === "error" ? `[${event.level}] ${event.msg}` : void 0;
|
|
553
|
+
case "external:waiting": return `waiting on external: ${event.key}`;
|
|
554
|
+
case "approval:pending": return `approval pending: ${event.toolName}`;
|
|
555
|
+
case "run:end": return `run finished: ${event.status} (total ${fmtUsd(event.totalUsd)})`;
|
|
556
|
+
default: return;
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
const onEvent = (event) => {
|
|
560
|
+
const now = clock.now();
|
|
561
|
+
applyEvent(state, event, now);
|
|
562
|
+
if (mode === "lines") {
|
|
563
|
+
const line = lineFor(event, now);
|
|
564
|
+
if (line !== void 0) sink.write(scrub(line) + "\n");
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
const finishLines = () => {
|
|
568
|
+
const roleCosts = state.cost?.byRole;
|
|
569
|
+
if (roleCosts === void 0) return;
|
|
570
|
+
const nonzero = Object.entries(roleCosts).filter(([, value]) => value > 0);
|
|
571
|
+
if (nonzero.length > 0) sink.write(`cost by role: ${nonzero.map(([r, v]) => `${r} ${fmtUsd(v)}`).join(", ")}\n`);
|
|
572
|
+
};
|
|
573
|
+
let cancelTick;
|
|
574
|
+
let unsubscribe = [];
|
|
575
|
+
const handleApi = {
|
|
576
|
+
mode,
|
|
577
|
+
done,
|
|
578
|
+
render: () => {
|
|
579
|
+
if (!settled && mode === "tty") {
|
|
580
|
+
tick += 1;
|
|
581
|
+
paintFrame();
|
|
582
|
+
}
|
|
583
|
+
},
|
|
584
|
+
stop: (final = true) => {
|
|
585
|
+
if (settled) return;
|
|
586
|
+
settled = true;
|
|
587
|
+
cancelTick?.();
|
|
588
|
+
for (const off of unsubscribe) off();
|
|
589
|
+
unsubscribe = [];
|
|
590
|
+
if (mode === "tty") {
|
|
591
|
+
if (final) paintFrame();
|
|
592
|
+
sink.write("\x1B[?25h");
|
|
593
|
+
} else if (final) finishLines();
|
|
594
|
+
resolveDone();
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
if (mode === "tty") {
|
|
598
|
+
sink.write("\x1B[?25l");
|
|
599
|
+
cancelTick = clock.every(Math.round(1e3 / fps), () => {
|
|
600
|
+
if (settled) return;
|
|
601
|
+
tick += 1;
|
|
602
|
+
const anyRunning = state.endedAt === void 0;
|
|
603
|
+
if (state.dirty || anyRunning) paintFrame();
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
const attachHandle = (handle) => {
|
|
607
|
+
if (settled) {
|
|
608
|
+
handle.result.catch(() => void 0);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
for (const type of CONSUMED_TYPES) unsubscribe.push(handle.on(type, onEvent));
|
|
612
|
+
handle.result.then((outcome) => {
|
|
613
|
+
state.cost = outcome.cost;
|
|
614
|
+
state.status = outcome.status;
|
|
615
|
+
state.totalUsd = outcome.cost.totalUsd;
|
|
616
|
+
state.endedAt ??= clock.now();
|
|
617
|
+
}).catch((thrown) => {
|
|
618
|
+
state.notices.push(`error: ${thrown instanceof Error ? thrown.message : String(thrown)}`);
|
|
619
|
+
state.endedAt ??= clock.now();
|
|
620
|
+
}).finally(() => {
|
|
621
|
+
handleApi.stop(true);
|
|
622
|
+
});
|
|
623
|
+
};
|
|
624
|
+
if (isHandle(source)) attachHandle(source);
|
|
625
|
+
else if (typeof source.then === "function") source.then((handle) => {
|
|
626
|
+
attachHandle(handle);
|
|
627
|
+
}).catch((thrown) => {
|
|
628
|
+
state.notices.push(`error: ${thrown instanceof Error ? thrown.message : String(thrown)}`);
|
|
629
|
+
state.endedAt ??= clock.now();
|
|
630
|
+
handleApi.stop(true);
|
|
631
|
+
});
|
|
632
|
+
else (async () => {
|
|
633
|
+
try {
|
|
634
|
+
for await (const event of source) {
|
|
635
|
+
if (settled) break;
|
|
636
|
+
onEvent(event);
|
|
637
|
+
}
|
|
638
|
+
} catch (thrown) {
|
|
639
|
+
state.notices.push(`error: ${thrown instanceof Error ? thrown.message : String(thrown)}`);
|
|
640
|
+
} finally {
|
|
641
|
+
state.endedAt ??= clock.now();
|
|
642
|
+
handleApi.stop(true);
|
|
643
|
+
}
|
|
644
|
+
})();
|
|
645
|
+
return handleApi;
|
|
646
|
+
}
|
|
647
|
+
//#endregion
|
|
52
648
|
//#region src/defaults.ts
|
|
53
649
|
/**
|
|
54
650
|
* Drop-in engine defaults: `createEngine({ ..., defaults: { routing:
|
|
@@ -98,4 +694,4 @@ const recommendedDefaults = {
|
|
|
98
694
|
} }
|
|
99
695
|
};
|
|
100
696
|
//#endregion
|
|
101
|
-
export { ANTHROPIC_MODELS, OPENAI_MODELS, anthropic, openai, recommendedDefaults, renderProgress };
|
|
697
|
+
export { ANTHROPIC_MODELS, OPENAI_MODELS, anthropic, openai, progress, recommendedDefaults, renderProgress };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rulvar/rulvar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.22.0",
|
|
4
4
|
"description": "Rulvar umbrella package: re-exports @rulvar/core, both first-class adapters, the file store, and the terminal progress renderer. Also installable through the unscoped alias package rulvar, which re-exports this one.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -22,16 +22,16 @@
|
|
|
22
22
|
"access": "public"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@rulvar/core": "1.
|
|
26
|
-
"@rulvar/anthropic": "1.
|
|
27
|
-
"@rulvar/openai": "1.
|
|
25
|
+
"@rulvar/core": "1.22.0",
|
|
26
|
+
"@rulvar/anthropic": "1.22.0",
|
|
27
|
+
"@rulvar/openai": "1.22.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@types/node": "^22.20.0",
|
|
31
31
|
"tsdown": "^0.22.3",
|
|
32
32
|
"typescript": "~6.0.3",
|
|
33
33
|
"zod": "^4.4.3",
|
|
34
|
-
"@rulvar/testing": "1.
|
|
34
|
+
"@rulvar/testing": "1.22.0"
|
|
35
35
|
},
|
|
36
36
|
"repository": {
|
|
37
37
|
"type": "git",
|