@sweny-ai/core 0.1.185 → 0.1.186

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/dist/cli/runs.js CHANGED
@@ -1,12 +1,15 @@
1
1
  /**
2
2
  * `sweny runs` and `sweny runs diff`: read-only views over `.sweny/runs/`.
3
- * Metadata only. Color only when `color` is true (TTY).
3
+ * `runs` lists metadata. `runs diff` compares the metadata of two runs and,
4
+ * first, what changed in their answers (run-answers.ts). Color only when
5
+ * `color` is true (TTY and no NO_COLOR); the glyph set follows `unicode`.
6
+ * The diff always ends on one plain line with the answer counts.
4
7
  */
5
8
  import { findRun, listRuns } from "./run-history.js";
9
+ import { compareAnswers, readRunAnswers, } from "./run-answers.js";
6
10
  import { formatCost, formatReceiptDuration, formatTokenCount } from "./run-output.js";
7
11
  import { createPaint } from "./style.js";
8
- import { colorEnabled } from "./terminal.js";
9
- import { GLYPHS } from "../theme.js";
12
+ import { colorEnabled, glyphsFor, sanitizeTerminal, supportsUnicode } from "./terminal.js";
10
13
  // ── Small formatters ────────────────────────────────────────────
11
14
  export function formatAge(startedAt, nowMs = Date.now()) {
12
15
  const s = Math.max(0, Math.round((nowMs - Date.parse(startedAt)) / 1000));
@@ -20,8 +23,14 @@ export function formatAge(startedAt, nowMs = Date.now()) {
20
23
  return `${h}h ago`;
21
24
  return `${Math.floor(h / 24)}d ago`;
22
25
  }
23
- const G = GLYPHS.unicode;
24
- const ICON = { success: G.success, failed: G.failure, crashed: G.failure, skipped: G.skipped };
26
+ /** Own keys only: a status of `constructor` from a forged record must not find Object.prototype. */
27
+ const icon = (icons, status) => Object.hasOwn(icons, status) ? icons[status] : "?";
28
+ const iconsFor = (g) => ({
29
+ success: g.success,
30
+ failed: g.failure,
31
+ crashed: g.failure,
32
+ skipped: g.skipped,
33
+ });
25
34
  /** Status text in its role color: success, error, or muted for skipped. */
26
35
  function paint(text, status, color) {
27
36
  if (!color)
@@ -38,8 +47,49 @@ const costText = (n) => (n === null ? "-" : formatCost(n));
38
47
  function pad(s, w, right = false) {
39
48
  return right ? s.padStart(w) : s.padEnd(w);
40
49
  }
50
+ // ── History records are untrusted ───────────────────────────────
51
+ const finite = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
52
+ const finiteOrNull = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
53
+ /**
54
+ * A copy of a history record that is safe to print. `.sweny/runs/` is writable by an agent, so every
55
+ * string read from a record goes through {@link sanitizeTerminal} (control sequences stripped, length
56
+ * capped) and every number is coerced to a finite number. Only for display: matching and `--json`
57
+ * use the record as read.
58
+ */
59
+ export function printableRun(r) {
60
+ const t = (v, max = 64) => sanitizeTerminal(v, max);
61
+ return {
62
+ ...r,
63
+ run_id: t(r.run_id),
64
+ workflow_id: t(r.workflow_id),
65
+ workflow_hash: t(r.workflow_hash),
66
+ started_at: t(r.started_at),
67
+ status: t(r.status, 16),
68
+ duration_ms: finite(r.duration_ms),
69
+ nodes: r.nodes.map((n) => ({
70
+ id: t(n.id),
71
+ status: t(n.status, 16),
72
+ duration_ms: finite(n.duration_ms),
73
+ tool_calls: finite(n.tool_calls),
74
+ tokens: finiteOrNull(n.tokens),
75
+ cost_usd: finiteOrNull(n.cost_usd),
76
+ retries: finite(n.retries),
77
+ })),
78
+ routes: r.routes.map((e) => ({ from: t(e.from), to: t(e.to) })),
79
+ totals: {
80
+ nodes_total: finite(r.totals.nodes_total),
81
+ nodes_ok: finite(r.totals.nodes_ok),
82
+ nodes_skipped: finite(r.totals.nodes_skipped),
83
+ tool_calls: finite(r.totals.tool_calls),
84
+ tokens: finiteOrNull(r.totals.tokens),
85
+ cost_usd: finiteOrNull(r.totals.cost_usd),
86
+ },
87
+ };
88
+ }
41
89
  // ── sweny runs ──────────────────────────────────────────────────
42
- export function formatRunsTable(runs, color, nowMs = Date.now()) {
90
+ export function formatRunsTable(records, color, nowMs = Date.now(), unicode = true) {
91
+ const ICON = iconsFor(glyphsFor(unicode));
92
+ const runs = records.map(printableRun);
43
93
  if (runs.length === 0)
44
94
  return " No runs recorded yet. Run `sweny workflow run <file>` to start a history.\n";
45
95
  const rows = runs.map((r) => ({
@@ -47,7 +97,7 @@ export function formatRunsTable(runs, color, nowMs = Date.now()) {
47
97
  cells: [
48
98
  r.run_id,
49
99
  r.workflow_id,
50
- `${ICON[r.status] ?? "?"} ${r.status}`,
100
+ `${icon(ICON, r.status)} ${r.status}`,
51
101
  formatReceiptDuration(r.duration_ms),
52
102
  tokensText(r.totals?.tokens ?? null),
53
103
  costText(r.totals?.cost_usd ?? null),
@@ -82,41 +132,180 @@ const deltaTokens = (a, b) => `${sign(b - a)}${formatTokenCount(Math.abs(b - a))
82
132
  const deltaCost = (a, b) => `${sign(b - a)}${formatCost(Math.abs(b - a))}`;
83
133
  const deltaCount = (a, b) => `${sign(b - a)}${Math.abs(b - a)}`;
84
134
  /** `label a → b (+delta)` for a numeric metric; null when unchanged. */
85
- function metric(label, a, b, show, delta) {
135
+ function metric(label, a, b, show, delta, arrow = "→") {
86
136
  if (a === b)
87
137
  return null;
88
138
  if (a === null || b === null)
89
- return `${label} ${a === null ? "-" : show(a)} → ${b === null ? "-" : show(b)}`;
90
- return `${label} ${show(a)} → ${show(b)} (${delta(a, b)})`;
139
+ return `${label} ${a === null ? "-" : show(a)} ${arrow} ${b === null ? "-" : show(b)}`;
140
+ return `${label} ${show(a)} ${arrow} ${show(b)} (${delta(a, b)})`;
91
141
  }
92
- function nodeChanges(a, b) {
142
+ function nodeChanges(a, b, arrow) {
93
143
  return [
94
- metric("duration", a.duration_ms, b.duration_ms, fmtDur, deltaDuration),
95
- metric("tokens", a.tokens, b.tokens, formatTokenCount, deltaTokens),
96
- metric("cost", a.cost_usd, b.cost_usd, formatCost, deltaCost),
97
- metric("tools", a.tool_calls, b.tool_calls, String, deltaCount),
98
- metric("retries", a.retries, b.retries, String, deltaCount),
144
+ metric("duration", a.duration_ms, b.duration_ms, fmtDur, deltaDuration, arrow),
145
+ metric("tokens", a.tokens, b.tokens, formatTokenCount, deltaTokens, arrow),
146
+ metric("cost", a.cost_usd, b.cost_usd, formatCost, deltaCost, arrow),
147
+ metric("tools", a.tool_calls, b.tool_calls, String, deltaCount, arrow),
148
+ metric("retries", a.retries, b.retries, String, deltaCount, arrow),
99
149
  ].filter((x) => x !== null);
100
150
  }
101
- const routeKey = (r) => `${r.from} → ${r.to}`;
102
- export function formatRunDiff(a, b, color) {
151
+ const routeKey = (r) => `${r.from}\u0000${r.to}`;
152
+ // ── Answers section ─────────────────────────────────────────────
153
+ /** Items listed per kind (added, resolved, changed) in one list before "... and N more". The JSON keeps all. */
154
+ const MAX_ITEMS_SHOWN = 10;
155
+ const MAX_FIELDS_SHOWN = 3;
156
+ const VALUE_MAX = 56;
157
+ /** One printable line (an answer is untrusted text): see {@link sanitizeTerminal}. */
158
+ const oneLine = (s) => sanitizeTerminal(s, 10_000);
159
+ function clip(s, max, g) {
160
+ return s.length > max ? s.slice(0, max - 1) + g.ellipsis : s;
161
+ }
162
+ function showValue(v, g) {
163
+ if (v === undefined)
164
+ return "(none)";
165
+ const text = typeof v === "string" ? v : typeof v === "number" || typeof v === "boolean" ? String(v) : JSON.stringify(v);
166
+ return clip(oneLine(text), VALUE_MAX, g);
167
+ }
168
+ /** Lines in `b` not in `a` (added) and in `a` not in `b` (removed), as multisets. */
169
+ function lineDelta(a, b) {
170
+ const left = new Map();
171
+ for (const l of a.split("\n"))
172
+ left.set(l, (left.get(l) ?? 0) + 1);
173
+ let added = 0;
174
+ for (const l of b.split("\n")) {
175
+ const n = left.get(l) ?? 0;
176
+ if (n > 0)
177
+ left.set(l, n - 1);
178
+ else
179
+ added++;
180
+ }
181
+ let removed = 0;
182
+ for (const n of left.values())
183
+ removed += n;
184
+ return { added, removed };
185
+ }
186
+ /** `before -> after`, or `+X -Y lines` when either side is a long or multi-line string. */
187
+ function valueChange(before, after, g) {
188
+ // Stored text is cut at ANSWER_STRING_MAX; the change is past the cut (found by digest).
189
+ if (typeof before === "string" && before === after)
190
+ return "changed past the stored excerpt";
191
+ const long = (v) => typeof v === "string" && (v.includes("\n") || v.length > VALUE_MAX);
192
+ if (typeof before === "string" && typeof after === "string" && (long(before) || long(after))) {
193
+ const d = lineDelta(before, after);
194
+ return `+${d.added} -${d.removed} lines`;
195
+ }
196
+ return `${showValue(before, g)} ${g.arrow} ${showValue(after, g)}`;
197
+ }
198
+ const isChangedEntry = (e) => e.kind === "collection" ? e.added.length + e.resolved.length + e.changed.length > 0 : e.status !== "unchanged";
199
+ function countsHead(e, g) {
200
+ return [
201
+ e.added.length ? `${e.added.length} added` : "",
202
+ e.resolved.length ? `${e.resolved.length} resolved` : "",
203
+ e.changed.length ? `${e.changed.length} changed` : "",
204
+ e.unchanged ? `${e.unchanged} unchanged` : "",
205
+ ]
206
+ .filter(Boolean)
207
+ .join(` ${g.sep} `);
208
+ }
209
+ /**
210
+ * The answers block. Marker colors keep the palette's meanings: `+` a new item
211
+ * is `warning` (look at this), `-` a resolved one is `success`, `~` a changed
212
+ * one is `info`; counts and unchanged nodes are `muted`.
213
+ */
214
+ function formatAnswersSection(cmp, color, g) {
215
+ const p = createPaint(color);
216
+ const out = [" answers"];
217
+ const items = (marker, paintMarker, list, word) => {
218
+ for (const it of list.slice(0, MAX_ITEMS_SHOWN)) {
219
+ out.push(` ${paintMarker(marker)} ${clip(oneLine(it.label), 72, g)}`);
220
+ }
221
+ if (list.length > MAX_ITEMS_SHOWN) {
222
+ out.push(p.muted(` ${g.ellipsis} and ${list.length - MAX_ITEMS_SHOWN} more ${word}`));
223
+ }
224
+ };
225
+ const fields = (list) => {
226
+ if (list.length === 0)
227
+ return;
228
+ const w = Math.max(...list.map((f) => f.field.length));
229
+ for (const f of list.slice(0, MAX_FIELDS_SHOWN)) {
230
+ out.push(` ${pad(f.field, w)} ${valueChange(f.before, f.after, g)}`);
231
+ }
232
+ if (list.length > MAX_FIELDS_SHOWN)
233
+ out.push(p.muted(` ${g.ellipsis} and ${list.length - MAX_FIELDS_SHOWN} more fields`));
234
+ };
235
+ const nodes = [...new Set(cmp.entries.map((e) => e.node))];
236
+ for (const node of nodes) {
237
+ const changed = cmp.entries.filter((e) => e.node === node && isChangedEntry(e));
238
+ if (changed.length === 0) {
239
+ out.push(` ${node} ${p.muted("unchanged")}`);
240
+ continue;
241
+ }
242
+ out.push(` ${node}`);
243
+ const w = Math.max(...changed.map((e) => e.field.length));
244
+ for (const e of changed) {
245
+ if (e.kind === "collection") {
246
+ out.push(` ${pad(e.field, w)} ${p.muted(countsHead(e, g))}`);
247
+ items("+", p.warning, e.added, "added");
248
+ items("-", p.success, e.resolved, "resolved");
249
+ for (const c of e.changed.slice(0, MAX_ITEMS_SHOWN)) {
250
+ out.push(` ${p.info("~")} ${clip(oneLine(c.label), 72, g)}`);
251
+ fields(c.fields);
252
+ }
253
+ if (e.changed.length > MAX_ITEMS_SHOWN) {
254
+ out.push(p.muted(` ${g.ellipsis} and ${e.changed.length - MAX_ITEMS_SHOWN} more changed`));
255
+ }
256
+ }
257
+ else if (e.status === "added") {
258
+ out.push(` ${pad(e.field, w)} ${p.warning("added")} ${showValue(e.after, g)}`);
259
+ }
260
+ else if (e.status === "resolved") {
261
+ out.push(` ${pad(e.field, w)} ${p.success("resolved")} ${p.muted(`(was ${showValue(e.before, g)})`)}`);
262
+ }
263
+ else {
264
+ out.push(` ${pad(e.field, w)} ${valueChange(e.before, e.after, g)}`);
265
+ }
266
+ }
267
+ }
268
+ for (const n of cmp.not_compared) {
269
+ out.push(` ${n.node} ${p.muted(`not compared (answer in ${n.only_in} only)`)}`);
270
+ }
271
+ if (nodes.length === 0 && cmp.not_compared.length === 0)
272
+ out.push(p.muted(" no declared outputs to compare"));
273
+ return out;
274
+ }
275
+ const countsLine = (c) => `${c.added} added, ${c.resolved} resolved, ${c.changed} changed, ${c.unchanged} unchanged`;
276
+ /** The last line of the diff: plain text, no color, no glyphs, so a log or a grep always has the answer. */
277
+ function answersFinalLine(cmp, a, b) {
278
+ if (cmp.available)
279
+ return ` answers: ${countsLine(cmp.counts)}`;
280
+ if (cmp.reason === "different_workflows")
281
+ return " answers: not compared (different workflows)";
282
+ const ids = cmp.missing.map((m) => (m === "a" ? a.run_id : b.run_id)).join(" and ");
283
+ return ` answers: not recorded for ${ids} (runs made with --no-history, or by an older sweny, have none)`;
284
+ }
285
+ export function formatRunDiff(rawA, rawB, color, opts = {}) {
286
+ const a = printableRun(rawA);
287
+ const b = printableRun(rawB);
288
+ const g = glyphsFor(opts.unicode ?? true);
289
+ const ICON = iconsFor(g);
103
290
  const dim = createPaint(color).muted;
104
291
  const out = [];
105
- const hdr = (tag, r) => ` ${tag} ${r.run_id} ${r.workflow_id} ${paint(`${ICON[r.status] ?? "?"} ${r.status}`, r.status, color)} ${formatReceiptDuration(r.duration_ms)}`;
292
+ const hdr = (tag, r) => ` ${tag} ${r.run_id} ${r.workflow_id} ${paint(`${icon(ICON, r.status)} ${r.status}`, r.status, color)} ${formatReceiptDuration(r.duration_ms)}`;
106
293
  out.push(hdr("a", a), hdr("b", b), "");
294
+ if (opts.answers?.available)
295
+ out.push(...formatAnswersSection(opts.answers, color, g), "");
107
296
  const short = (h) => h.slice(0, 7);
108
297
  out.push(a.workflow_hash === b.workflow_hash
109
298
  ? ` workflow unchanged (${short(a.workflow_hash)})`
110
- : ` workflow changed ${short(a.workflow_hash)} → ${short(b.workflow_hash)}`);
299
+ : ` workflow changed ${short(a.workflow_hash)} ${g.arrow} ${short(b.workflow_hash)}`);
111
300
  if (a.workflow_id !== b.workflow_id)
112
- out.push(` id ${a.workflow_id} → ${b.workflow_id}`);
301
+ out.push(` id ${a.workflow_id} ${g.arrow} ${b.workflow_id}`);
113
302
  out.push(a.status === b.status
114
- ? ` status ${ICON[b.status]} ${b.status} (same)`
115
- : ` status ${ICON[a.status]} ${a.status} → ${paint(`${ICON[b.status]} ${b.status}`, b.status, color)}`);
303
+ ? ` status ${icon(ICON, b.status)} ${b.status} (same)`
304
+ : ` status ${icon(ICON, a.status)} ${a.status} ${g.arrow} ${paint(`${icon(ICON, b.status)} ${b.status}`, b.status, color)}`);
116
305
  const tot = [
117
- metric("duration ", a.duration_ms, b.duration_ms, formatReceiptDuration, deltaDuration),
118
- metric("tokens ", a.totals.tokens, b.totals.tokens, formatTokenCount, deltaTokens),
119
- metric("cost ", a.totals.cost_usd, b.totals.cost_usd, formatCost, deltaCost),
306
+ metric("duration ", a.duration_ms, b.duration_ms, formatReceiptDuration, deltaDuration, g.arrow),
307
+ metric("tokens ", a.totals.tokens, b.totals.tokens, formatTokenCount, deltaTokens, g.arrow),
308
+ metric("cost ", a.totals.cost_usd, b.totals.cost_usd, formatCost, deltaCost, g.arrow),
120
309
  ];
121
310
  for (const t of tot)
122
311
  if (t)
@@ -130,18 +319,21 @@ export function formatRunDiff(a, b, color) {
130
319
  const na = aNodes.get(id);
131
320
  const nb = bNodes.get(id);
132
321
  if (na && !nb) {
133
- out.push(` ${pad(id, w)} ${dim("removed")} (was ${ICON[na.status]} ${na.status})`);
322
+ out.push(` ${pad(id, w)} ${dim("removed")} (was ${icon(ICON, na.status)} ${na.status})`);
134
323
  }
135
324
  else if (!na && nb) {
136
- out.push(` ${pad(id, w)} ${paint(`added ${ICON[nb.status]} ${nb.status}`, nb.status, color)}`);
325
+ out.push(` ${pad(id, w)} ${paint(`added ${icon(ICON, nb.status)} ${nb.status}`, nb.status, color)}`);
137
326
  }
138
327
  else if (na && nb) {
139
- const st = na.status === nb.status ? ICON[nb.status] : `${ICON[na.status]} → ${paint(ICON[nb.status], nb.status, color)}`;
140
- const ch = nodeChanges(na, nb);
328
+ const st = na.status === nb.status
329
+ ? icon(ICON, nb.status)
330
+ : `${icon(ICON, na.status)} ${g.arrow} ${paint(icon(ICON, nb.status), nb.status, color)}`;
331
+ const ch = nodeChanges(na, nb, g.arrow);
141
332
  out.push(` ${pad(id, w)} ${st} ${ch.length ? ch.join(" ") : dim("no change")}`);
142
333
  }
143
334
  }
144
335
  out.push("", " routes");
336
+ const show = (key) => key.split("\u0000").join(` ${g.arrow} `);
145
337
  const ar = new Set(a.routes.map(routeKey));
146
338
  const br = new Set(b.routes.map(routeKey));
147
339
  const onlyA = [...ar].filter((r) => !br.has(r));
@@ -149,9 +341,11 @@ export function formatRunDiff(a, b, color) {
149
341
  if (onlyA.length === 0 && onlyB.length === 0)
150
342
  out.push(" identical");
151
343
  for (const r of onlyA)
152
- out.push(` - ${r} ${dim("(only in a)")}`);
344
+ out.push(` - ${show(r)} ${dim("(only in a)")}`);
153
345
  for (const r of onlyB)
154
- out.push(` + ${r} ${dim("(only in b)")}`);
346
+ out.push(` + ${show(r)} ${dim("(only in b)")}`);
347
+ if (opts.answers)
348
+ out.push("", answersFinalLine(opts.answers, a, b));
155
349
  return out.join("\n") + "\n";
156
350
  }
157
351
  /** Pick two runs: explicit refs, or the newest two runs of one workflow (--workflow, else the newest workflow with two or more runs). a = older, b = newer. */
@@ -162,9 +356,9 @@ export function pickRunsForDiff(runs, refs, workflowId) {
162
356
  const a = findRun(runs, refs[0]);
163
357
  const b = findRun(runs, refs[1]);
164
358
  if (!a)
165
- return { error: `No run matches "${refs[0]}". Run \`sweny runs\` to list ids.` };
359
+ return { error: `No run matches "${sanitizeTerminal(refs[0], 64)}". Run \`sweny runs\` to list ids.` };
166
360
  if (!b)
167
- return { error: `No run matches "${refs[1]}". Run \`sweny runs\` to list ids.` };
361
+ return { error: `No run matches "${sanitizeTerminal(refs[1], 64)}". Run \`sweny runs\` to list ids.` };
168
362
  return { a, b };
169
363
  }
170
364
  // No --workflow: the newest workflow that has at least two runs to compare.
@@ -174,10 +368,37 @@ export function pickRunsForDiff(runs, refs, workflowId) {
174
368
  const wf = workflowId ?? runs.find((r) => (counts.get(r.workflow_id) ?? 0) >= 2)?.workflow_id ?? runs[0]?.workflow_id;
175
369
  const same = wf ? runs.filter((r) => r.workflow_id === wf) : [];
176
370
  if (same.length < 2) {
177
- return { error: wf ? `Need two runs of "${wf}" to diff; found ${same.length}.` : "No runs recorded yet." };
371
+ return {
372
+ error: wf
373
+ ? `Need two runs of "${sanitizeTerminal(wf, 64)}" to diff; found ${same.length}. Run it again, then \`sweny runs diff\`.`
374
+ : "No runs recorded yet. Run `sweny workflow run <file>` twice, then `sweny runs diff`.",
375
+ };
178
376
  }
179
377
  return { a: same[1], b: same[0] };
180
378
  }
379
+ /**
380
+ * Body of `sweny runs diff`. Returns the exit code (0 diffed, 1 nothing to diff).
381
+ *
382
+ * `--json` prints `{ schema_version: 1, a: RunRecord, b: RunRecord, answers }` and never ANSI.
383
+ * `answers` is `{ available: true, counts, entries, not_compared }` or
384
+ * `{ available: false, reason: "different_workflows" | "missing_answers", missing? }`.
385
+ */
386
+ export function runRunsDiff(refs, opts, io) {
387
+ const env = io.env ?? process.env;
388
+ const picked = pickRunsForDiff(listRuns(io.cwd), refs, opts.workflow);
389
+ if ("error" in picked) {
390
+ io.stderr.write(createPaint(colorEnabled(io.stderr, env)).error(` ${picked.error}`) + "\n");
391
+ return 1;
392
+ }
393
+ const { a, b } = picked;
394
+ const answers = compareAnswers(readRunAnswers(a.run_id, io.cwd), readRunAnswers(b.run_id, io.cwd));
395
+ if (opts.json) {
396
+ io.stdout.write(JSON.stringify({ schema_version: 1, a, b, answers }, null, 2) + "\n");
397
+ return 0;
398
+ }
399
+ io.stdout.write("\n" + formatRunDiff(a, b, colorEnabled(io.stdout, env), { answers, unicode: supportsUnicode(env) }) + "\n");
400
+ return 0;
401
+ }
181
402
  // ── Command wiring ──────────────────────────────────────────────
182
403
  export function registerRunsCommand(program) {
183
404
  const runsCmd = program
@@ -201,22 +422,18 @@ export function registerRunsCommand(program) {
201
422
  process.stdout.write(JSON.stringify(runs, null, 2) + "\n");
202
423
  return;
203
424
  }
204
- process.stdout.write("\n" + formatRunsTable(runs, colorEnabled(process.stdout)) + "\n");
425
+ process.stdout.write("\n" + formatRunsTable(runs, colorEnabled(process.stdout), Date.now(), supportsUnicode()) + "\n");
205
426
  });
206
427
  runsCmd
207
428
  .command("diff [a] [b]")
208
- .description("Compare two runs (default: the last two runs of the same workflow)")
429
+ .description("Compare two runs: what changed in the answers, then how they ran (default: the last two of a workflow)")
209
430
  .option("--workflow <id>", "Workflow to pick the default pair from")
431
+ .option("--json", "Output { schema_version, a, b, answers } as JSON")
210
432
  .action((a, b, opts) => {
211
433
  const refs = [a, b].filter((x) => x !== undefined);
212
- const runs = listRuns();
213
- const picked = pickRunsForDiff(runs, refs, opts.workflow);
214
- if ("error" in picked) {
215
- console.error(createPaint(colorEnabled(process.stderr)).error(` ${picked.error}`));
216
- process.exitCode = 1;
217
- return;
218
- }
219
- process.stdout.write("\n" + formatRunDiff(picked.a, picked.b, colorEnabled(process.stdout)) + "\n");
434
+ const code = runRunsDiff(refs, opts, { stdout: process.stdout, stderr: process.stderr });
435
+ if (code !== 0)
436
+ process.exitCode = code;
220
437
  });
221
438
  return runsCmd;
222
439
  }
@@ -42,6 +42,15 @@ export declare function ticketBoxFor(unicode: boolean): TicketBox;
42
42
  export declare function spinnerFramesFor(unicode: boolean): readonly string[];
43
43
  /** The glyphs for the current process. */
44
44
  export declare function glyphs(env?: Env): GlyphSet;
45
+ /**
46
+ * Text that came from a file an agent can write (run history, say), made safe to print: terminal
47
+ * control sequences are removed whole (CSI such as `ESC[2J`, OSC such as the `ESC]8;;url` hyperlink
48
+ * and title sequences, DCS/PM/APC strings, lone ESC pairs, their 8-bit C1 forms), then any other
49
+ * C0/C1 control character, bidi override and line separator is dropped or becomes a space; runs of
50
+ * whitespace collapse to one space. Printable Unicode is kept. `max` caps the length (default 200,
51
+ * `...` marks a cut).
52
+ */
53
+ export declare function sanitizeTerminal(text: unknown, max?: number): string;
45
54
  /** Replace single-column Unicode drawing characters with their ASCII stand-ins. */
46
55
  export declare function toAsciiDrawing(text: string): string;
47
56
  export {};
@@ -80,6 +80,28 @@ export function spinnerFramesFor(unicode) {
80
80
  export function glyphs(env = process.env) {
81
81
  return glyphsFor(supportsUnicode(env));
82
82
  }
83
+ /**
84
+ * Text that came from a file an agent can write (run history, say), made safe to print: terminal
85
+ * control sequences are removed whole (CSI such as `ESC[2J`, OSC such as the `ESC]8;;url` hyperlink
86
+ * and title sequences, DCS/PM/APC strings, lone ESC pairs, their 8-bit C1 forms), then any other
87
+ * C0/C1 control character, bidi override and line separator is dropped or becomes a space; runs of
88
+ * whitespace collapse to one space. Printable Unicode is kept. `max` caps the length (default 200,
89
+ * `...` marks a cut).
90
+ */
91
+ export function sanitizeTerminal(text, max = 200) {
92
+ const s = String(text ?? "")
93
+ // OSC (ESC ] / 0x9d) up to BEL or ST; DCS, SOS, PM, APC (ESC P X ^ _ / 0x90 0x98 0x9e 0x9f) up to ST
94
+ .replace(/(?:\x1b\]|\x9d)[^\x07\x1b\x9c]*(?:\x07|\x1b\\|\x9c)?/g, "")
95
+ .replace(/(?:\x1b[PX^_]|[\x90\x98\x9e\x9f])[^\x1b\x9c]*(?:\x1b\\|\x9c)?/g, "")
96
+ // CSI (ESC [ / 0x9b): parameters, intermediates, final byte
97
+ .replace(/(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]?/g, "")
98
+ // any other ESC pair
99
+ .replace(/\x1b[ -~]?/g, "")
100
+ .replace(/[\u2028\u2029\s]+/g, " ")
101
+ .replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, "")
102
+ .trim();
103
+ return s.length > max ? s.slice(0, Math.max(0, max - 3)) + "..." : s;
104
+ }
83
105
  function truthy(v) {
84
106
  return v !== undefined && v !== "" && v !== "0" && v.toLowerCase() !== "false";
85
107
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.185",
3
+ "version": "0.1.186",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"