@sweny-ai/core 0.1.206 → 0.1.208

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.
@@ -16,6 +16,15 @@ import { type SpendKind } from "../budget.js";
16
16
  export declare const RUN_HISTORY_SCHEMA_VERSION = 1;
17
17
  export declare const RUN_HISTORY_DIR: string;
18
18
  export declare const RUN_HISTORY_KEEP = 200;
19
+ /**
20
+ * Version of the optional detail fields (per-node `tool_counts`, `tokens_in`, `tokens_out`,
21
+ * `num_turns`, and the record's `policy`), stamped as `detail_version` on records that carry them.
22
+ * `schema_version` stays 1: older records have no detail and read as such, and older readers ignore it.
23
+ */
24
+ export declare const RUN_DETAIL_VERSION = 1;
25
+ /** At most this many distinct tool names are kept per node, each cut to {@link TOOL_NAME_MAX} characters. */
26
+ export declare const TOOL_COUNTS_MAX = 64;
27
+ export declare const TOOL_NAME_MAX = 80;
19
28
  export type RunStatus = "success" | "failed" | "crashed";
20
29
  export interface RunNodeRecord {
21
30
  id: string;
@@ -27,6 +36,25 @@ export interface RunNodeRecord {
27
36
  /** SDK-reported cost; null when none was reported (never estimated). */
28
37
  cost_usd: number | null;
29
38
  retries: number;
39
+ /**
40
+ * Calls per tool name (names and counts only, never inputs or outputs). Optional: absent on records
41
+ * written before `detail_version` 1, and when the node made no tool call.
42
+ */
43
+ tool_counts?: Record<string, number>;
44
+ /** Input tokens, when the node reported usage. Optional (detail_version 1). */
45
+ tokens_in?: number;
46
+ /** Output tokens, when the node reported usage. Optional (detail_version 1). */
47
+ tokens_out?: number;
48
+ /** Model turns, when the node reported them. Optional (detail_version 1). */
49
+ num_turns?: number;
50
+ }
51
+ /** The agent floor the run ran under: booleans and a mode only. Optional (detail_version 1). */
52
+ export interface RunPolicyRecord {
53
+ /** The agent subprocess env was narrowed to the allowlist on every node. */
54
+ scoped: boolean;
55
+ sandbox: "off" | "auto" | "strict";
56
+ /** The sandbox was enabled for every node that reported it. */
57
+ sandboxed: boolean;
30
58
  }
31
59
  export interface RunTotals {
32
60
  nodes_total: number;
@@ -65,6 +93,10 @@ export interface RunRecord {
65
93
  * jump in `totals` that no node explains shows here.
66
94
  */
67
95
  spend?: Partial<Record<SpendKind, RunKindSpend>>;
96
+ /** 1 when the optional detail fields below and on nodes are recorded. Absent on older records. */
97
+ detail_version?: number;
98
+ /** Absent when no node reported its floor. */
99
+ policy?: RunPolicyRecord;
68
100
  }
69
101
  export interface RunKindSpend {
70
102
  tokens: number;
@@ -84,6 +116,10 @@ export interface NodeTimer {
84
116
  lastResults: Map<string, NodeResult>;
85
117
  }
86
118
  export declare function createNodeTimer(now?: () => number): NodeTimer;
119
+ /** Calls per tool name, in first-seen order. Names are cut and capped; undefined when there are none. */
120
+ export declare function countTools(calls: ReadonlyArray<{
121
+ tool: string;
122
+ }> | undefined): Record<string, number> | undefined;
87
123
  export interface BuildRunRecordInput {
88
124
  runId: string;
89
125
  workflow: Workflow;
@@ -20,6 +20,15 @@ import { openNoFollow } from "../safe-file.js";
20
20
  export const RUN_HISTORY_SCHEMA_VERSION = 1;
21
21
  export const RUN_HISTORY_DIR = path.join(".sweny", "runs");
22
22
  export const RUN_HISTORY_KEEP = 200;
23
+ /**
24
+ * Version of the optional detail fields (per-node `tool_counts`, `tokens_in`, `tokens_out`,
25
+ * `num_turns`, and the record's `policy`), stamped as `detail_version` on records that carry them.
26
+ * `schema_version` stays 1: older records have no detail and read as such, and older readers ignore it.
27
+ */
28
+ export const RUN_DETAIL_VERSION = 1;
29
+ /** At most this many distinct tool names are kept per node, each cut to {@link TOOL_NAME_MAX} characters. */
30
+ export const TOOL_COUNTS_MAX = 64;
31
+ export const TOOL_NAME_MAX = 80;
23
32
  const RECORD_READ_MAX_BYTES = 1024 * 1024;
24
33
  // ── Hash + ids ──────────────────────────────────────────────────
25
34
  function canonicalize(value) {
@@ -78,6 +87,29 @@ export function createNodeTimer(now = Date.now) {
78
87
  }
79
88
  // ── Build the record ────────────────────────────────────────────
80
89
  const isNum = (v) => typeof v === "number" && Number.isFinite(v);
90
+ /** Calls per tool name, in first-seen order. Names are cut and capped; undefined when there are none. */
91
+ export function countTools(calls) {
92
+ const counts = new Map();
93
+ for (const c of calls ?? []) {
94
+ if (typeof c?.tool !== "string")
95
+ continue;
96
+ // eslint-disable-next-line no-control-regex
97
+ const name = c.tool.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, TOOL_NAME_MAX);
98
+ if (name === "")
99
+ continue;
100
+ if (!counts.has(name) && counts.size >= TOOL_COUNTS_MAX)
101
+ continue;
102
+ counts.set(name, (counts.get(name) ?? 0) + 1);
103
+ }
104
+ if (counts.size === 0)
105
+ return undefined;
106
+ const out = {};
107
+ // defineProperty: a tool named `__proto__` stays a plain key.
108
+ for (const [k, v] of counts) {
109
+ Object.defineProperty(out, k, { value: v, enumerable: true, writable: true, configurable: true });
110
+ }
111
+ return out;
112
+ }
81
113
  export function buildRunRecord(i) {
82
114
  const summary = summarizeRun(i.results, i.durationMs, i.crashed, i.trace);
83
115
  const retries = new Map();
@@ -91,6 +123,7 @@ export function buildRunRecord(i) {
91
123
  const tokens = hasTokens
92
124
  ? (isNum(u.inputTokens) ? u.inputTokens : 0) + (isNum(u.outputTokens) ? u.outputTokens : 0)
93
125
  : null;
126
+ const toolCounts = countTools(r.toolCalls);
94
127
  return {
95
128
  id,
96
129
  status: r.status,
@@ -99,6 +132,10 @@ export function buildRunRecord(i) {
99
132
  tokens,
100
133
  cost_usd: u && isNum(u.costUsd) ? u.costUsd : null,
101
134
  retries: retries.get(id) ?? 0,
135
+ ...(toolCounts ? { tool_counts: toolCounts } : {}),
136
+ ...(u && isNum(u.inputTokens) ? { tokens_in: u.inputTokens } : {}),
137
+ ...(u && isNum(u.outputTokens) ? { tokens_out: u.outputTokens } : {}),
138
+ ...(u && isNum(u.numTurns) ? { num_turns: u.numTurns } : {}),
102
139
  };
103
140
  });
104
141
  const harness = [...i.results.values()].find((r) => r.harness)?.harness;
@@ -124,6 +161,16 @@ export function buildRunRecord(i) {
124
161
  ...(harness ? { harness: { id: harness.id, version: harness.version } } : {}),
125
162
  ...(degraded.length > 0 ? { degraded } : {}),
126
163
  ...(summary.spend ? { spend: recordSpend(summary.spend) } : {}),
164
+ detail_version: RUN_DETAIL_VERSION,
165
+ ...(summary.policy?.envScope !== undefined && summary.policy.sandbox
166
+ ? {
167
+ policy: {
168
+ scoped: summary.policy.envScope,
169
+ sandbox: summary.policy.sandbox.mode,
170
+ sandboxed: summary.policy.sandbox.started,
171
+ },
172
+ }
173
+ : {}),
127
174
  };
128
175
  }
129
176
  function recordSpend(spend) {
@@ -201,18 +248,124 @@ export function recordRun(record, cwd = process.cwd()) {
201
248
  pruneRuns(cwd);
202
249
  return true;
203
250
  }
204
- function isRecord(v) {
205
- const r = v;
206
- return (!!r &&
207
- typeof r === "object" &&
208
- r.schema_version === RUN_HISTORY_SCHEMA_VERSION &&
209
- typeof r.run_id === "string" &&
210
- typeof r.workflow_id === "string" &&
211
- typeof r.started_at === "string" &&
212
- Array.isArray(r.nodes) &&
213
- Array.isArray(r.routes) &&
214
- !!r.totals &&
215
- typeof r.totals === "object");
251
+ const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
252
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
253
+ const numOrNull = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
254
+ const RUNGS = new Set(["expr", "decider", "agent"]);
255
+ const SANDBOX_MODES = new Set(["off", "auto", "strict"]);
256
+ const count = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
257
+ /**
258
+ * Per-tool counts read back: own keys only, names within the writer's own limits (no control
259
+ * characters, at most TOOL_NAME_MAX long, at most TOOL_COUNTS_MAX of them), values non-negative
260
+ * integers. Anything else is dropped; undefined when nothing is left.
261
+ */
262
+ function parseToolCounts(v) {
263
+ if (!isObj(v))
264
+ return undefined;
265
+ const out = {};
266
+ let kept = 0;
267
+ for (const [k, c] of Object.entries(v)) {
268
+ if (kept >= TOOL_COUNTS_MAX)
269
+ break;
270
+ // eslint-disable-next-line no-control-regex
271
+ if (k === "" || k.length > TOOL_NAME_MAX || /[\u0000-\u001f\u007f]/.test(k) || !count(c))
272
+ continue;
273
+ Object.defineProperty(out, k, { value: c, enumerable: true, writable: true, configurable: true });
274
+ kept++;
275
+ }
276
+ return kept > 0 ? out : undefined;
277
+ }
278
+ /**
279
+ * A typed, allow-listed copy of a stored record, or null when it is not a run record. History is
280
+ * agent-writable, so a record is never passed through: only the documented fields are copied, numbers
281
+ * are made finite, and malformed node, route, harness, degraded and spend entries are dropped. Strings
282
+ * are kept as read (terminal safety is the printer's job, see `printableRun`).
283
+ */
284
+ function parseRecord(v) {
285
+ if (!isObj(v))
286
+ return null;
287
+ if (v.schema_version !== RUN_HISTORY_SCHEMA_VERSION ||
288
+ typeof v.run_id !== "string" ||
289
+ typeof v.workflow_id !== "string" ||
290
+ typeof v.started_at !== "string" ||
291
+ typeof v.status !== "string" ||
292
+ !Array.isArray(v.nodes) ||
293
+ !Array.isArray(v.routes) ||
294
+ !isObj(v.totals)) {
295
+ return null;
296
+ }
297
+ const t = v.totals;
298
+ const out = {
299
+ schema_version: RUN_HISTORY_SCHEMA_VERSION,
300
+ run_id: v.run_id,
301
+ workflow_id: v.workflow_id,
302
+ workflow_hash: typeof v.workflow_hash === "string" ? v.workflow_hash : "",
303
+ started_at: v.started_at,
304
+ duration_ms: num(v.duration_ms),
305
+ status: v.status,
306
+ nodes: v.nodes
307
+ .filter((n) => isObj(n) && typeof n.id === "string" && typeof n.status === "string")
308
+ .map((n) => {
309
+ const toolCounts = parseToolCounts(n.tool_counts);
310
+ return {
311
+ id: n.id,
312
+ status: n.status,
313
+ duration_ms: num(n.duration_ms),
314
+ tool_calls: num(n.tool_calls),
315
+ tokens: numOrNull(n.tokens),
316
+ cost_usd: numOrNull(n.cost_usd),
317
+ retries: num(n.retries),
318
+ // detail_version 1 fields: optional, kept only when well-formed.
319
+ ...(toolCounts ? { tool_counts: toolCounts } : {}),
320
+ ...(count(n.tokens_in) ? { tokens_in: n.tokens_in } : {}),
321
+ ...(count(n.tokens_out) ? { tokens_out: n.tokens_out } : {}),
322
+ ...(count(n.num_turns) ? { num_turns: n.num_turns } : {}),
323
+ };
324
+ }),
325
+ routes: v.routes
326
+ .filter((e) => isObj(e) && typeof e.from === "string" && typeof e.to === "string")
327
+ .map((e) => ({
328
+ from: e.from,
329
+ to: e.to,
330
+ ...(typeof e.rung === "string" && RUNGS.has(e.rung) ? { rung: e.rung } : {}),
331
+ })),
332
+ totals: {
333
+ nodes_total: num(t.nodes_total),
334
+ nodes_ok: num(t.nodes_ok),
335
+ nodes_skipped: num(t.nodes_skipped),
336
+ tool_calls: num(t.tool_calls),
337
+ tokens: numOrNull(t.tokens),
338
+ cost_usd: numOrNull(t.cost_usd),
339
+ },
340
+ };
341
+ if (isObj(v.harness) && typeof v.harness.id === "string" && typeof v.harness.version === "string") {
342
+ out.harness = { id: v.harness.id, version: v.harness.version };
343
+ }
344
+ if (count(v.detail_version) && v.detail_version >= 1)
345
+ out.detail_version = v.detail_version;
346
+ if (isObj(v.policy) &&
347
+ typeof v.policy.scoped === "boolean" &&
348
+ typeof v.policy.sandboxed === "boolean" &&
349
+ typeof v.policy.sandbox === "string" &&
350
+ SANDBOX_MODES.has(v.policy.sandbox)) {
351
+ out.policy = {
352
+ scoped: v.policy.scoped,
353
+ sandbox: v.policy.sandbox,
354
+ sandboxed: v.policy.sandboxed,
355
+ };
356
+ }
357
+ if (Array.isArray(v.degraded))
358
+ out.degraded = v.degraded.filter((d) => typeof d === "string");
359
+ if (isObj(v.spend)) {
360
+ const spend = {};
361
+ for (const k of SPEND_KINDS) {
362
+ const e = Object.hasOwn(v.spend, k) ? v.spend[k] : undefined;
363
+ if (isObj(e))
364
+ spend[k] = { tokens: num(e.tokens), cost_usd: num(e.cost_usd), calls: num(e.calls) };
365
+ }
366
+ out.spend = spend;
367
+ }
368
+ return out;
216
369
  }
217
370
  /**
218
371
  * All readable records, newest first. Corrupt files and unknown schema versions
@@ -238,8 +391,9 @@ export function listRuns(cwd = process.cwd()) {
238
391
  if (!st.isFile() || st.size > RECORD_READ_MAX_BYTES)
239
392
  continue;
240
393
  const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
241
- if (isRecord(parsed) && RUN_ID_RE.test(parsed.run_id) && f === `${parsed.run_id}.json`)
242
- out.push(parsed);
394
+ const rec = parseRecord(parsed);
395
+ if (rec && RUN_ID_RE.test(rec.run_id) && f === `${rec.run_id}.json`)
396
+ out.push(rec);
243
397
  }
244
398
  catch {
245
399
  // corrupt record: skip
@@ -10,12 +10,15 @@ import { type RunRecord } from "./run-history.js";
10
10
  import { type AnswersComparison } from "./run-answers.js";
11
11
  export declare function formatAge(startedAt: string, nowMs?: number): string;
12
12
  /**
13
- * A copy of a history record that is safe to print. `.sweny/runs/` is writable by an agent, so every
14
- * string read from a record goes through {@link sanitizeTerminal} (control sequences stripped, length
15
- * capped) and every number is coerced to a finite number. Only for display: matching and `--json`
16
- * use the record as read.
13
+ * A copy of a history record that is safe to print and to export. `.sweny/runs/` is writable by an
14
+ * agent, so the copy is built from an explicit allow-list of typed fields, never by spreading the
15
+ * record: unknown keys are dropped at every level, every string goes through {@link sanitizeTerminal}
16
+ * (control sequences stripped, length capped), every number is coerced finite, and malformed node and
17
+ * route entries are skipped. The table, the diff and every `--json` output use it.
17
18
  */
18
19
  export declare function printableRun(r: RunRecord): RunRecord;
20
+ /** `sweny runs --json`: the validated records, never the raw ones. */
21
+ export declare function formatRunsJson(records: RunRecord[]): string;
19
22
  export declare function formatRunsTable(records: RunRecord[], color: boolean, nowMs?: number, unicode?: boolean): string;
20
23
  export interface RunDiffOptions {
21
24
  /** Answer comparison for the pair; when set the diff leads with it and ends on its summary line. */
package/dist/cli/runs.js CHANGED
@@ -51,23 +51,31 @@ function pad(s, w, right = false) {
51
51
  // ── History records are untrusted ───────────────────────────────
52
52
  const finite = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
53
53
  const finiteOrNull = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
54
+ const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
55
+ const RUNG = new Set(["expr", "decider", "agent"]);
54
56
  /**
55
- * A copy of a history record that is safe to print. `.sweny/runs/` is writable by an agent, so every
56
- * string read from a record goes through {@link sanitizeTerminal} (control sequences stripped, length
57
- * capped) and every number is coerced to a finite number. Only for display: matching and `--json`
58
- * use the record as read.
57
+ * A copy of a history record that is safe to print and to export. `.sweny/runs/` is writable by an
58
+ * agent, so the copy is built from an explicit allow-list of typed fields, never by spreading the
59
+ * record: unknown keys are dropped at every level, every string goes through {@link sanitizeTerminal}
60
+ * (control sequences stripped, length capped), every number is coerced finite, and malformed node and
61
+ * route entries are skipped. The table, the diff and every `--json` output use it.
59
62
  */
60
63
  export function printableRun(r) {
61
64
  const t = (v, max = 64) => sanitizeTerminal(v, max);
62
- return {
63
- ...r,
64
- run_id: t(r.run_id),
65
- workflow_id: t(r.workflow_id),
66
- workflow_hash: t(r.workflow_hash),
67
- started_at: t(r.started_at),
68
- status: t(r.status, 16),
69
- duration_ms: finite(r.duration_ms),
70
- nodes: r.nodes.map((n) => ({
65
+ const rec = r;
66
+ const list = (v) => (Array.isArray(v) ? v : []);
67
+ const totals = isObj(rec.totals) ? rec.totals : {};
68
+ const out = {
69
+ schema_version: finite(rec.schema_version),
70
+ run_id: t(rec.run_id),
71
+ workflow_id: t(rec.workflow_id),
72
+ workflow_hash: t(rec.workflow_hash),
73
+ started_at: t(rec.started_at),
74
+ duration_ms: finite(rec.duration_ms),
75
+ status: t(rec.status, 16),
76
+ nodes: list(rec.nodes)
77
+ .filter(isObj)
78
+ .map((n) => ({
71
79
  id: t(n.id),
72
80
  status: t(n.status, 16),
73
81
  duration_ms: finite(n.duration_ms),
@@ -76,17 +84,34 @@ export function printableRun(r) {
76
84
  cost_usd: finiteOrNull(n.cost_usd),
77
85
  retries: finite(n.retries),
78
86
  })),
79
- routes: r.routes.map((e) => ({ from: t(e.from), to: t(e.to) })),
87
+ routes: list(rec.routes)
88
+ .filter((e) => isObj(e) && typeof e.from === "string" && typeof e.to === "string")
89
+ .map((e) => ({
90
+ from: t(e.from),
91
+ to: t(e.to),
92
+ ...(typeof e.rung === "string" && RUNG.has(e.rung) ? { rung: e.rung } : {}),
93
+ })),
80
94
  totals: {
81
- nodes_total: finite(r.totals.nodes_total),
82
- nodes_ok: finite(r.totals.nodes_ok),
83
- nodes_skipped: finite(r.totals.nodes_skipped),
84
- tool_calls: finite(r.totals.tool_calls),
85
- tokens: finiteOrNull(r.totals.tokens),
86
- cost_usd: finiteOrNull(r.totals.cost_usd),
95
+ nodes_total: finite(totals.nodes_total),
96
+ nodes_ok: finite(totals.nodes_ok),
97
+ nodes_skipped: finite(totals.nodes_skipped),
98
+ tool_calls: finite(totals.tool_calls),
99
+ tokens: finiteOrNull(totals.tokens),
100
+ cost_usd: finiteOrNull(totals.cost_usd),
87
101
  },
88
- ...(r.spend && typeof r.spend === "object" ? { spend: printableSpend(r.spend) } : {}),
89
102
  };
103
+ if (isObj(rec.harness))
104
+ out.harness = { id: t(rec.harness.id), version: t(rec.harness.version) };
105
+ if (Array.isArray(rec.degraded)) {
106
+ out.degraded = rec.degraded.filter((d) => typeof d === "string").map((d) => t(d));
107
+ }
108
+ if (isObj(rec.spend))
109
+ out.spend = printableSpend(rec.spend);
110
+ return out;
111
+ }
112
+ /** `sweny runs --json`: the validated records, never the raw ones. */
113
+ export function formatRunsJson(records) {
114
+ return JSON.stringify(records.map(printableRun), null, 2) + "\n";
90
115
  }
91
116
  /** The record's split, or its totals as all node spend when it has none. */
92
117
  function splitOf(r) {
@@ -432,7 +457,8 @@ export function runRunsDiff(refs, opts, io) {
432
457
  const { a, b } = picked;
433
458
  const answers = compareAnswers(readRunAnswers(a.run_id, io.cwd), readRunAnswers(b.run_id, io.cwd));
434
459
  if (opts.json) {
435
- io.stdout.write(JSON.stringify({ schema_version: 1, a, b, answers }, null, 2) + "\n");
460
+ const body = { schema_version: 1, a: printableRun(a), b: printableRun(b), answers };
461
+ io.stdout.write(JSON.stringify(body, null, 2) + "\n");
436
462
  return 0;
437
463
  }
438
464
  io.stdout.write("\n" + formatRunDiff(a, b, colorEnabled(io.stdout, env), { answers, unicode: supportsUnicode(env) }) + "\n");
@@ -458,7 +484,7 @@ export function registerRunsCommand(program) {
458
484
  runs = runs.filter((r) => r.workflow_id === opts.workflow);
459
485
  runs = runs.slice(0, limit);
460
486
  if (opts.json) {
461
- process.stdout.write(JSON.stringify(runs, null, 2) + "\n");
487
+ process.stdout.write(formatRunsJson(runs));
462
488
  return;
463
489
  }
464
490
  process.stdout.write("\n" + formatRunsTable(runs, colorEnabled(process.stdout), Date.now(), supportsUnicode()) + "\n");
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `sweny try --capture <run>`: turn a recorded run into the `sweny try` fixture (#537).
3
+ *
4
+ * Input is what a real run already left in `.sweny/runs/`: the history record
5
+ * (`<run-id>.json`, run-history.ts) and the answers (`<run-id>/answers.json`,
6
+ * run-answers.ts). Output is a `TryFixture` with `illustrative: false`, so
7
+ * `sweny try` labels it "a replay of a real run on <date>".
8
+ *
9
+ * Nothing is invented. Durations, tool-call counts, token and cost totals, the
10
+ * harness, the policy floor and the answer all come from the record. A run that
11
+ * lacks them (written before `detail_version` 1), failed, or answered nothing is
12
+ * refused with the reason, never padded. The answer is redacted a second time
13
+ * here (the journal's redactor plus the run's secret values), so a token that
14
+ * slipped past the first pass does not reach a committed file.
15
+ *
16
+ * Deterministic: fixed key order, no clock, no randomness. The same run always
17
+ * yields the same bytes.
18
+ */
19
+ import { type RunAnswers } from "./run-answers.js";
20
+ import { type RunRecord } from "./run-history.js";
21
+ import { type TryFixture } from "./try.js";
22
+ export interface CaptureOptions {
23
+ /** `owner/name` of the repo the run was on (a record does not hold it). */
24
+ sampleRepo: string;
25
+ /** Extra secret values to scrub from the answer. Default: none beyond the redactor's patterns. */
26
+ secrets?: string[];
27
+ }
28
+ /**
29
+ * The fixture for one recorded run. Throws, naming the reason, when the run cannot honestly be one:
30
+ * not a success, no detail (older record), no harness, an unknown template or node, or no answers.
31
+ */
32
+ export declare function buildTryFixture(record: RunRecord, answers: RunAnswers | null, opts: CaptureOptions): TryFixture;
33
+ /** The committed bytes: two-space JSON, a trailing newline. */
34
+ export declare function serializeTryFixture(fixture: TryFixture): string;
35
+ export interface CaptureSource {
36
+ record: RunRecord;
37
+ answers: RunAnswers | null;
38
+ }
39
+ /**
40
+ * Find the run a `--capture` argument names, through the same validating readers `sweny runs` uses
41
+ * (history is agent-writable, so a planted or linked file is skipped, not trusted). `ref` is
42
+ * `latest` (newest record under `<cwd>/.sweny/runs`), a run id or unique prefix, or a path to the run
43
+ * dir (`.sweny/runs/<id>`) or its record (`.sweny/runs/<id>.json`).
44
+ */
45
+ export declare function resolveCaptureRun(ref: string, cwd?: string): CaptureSource;
46
+ export interface CaptureRequest {
47
+ ref: string;
48
+ repo?: string;
49
+ /** Write the fixture here. Default: return it for stdout. */
50
+ out?: string;
51
+ cwd?: string;
52
+ env?: Record<string, string | undefined>;
53
+ }
54
+ /** Resolve, convert and (when `out` is set) write. Returns the fixture text and where it went. */
55
+ export declare function captureTryFixture(req: CaptureRequest): {
56
+ text: string;
57
+ fixture: TryFixture;
58
+ wrote?: string;
59
+ };
@@ -0,0 +1,157 @@
1
+ /**
2
+ * `sweny try --capture <run>`: turn a recorded run into the `sweny try` fixture (#537).
3
+ *
4
+ * Input is what a real run already left in `.sweny/runs/`: the history record
5
+ * (`<run-id>.json`, run-history.ts) and the answers (`<run-id>/answers.json`,
6
+ * run-answers.ts). Output is a `TryFixture` with `illustrative: false`, so
7
+ * `sweny try` labels it "a replay of a real run on <date>".
8
+ *
9
+ * Nothing is invented. Durations, tool-call counts, token and cost totals, the
10
+ * harness, the policy floor and the answer all come from the record. A run that
11
+ * lacks them (written before `detail_version` 1), failed, or answered nothing is
12
+ * refused with the reason, never padded. The answer is redacted a second time
13
+ * here (the journal's redactor plus the run's secret values), so a token that
14
+ * slipped past the first pass does not reach a committed file.
15
+ *
16
+ * Deterministic: fixed key order, no clock, no randomness. The same run always
17
+ * yields the same bytes.
18
+ */
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { redact, runSecretValues } from "../journal.js";
22
+ import { readRunAnswers } from "./run-answers.js";
23
+ import { RUN_HISTORY_DIR, RUN_ID_RE, findRun, listRuns } from "./run-history.js";
24
+ import { loadTryWorkflow, parseTryFixture } from "./try.js";
25
+ const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
26
+ function usageOf(n) {
27
+ const usage = {};
28
+ if (typeof n.tokens_in === "number")
29
+ usage.inputTokens = n.tokens_in;
30
+ if (typeof n.tokens_out === "number")
31
+ usage.outputTokens = n.tokens_out;
32
+ if (typeof n.cost_usd === "number")
33
+ usage.costUsd = n.cost_usd;
34
+ if (typeof n.num_turns === "number")
35
+ usage.numTurns = n.num_turns;
36
+ return Object.keys(usage).length > 0 ? usage : undefined;
37
+ }
38
+ /**
39
+ * The fixture for one recorded run. Throws, naming the reason, when the run cannot honestly be one:
40
+ * not a success, no detail (older record), no harness, an unknown template or node, or no answers.
41
+ */
42
+ export function buildTryFixture(record, answers, opts) {
43
+ const refuse = (why) => {
44
+ throw new Error(`cannot capture run ${record.run_id}: ${why}`);
45
+ };
46
+ if (!REPO_RE.test(opts.sampleRepo))
47
+ refuse(`--repo must look like owner/name, got "${opts.sampleRepo}"`);
48
+ if (record.status !== "success")
49
+ refuse(`it ended ${record.status}; a demo is captured from a successful run`);
50
+ if (typeof record.detail_version !== "number" || record.detail_version < 1) {
51
+ refuse("it predates per-tool call counts (no detail_version). Re-run with a sweny that records them");
52
+ }
53
+ if (!record.harness)
54
+ refuse("it recorded no harness");
55
+ if (!record.policy)
56
+ refuse("it recorded no policy floor (env scope and sandbox)");
57
+ if (!answers)
58
+ refuse("it has no answers.json");
59
+ if (answers.run_id !== record.run_id || answers.workflow_id !== record.workflow_id) {
60
+ refuse("answers.json belongs to a different run");
61
+ }
62
+ if (record.nodes.length === 0)
63
+ refuse("it recorded no nodes");
64
+ const workflow = loadTryWorkflow(record.workflow_id);
65
+ const secrets = opts.secrets ?? [];
66
+ const nodes = {};
67
+ for (const n of record.nodes) {
68
+ if (!workflow.nodes[n.id])
69
+ refuse(`node "${n.id}" is not in the ${record.workflow_id} template`);
70
+ if (n.status !== "success")
71
+ refuse(`node "${n.id}" ended ${n.status}`);
72
+ const usage = usageOf(n);
73
+ // Only nodes whose template declares an output schema: a free-form `summary` (what answers.json
74
+ // keeps for a node with no schema) is the agent's working notes, not the answer, and stays out.
75
+ const schema = workflow.nodes[n.id].output;
76
+ const declared = schema?.properties && Object.keys(schema.properties).length > 0 ? answers.nodes[n.id] : undefined;
77
+ const output = declared ? redact(declared, secrets).value : undefined;
78
+ nodes[n.id] = {
79
+ duration_ms: n.duration_ms,
80
+ tool_calls: { ...(n.tool_counts ?? {}) },
81
+ ...(usage ? { usage } : {}),
82
+ ...(output && Object.keys(output).length > 0 ? { output } : {}),
83
+ };
84
+ }
85
+ if (!Object.values(nodes).some((n) => n.output))
86
+ refuse("no node recorded an answer");
87
+ const date = record.started_at.slice(0, 10);
88
+ return parseTryFixture({
89
+ schema_version: 1,
90
+ illustrative: false,
91
+ captured_at: record.started_at,
92
+ notice: `A replay of a real run of ${record.workflow_id} on ${opts.sampleRepo}, started ${record.started_at.slice(0, 19)}Z (${date}). ` +
93
+ "Durations, tool-call counts, token usage and cost are as recorded; the answer is redacted. " +
94
+ "Made by `sweny try --capture`; do not edit by hand.",
95
+ workflow: record.workflow_id,
96
+ sample_repo: opts.sampleRepo,
97
+ harness: { id: record.harness.id, version: record.harness.version },
98
+ policy: {
99
+ envScope: record.policy.scoped,
100
+ sandbox: record.policy.sandbox,
101
+ sandboxStarted: record.policy.sandboxed,
102
+ },
103
+ nodes,
104
+ });
105
+ }
106
+ /** The committed bytes: two-space JSON, a trailing newline. */
107
+ export function serializeTryFixture(fixture) {
108
+ return `${JSON.stringify(fixture, null, 2)}\n`;
109
+ }
110
+ /**
111
+ * Find the run a `--capture` argument names, through the same validating readers `sweny runs` uses
112
+ * (history is agent-writable, so a planted or linked file is skipped, not trusted). `ref` is
113
+ * `latest` (newest record under `<cwd>/.sweny/runs`), a run id or unique prefix, or a path to the run
114
+ * dir (`.sweny/runs/<id>`) or its record (`.sweny/runs/<id>.json`).
115
+ */
116
+ export function resolveCaptureRun(ref, cwd = process.cwd()) {
117
+ let root = cwd;
118
+ let wanted = ref;
119
+ if (ref !== "latest" && !RUN_ID_RE.test(ref)) {
120
+ const abs = path.resolve(cwd, ref);
121
+ const id = path.basename(abs).replace(/\.json$/, "");
122
+ const runsDir = path.dirname(abs);
123
+ const stateDir = path.dirname(runsDir);
124
+ if (path.basename(runsDir) !== path.basename(RUN_HISTORY_DIR) || path.basename(stateDir) !== ".sweny") {
125
+ // Not a path into .sweny/runs: maybe a unique id prefix.
126
+ wanted = ref;
127
+ }
128
+ else {
129
+ root = path.dirname(stateDir);
130
+ wanted = id;
131
+ }
132
+ }
133
+ const runs = listRuns(root);
134
+ const record = wanted === "latest" ? runs[0] : findRun(runs, wanted);
135
+ if (!record) {
136
+ throw new Error(`no run "${ref}" under ${path.join(root, RUN_HISTORY_DIR)}` +
137
+ (runs.length === 0 ? " (no runs recorded there)" : ""));
138
+ }
139
+ return { record, answers: readRunAnswers(record.run_id, root) };
140
+ }
141
+ /** Resolve, convert and (when `out` is set) write. Returns the fixture text and where it went. */
142
+ export function captureTryFixture(req) {
143
+ const env = req.env ?? process.env;
144
+ const cwd = req.cwd ?? process.cwd();
145
+ const repo = req.repo ?? env.GITHUB_REPOSITORY;
146
+ if (!repo)
147
+ throw new Error("--repo owner/name is required (GITHUB_REPOSITORY is not set)");
148
+ const { record, answers } = resolveCaptureRun(req.ref, cwd);
149
+ const fixture = buildTryFixture(record, answers, { sampleRepo: repo, secrets: runSecretValues(undefined, env) });
150
+ const text = serializeTryFixture(fixture);
151
+ if (!req.out)
152
+ return { text, fixture };
153
+ const file = path.resolve(cwd, req.out);
154
+ fs.mkdirSync(path.dirname(file), { recursive: true });
155
+ fs.writeFileSync(file, text, { mode: 0o644 });
156
+ return { text, fixture, wrote: file };
157
+ }
package/dist/cli/try.d.ts CHANGED
@@ -9,7 +9,9 @@
9
9
  * executor is empty by construction.
10
10
  *
11
11
  * The recording lives in `try-fixture.json` (metadata plus the sample answer
12
- * only). Until a real run is recorded it is labelled illustrative.
12
+ * only). Until a real run is committed (`illustrative: false`, made by
13
+ * `sweny try --capture`, try-capture.ts) it is labelled illustrative; a real one
14
+ * says "replay of a real run on <date>" instead.
13
15
  */
14
16
  import type { Command } from "commander";
15
17
  import type { AgentHarness } from "../harness/types.js";
@@ -33,6 +35,11 @@ export interface TryFixture {
33
35
  schema_version: 1;
34
36
  /** True until the owner replaces the file with a recording of a real run. */
35
37
  illustrative: boolean;
38
+ /**
39
+ * When the recorded run started (ISO 8601). Required when `illustrative` is false: the label says
40
+ * "replay of a real run on <date>".
41
+ */
42
+ captured_at?: string;
36
43
  notice: string;
37
44
  /** Built-in template id the recording is of. */
38
45
  workflow: string;
@@ -62,13 +69,19 @@ export declare function loadTryWorkflow(templateId: string): Workflow;
62
69
  * reported through `onProgress` spread across that time so progress feels live.
63
70
  */
64
71
  export declare function createReplayHarness(fixture: TryFixture, opts: {
65
- paceMs: number;
72
+ paceMs: number | ((nodeId: string) => number);
66
73
  }): AgentHarness;
67
74
  export declare const TRY_BANNER_TITLE = "Recorded demo";
68
75
  /** The one command to run next: scaffolds the same workflow on the user's repo and says how to run it. */
69
76
  export declare const TRY_NEXT_COMMAND = "npx @sweny-ai/core new --template explain-repo";
70
77
  /** Appended to `sweny try --help`. */
71
78
  export declare const TRY_HELP_NOTE: string;
79
+ /** `YYYY-MM-DD` of the recorded run, or "" when the fixture carries none. */
80
+ export declare function capturedDate(fixture: TryFixture): string;
81
+ /** What the recording is, in one clause: the label `sweny try` shows. Illustrative until a real run is committed. */
82
+ export declare function tryLabel(fixture: TryFixture): string;
83
+ /** The note under `sweny try --help`, for the recording that ships. */
84
+ export declare function tryHelpNote(fixture: TryFixture): string;
72
85
  /** The tagline, then the demo notice as one dim line. */
73
86
  export declare function formatTryBanner(fixture: TryFixture, paint?: Paint, unicode?: boolean): string;
74
87
  /** The closing line: one command. */
@@ -76,7 +89,11 @@ export declare function formatTryNext(paint?: Paint): string;
76
89
  export interface TryOptions {
77
90
  /** Replay instantly (and land the stamp without the one-frame pause). */
78
91
  fast?: boolean;
79
- /** Milliseconds each node takes to replay. Default 1200. Ignored with `fast`. */
92
+ /**
93
+ * Milliseconds each node takes to replay, fixed. Default: the recorded timings scaled to about
94
+ * {@link REPLAY_TARGET_MS} in total, and instant when output is not a live terminal (non-TTY, CI,
95
+ * dumb terminal). Ignored with `fast`.
96
+ */
80
97
  paceMs?: number;
81
98
  /** Also write the PR-comment markdown here. */
82
99
  commentFile?: string;
@@ -94,7 +111,12 @@ export interface TryOptions {
94
111
  columns?: number;
95
112
  fixture?: TryFixture;
96
113
  }
97
- export declare const DEFAULT_TRY_PACE_MS = 1200;
114
+ /** A default replay takes about this long in total, however long the real run took. */
115
+ export declare const REPLAY_TARGET_MS = 8000;
116
+ /** No node replays faster than this unless the replay is instant. */
117
+ export declare const REPLAY_NODE_MIN_MS = 250;
118
+ /** Per-node replay time: the recorded durations scaled to {@link REPLAY_TARGET_MS} in total (never slowed down). */
119
+ export declare function scaledPace(fixture: TryFixture, targetMs?: number): (nodeId: string) => number;
98
120
  /** The line that opens the job summary preview (record.ts cuts the preview from here). */
99
121
  export declare const SUMMARY_PREVIEW_LEAD = "In CI, the job summary gets this heading, this DAG and the receipt below:";
100
122
  /** Replay the recording. Returns the process exit code. */
package/dist/cli/try.js CHANGED
@@ -9,7 +9,9 @@
9
9
  * executor is empty by construction.
10
10
  *
11
11
  * The recording lives in `try-fixture.json` (metadata plus the sample answer
12
- * only). Until a real run is recorded it is labelled illustrative.
12
+ * only). Until a real run is committed (`illustrative: false`, made by
13
+ * `sweny try --capture`, try-capture.ts) it is labelled illustrative; a real one
14
+ * says "replay of a real run on <date>" instead.
13
15
  */
14
16
  import fs from "node:fs";
15
17
  import { workspaceRoot, writeFileNoFollow } from "../safe-file.js";
@@ -24,9 +26,10 @@ import { formatReceiptDuration, summarizeRun } from "./run-output.js";
24
26
  import { DagRenderer } from "./renderer.js";
25
27
  import { createNodeProgress, toolCallsText } from "./progress.js";
26
28
  import { createPaint } from "./style.js";
27
- import { colorEnabled, glyphsFor, supportsUnicode, terminalColumns } from "./terminal.js";
29
+ import { colorEnabled, glyphsFor, richOutput, supportsUnicode, terminalColumns } from "./terminal.js";
28
30
  import { writeReceipt } from "./ticket.js";
29
31
  import { SWENY_TAGLINE } from "../theme.js";
32
+ const CAPTURED_AT_RE = /^\d{4}-\d{2}-\d{2}(T[\d:.]+Z)?$/;
30
33
  const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
31
34
  /** Shape check with a message naming the first bad field. Throws on a malformed fixture. */
32
35
  export function parseTryFixture(raw) {
@@ -43,6 +46,12 @@ export function parseTryFixture(raw) {
43
46
  }
44
47
  if (typeof raw.illustrative !== "boolean")
45
48
  bad("illustrative must be a boolean");
49
+ if (raw.illustrative === false && (typeof raw.captured_at !== "string" || !CAPTURED_AT_RE.test(raw.captured_at))) {
50
+ bad("a real recording (illustrative: false) needs captured_at, an ISO date such as 2026-11-12T14:00:00.000Z");
51
+ }
52
+ if (raw.captured_at !== undefined && (typeof raw.captured_at !== "string" || !CAPTURED_AT_RE.test(raw.captured_at))) {
53
+ bad("captured_at must be an ISO date");
54
+ }
46
55
  if (!isObj(raw.harness) || typeof raw.harness.id !== "string" || typeof raw.harness.version !== "string") {
47
56
  bad("harness must be { id, version }");
48
57
  }
@@ -135,7 +144,8 @@ export function createReplayHarness(fixture, opts) {
135
144
  for (let i = 0; i < count; i++)
136
145
  toolCalls.push({ tool, input: {} });
137
146
  }
138
- const step = toolCalls.length > 0 ? opts.paceMs / toolCalls.length : opts.paceMs;
147
+ const nodePace = typeof opts.paceMs === "function" ? opts.paceMs(id) : opts.paceMs;
148
+ const step = toolCalls.length > 0 ? nodePace / toolCalls.length : nodePace;
139
149
  for (const tc of toolCalls) {
140
150
  await sleep(step, req.signal);
141
151
  req.onProgress?.(tc.tool);
@@ -161,20 +171,46 @@ export const TRY_NEXT_COMMAND = "npx @sweny-ai/core new --template explain-repo"
161
171
  /** Appended to `sweny try --help`. */
162
172
  export const TRY_HELP_NOTE = "The recording is ILLUSTRATIVE SAMPLE DATA (a fictional repo, hand-written timings and token counts), not output from a real run. " +
163
173
  "Nothing runs: no agent, no network, no credentials read, no files written.";
174
+ /** `YYYY-MM-DD` of the recorded run, or "" when the fixture carries none. */
175
+ export function capturedDate(fixture) {
176
+ return fixture.captured_at?.slice(0, 10) ?? "";
177
+ }
178
+ /** What the recording is, in one clause: the label `sweny try` shows. Illustrative until a real run is committed. */
179
+ export function tryLabel(fixture) {
180
+ return fixture.illustrative
181
+ ? `of ${fixture.workflow} on a sample repo (${fixture.sample_repo}), illustrative data`
182
+ : `of ${fixture.workflow} on ${fixture.sample_repo}, a replay of a real run on ${capturedDate(fixture)}`;
183
+ }
184
+ /** The note under `sweny try --help`, for the recording that ships. */
185
+ export function tryHelpNote(fixture) {
186
+ return fixture.illustrative
187
+ ? TRY_HELP_NOTE
188
+ : `The recording is a replay of a real run of ${fixture.workflow} on ${fixture.sample_repo} (${capturedDate(fixture)}): ` +
189
+ "recorded tool-call counts, tokens, cost and answer (redacted), timings scaled for replay. " +
190
+ "Nothing runs now: no agent, no network, no credentials read, no files written.";
191
+ }
164
192
  /** The tagline, then the demo notice as one dim line. */
165
193
  export function formatTryBanner(fixture, paint = createPaint(false), unicode = true) {
166
194
  const g = glyphsFor(unicode);
167
- const data = fixture.illustrative ? ", illustrative data" : "";
168
195
  return [
169
196
  ` ${paint.brand(g.brand)} ${paint.strong("SWEny")} ${SWENY_TAGLINE}`,
170
- paint.muted(` ${TRY_BANNER_TITLE} of ${fixture.workflow} on a sample repo (${fixture.sample_repo})${data}. No agent, network or credentials.`),
197
+ paint.muted(` ${TRY_BANNER_TITLE} ${tryLabel(fixture)}. No agent, network or credentials.`),
171
198
  ].join("\n");
172
199
  }
173
200
  /** The closing line: one command. */
174
201
  export function formatTryNext(paint = createPaint(false)) {
175
202
  return ` ${paint.muted("Run it on your repo:")} ${paint.strong(TRY_NEXT_COMMAND)}`;
176
203
  }
177
- export const DEFAULT_TRY_PACE_MS = 1200;
204
+ /** A default replay takes about this long in total, however long the real run took. */
205
+ export const REPLAY_TARGET_MS = 8000;
206
+ /** No node replays faster than this unless the replay is instant. */
207
+ export const REPLAY_NODE_MIN_MS = 250;
208
+ /** Per-node replay time: the recorded durations scaled to {@link REPLAY_TARGET_MS} in total (never slowed down). */
209
+ export function scaledPace(fixture, targetMs = REPLAY_TARGET_MS) {
210
+ const total = Object.values(fixture.nodes).reduce((a, n) => a + n.duration_ms, 0);
211
+ const scale = total > targetMs ? targetMs / total : 1;
212
+ return (id) => Math.max(REPLAY_NODE_MIN_MS, Math.round((fixture.nodes[id]?.duration_ms ?? 0) * scale));
213
+ }
178
214
  const silentLogger = { info() { }, warn() { }, error() { }, debug() { } };
179
215
  /** The line that opens the job summary preview (record.ts cuts the preview from here). */
180
216
  export const SUMMARY_PREVIEW_LEAD = "In CI, the job summary gets this heading, this DAG and the receipt below:";
@@ -182,8 +218,16 @@ export const SUMMARY_PREVIEW_LEAD = "In CI, the job summary gets this heading, t
182
218
  export async function runTry(opts = {}) {
183
219
  const write = opts.write ?? ((s) => void process.stdout.write(s));
184
220
  const tty = opts.tty ?? Boolean(process.stdout.isTTY);
185
- const paceMs = opts.fast ? 0 : Math.max(0, opts.paceMs ?? DEFAULT_TRY_PACE_MS);
186
221
  const fixture = opts.fixture ?? loadTryFixture();
222
+ // Explicit pace wins. Otherwise real timings, scaled, on a live terminal only: a pipe, CI or a
223
+ // dumb terminal gets the replay at once (the receipt shows the recorded times either way).
224
+ const paceMs = opts.fast
225
+ ? 0
226
+ : opts.paceMs !== undefined
227
+ ? Math.max(0, opts.paceMs)
228
+ : richOutput({ isTTY: tty }, process.env)
229
+ ? scaledPace(fixture)
230
+ : 0;
187
231
  const workflow = loadTryWorkflow(fixture.workflow);
188
232
  for (const id of Object.keys(fixture.nodes)) {
189
233
  if (!workflow.nodes[id])
@@ -281,16 +325,53 @@ export async function runTry(opts = {}) {
281
325
  write(`${formatTryNext(paint)}\n\n`);
282
326
  return receipt.ok ? 0 : 1;
283
327
  }
328
+ /** The shipped fixture's label, falling back to the illustrative wording when it cannot be read. */
329
+ function shippedHelp() {
330
+ try {
331
+ const fixture = loadTryFixture();
332
+ return {
333
+ description: fixture.illustrative
334
+ ? "Replay a recorded demo run: no credentials, no network, no model calls (illustrative sample data)"
335
+ : "Replay a recorded real run: no credentials, no network, no model calls",
336
+ note: tryHelpNote(fixture),
337
+ };
338
+ }
339
+ catch {
340
+ return {
341
+ description: "Replay a recorded demo run: no credentials, no network, no model calls (illustrative sample data)",
342
+ note: TRY_HELP_NOTE,
343
+ };
344
+ }
345
+ }
284
346
  export function registerTryCommand(program) {
347
+ const help = shippedHelp();
285
348
  return program
286
349
  .command("try")
287
- .description("Replay a recorded demo run: no credentials, no network, no model calls (illustrative sample data)")
350
+ .description(help.description)
288
351
  .option("--fast", "Replay instantly instead of pacing the progress")
289
- .option("--pace <ms>", `Milliseconds each node takes to replay (default ${DEFAULT_TRY_PACE_MS})`)
352
+ .option("--pace <ms>", `Fixed milliseconds per node (default: the recorded timings scaled to about ${REPLAY_TARGET_MS / 1000}s, instant when not a live terminal)`)
290
353
  .option("--comment-file <path>", "Also write the PR-comment markdown to <path>")
291
354
  .option("--record <file.svg>", "Write the demo as an animated, self-contained SVG terminal recording to <file.svg>")
292
- .addHelpText("after", `\n${TRY_HELP_NOTE}\n`)
355
+ .option("--capture <run>", "Convert a recorded run (latest, a run id, or a path under .sweny/runs) into a try fixture instead of replaying")
356
+ .option("--repo <owner/name>", "With --capture: the repo the run was on (default: GITHUB_REPOSITORY)")
357
+ .option("--out <file>", "With --capture: write the fixture to <file> (default: stdout)")
358
+ .addHelpText("after", `\n${help.note}\n`)
293
359
  .action(async (options) => {
360
+ if (options.capture) {
361
+ try {
362
+ const { captureTryFixture } = await import("./try-capture.js");
363
+ const res = captureTryFixture({ ref: options.capture, repo: options.repo, out: options.out });
364
+ if (res.wrote)
365
+ process.stderr.write(` wrote ${res.wrote} (replay of run ${res.fixture.captured_at})\n`);
366
+ else
367
+ process.stdout.write(res.text);
368
+ }
369
+ catch (err) {
370
+ console.error(`\n could not capture the run: ${err instanceof Error ? err.message : err}\n`);
371
+ process.exitCode = 1;
372
+ }
373
+ return;
374
+ }
294
375
  if (options.record) {
295
376
  try {
296
377
  const { recordTrySvgToFile } = await import("./record.js");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.206",
3
+ "version": "0.1.208",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"