@jsleekr/graft 5.8.0 → 6.0.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/dist/index.js CHANGED
@@ -5,7 +5,17 @@ import * as path from 'node:path';
5
5
  import { compile, compileAndWrite, compileToProgram } from './compiler.js';
6
6
  import { VERSION } from './version.js';
7
7
  import { formatTokenReport } from './format.js';
8
- const KNOWN_BACKENDS = new Set(['claude']);
8
+ import { formatProgram } from './formatter.js';
9
+ import { ClaudeCodeBackend } from './codegen/claude-backend.js';
10
+ import { GenericBackend } from './codegen/generic-backend.js';
11
+ const KNOWN_BACKENDS = new Set(['claude', 'generic']);
12
+ function resolveBackend(name) {
13
+ switch (name) {
14
+ case 'claude': return new ClaudeCodeBackend();
15
+ case 'generic': return new GenericBackend();
16
+ default: throw new Error(`Unknown backend: ${name}`);
17
+ }
18
+ }
9
19
  const program = new Command();
10
20
  program
11
21
  .name('graft')
@@ -23,9 +33,10 @@ program
23
33
  process.exit(1);
24
34
  }
25
35
  const source = readSource(file);
36
+ const backend = resolveBackend(opts.backend);
26
37
  let result;
27
38
  try {
28
- result = compileAndWrite(source, path.resolve(file), path.resolve(opts.outDir));
39
+ result = compileAndWrite(source, path.resolve(file), path.resolve(opts.outDir), backend);
29
40
  }
30
41
  catch (e) {
31
42
  console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
@@ -91,10 +102,14 @@ program
91
102
  .option('--input <file>', 'input JSON file')
92
103
  .option('--dry-run', 'simulate execution without spawning subprocesses')
93
104
  .option('--verbose', 'print execution details')
105
+ .option('--json', 'output results as JSON')
94
106
  .option('--timeout <seconds>', 'subprocess timeout in seconds', '300')
95
107
  .option('--work-dir <dir>', 'working directory for execution')
96
108
  .action(async (file, opts) => {
97
- const { run } = await import('./runner.js');
109
+ const { run, getProgram } = await import('./runner.js');
110
+ const { formatRunResult } = await import('./runtime/result-formatter.js');
111
+ const { validateResult, formatQualityReport } = await import('./runtime/result-validator.js');
112
+ const { generateFeedback, formatSuggestions } = await import('./runtime/feedback.js');
98
113
  const result = await run({
99
114
  sourceFile: file,
100
115
  inputFile: opts.input,
@@ -103,29 +118,28 @@ program
103
118
  verbose: opts.verbose,
104
119
  timeoutMs: parseInt(opts.timeout, 10) * 1000,
105
120
  });
121
+ // Get the program for validation
122
+ const prog = getProgram();
123
+ // Format result
124
+ console.log('\n' + formatRunResult(result, prog, { json: opts.json, verbose: opts.verbose }));
125
+ // Quality validation + feedback (skip for JSON mode)
126
+ if (!opts.json && prog) {
127
+ const report = validateResult(result, prog);
128
+ console.log('\n' + formatQualityReport(report));
129
+ const suggestions = generateFeedback(report, prog);
130
+ if (suggestions.length > 0) {
131
+ console.log(formatSuggestions(suggestions));
132
+ }
133
+ }
106
134
  if (!result.success) {
107
- console.error('\nExecution failed:');
108
- for (const err of result.errors)
109
- console.error(` ${err}`);
110
135
  process.exit(1);
111
136
  }
112
- console.log(`\nGraph '${result.graph}' completed in ${result.totalDurationMs}ms`);
113
- console.log(`Nodes executed: ${result.nodeResults.length}`);
114
- for (const nr of result.nodeResults) {
115
- const status = nr.success ? 'OK' : 'FAILED';
116
- console.log(` ${nr.node.padEnd(20)} ${status.padEnd(8)} ${nr.durationMs}ms`);
117
- }
118
- if (result.finalOutput !== null) {
119
- console.log('\nFinal output:');
120
- console.log(JSON.stringify(result.finalOutput, null, 2));
121
- }
122
- console.log('');
123
137
  });
124
138
  program
125
139
  .command('init')
126
140
  .description('Scaffold a new Graft project')
127
141
  .argument('<name>', 'project name')
128
- .action((name) => {
142
+ .action(async (name) => {
129
143
  const dir = path.resolve(name);
130
144
  if (fs.existsSync(dir)) {
131
145
  console.error(`Error: directory '${name}' already exists`);
@@ -134,6 +148,7 @@ program
134
148
  fs.mkdirSync(dir, { recursive: true });
135
149
  const baseName = path.basename(name);
136
150
  const safeName = baseName.replace(/[^a-zA-Z0-9]/g, '_').replace(/^_+|_+$/g, '') || 'pipeline';
151
+ // Generate pipeline.gft starter template
137
152
  fs.writeFileSync(path.join(dir, 'pipeline.gft'), `// ${safeName} — a simple two-node pipeline
138
153
 
139
154
  context Input(max_tokens: 500) {
@@ -161,13 +176,58 @@ edge Analyst -> Reviewer | select(answer, confidence) | compact
161
176
  graph ${safeName}(input: Input, output: Output, budget: 10k) {
162
177
  Analyst -> Reviewer -> done
163
178
  }
179
+ `);
180
+ // Generate .claude/CLAUDE.md with .gft spec so Claude Code natively understands Graft
181
+ const claudeDir = path.join(dir, '.claude');
182
+ fs.mkdirSync(claudeDir, { recursive: true });
183
+ const { buildSystemPrompt } = await import('./generator.js');
184
+ const gftSpec = buildSystemPrompt();
185
+ fs.writeFileSync(path.join(claudeDir, 'CLAUDE.md'), `# ${safeName}
186
+
187
+ This project uses **Graft** (.gft) for defining multi-agent pipelines.
188
+
189
+ ## Working with .gft files
190
+
191
+ When the user asks to create, modify, or manage pipelines:
192
+ 1. Write or edit \`.gft\` files using the syntax below
193
+ 2. Run \`graft compile <file.gft>\` to generate the \`.claude/\` harness structure
194
+ 3. Run \`graft check <file.gft>\` to validate without generating files
195
+
196
+ ## CLI Commands
197
+
198
+ \`\`\`bash
199
+ graft compile <file.gft> [--out-dir <dir>] # Compile to harness structure
200
+ graft check <file.gft> # Parse + analyze only
201
+ graft run <file.gft> --input <json> # Compile, execute, validate, suggest fixes
202
+ graft fmt <file.gft> [-w] # Format .gft source
203
+ graft visualize <file.gft> # Output pipeline DAG as Mermaid
204
+ graft watch <file.gft> # Watch and recompile on changes
205
+ \`\`\`
206
+
207
+ ## After Pipeline Execution
208
+
209
+ When a pipeline run completes, \`graft run\` automatically:
210
+ 1. Shows a formatted result summary (nodes, tokens, timing)
211
+ 2. Validates output against the .gft schema (types, ranges, empty fields)
212
+ 3. Suggests .gft modifications if quality issues are found
213
+
214
+ If the quality report shows issues:
215
+ - Empty fields → suggest increasing node output budget
216
+ - Budget exhaustion → suggest adding edge transforms (select, compact, truncate)
217
+ - Node failures → suggest adding on_failure: retry(N)
218
+ - Type mismatches → check the node's prompt and produces schema
219
+
220
+ Apply the suggested fixes to the .gft file, then run \`graft compile\` and \`graft run\` again.
221
+
222
+ ${gftSpec}
164
223
  `);
165
224
  console.log(`\nCreated ${name}/`);
166
- console.log(` pipeline.gft`);
225
+ console.log(` pipeline.gft — starter pipeline template`);
226
+ console.log(` .claude/CLAUDE.md — Graft spec for Claude Code`);
167
227
  console.log(`\nNext steps:`);
168
228
  console.log(` cd ${name}`);
169
229
  console.log(` graft compile pipeline.gft`);
170
- console.log(` # Open in Claude Code to run the pipeline`);
230
+ console.log(` # Open in Claude Code it already knows .gft syntax`);
171
231
  console.log('');
172
232
  });
173
233
  program
@@ -290,6 +350,146 @@ program
290
350
  }
291
351
  console.log(lines.join('\n'));
292
352
  });
353
+ program
354
+ .command('fmt')
355
+ .description('Format .gft source file')
356
+ .argument('<file>', '.gft source file')
357
+ .option('--check', 'check if file is already formatted (exit 1 if not)')
358
+ .option('-w, --write', 'write formatted output back to the file')
359
+ .action((file, opts) => {
360
+ const source = readSource(file);
361
+ const result = compileToProgram(source, path.resolve(file));
362
+ if (!result.success || !result.program) {
363
+ console.error('\n✗ Parse failed:\n');
364
+ for (const err of result.errors) {
365
+ console.error(err.format(source, file));
366
+ console.error('');
367
+ }
368
+ process.exit(1);
369
+ }
370
+ const formatted = formatProgram(result.program);
371
+ if (opts.check) {
372
+ if (source === formatted) {
373
+ console.log(`✓ ${file} is already formatted`);
374
+ }
375
+ else {
376
+ console.log(`✗ ${file} needs formatting`);
377
+ process.exit(1);
378
+ }
379
+ return;
380
+ }
381
+ if (opts.write) {
382
+ fs.writeFileSync(path.resolve(file), formatted, 'utf-8');
383
+ console.log(`✓ Formatted ${file}`);
384
+ return;
385
+ }
386
+ // Default: print to stdout
387
+ process.stdout.write(formatted);
388
+ });
389
+ program
390
+ .command('test')
391
+ .description('Test a .gft pipeline with mock data (dry-run + validation)')
392
+ .argument('<file>', '.gft source file')
393
+ .option('--input <json>', 'input JSON string or file path')
394
+ .option('--verbose', 'print detailed node outputs')
395
+ .action(async (file, opts) => {
396
+ const { runTest } = await import('./test-runner.js');
397
+ const source = readSource(file);
398
+ let input;
399
+ if (opts.input) {
400
+ // Try as JSON string first, then as file path
401
+ try {
402
+ input = JSON.parse(opts.input);
403
+ }
404
+ catch {
405
+ const inputPath = path.resolve(opts.input);
406
+ if (fs.existsSync(inputPath)) {
407
+ try {
408
+ input = JSON.parse(fs.readFileSync(inputPath, 'utf-8'));
409
+ }
410
+ catch (e) {
411
+ console.error(`Error: failed to parse input file: ${e instanceof Error ? e.message : String(e)}`);
412
+ process.exit(1);
413
+ }
414
+ }
415
+ else {
416
+ console.error(`Error: --input is not valid JSON and file not found: ${opts.input}`);
417
+ process.exit(1);
418
+ }
419
+ }
420
+ }
421
+ const result = await runTest({
422
+ source,
423
+ sourceFile: path.resolve(file),
424
+ input,
425
+ verbose: opts.verbose,
426
+ });
427
+ if (result.compileErrors.length > 0) {
428
+ console.error('\nCompilation failed:');
429
+ for (const err of result.compileErrors)
430
+ console.error(` ${err}`);
431
+ process.exit(1);
432
+ }
433
+ console.log(`\nTest input: ${JSON.stringify(result.inputUsed)}`);
434
+ console.log('');
435
+ let allPassed = true;
436
+ for (const nr of result.nodeResults) {
437
+ const status = nr.passed ? 'PASS' : 'FAIL';
438
+ const icon = nr.passed ? '+' : 'x';
439
+ console.log(` [${icon}] ${nr.node.padEnd(20)} ${status}`);
440
+ if (!nr.passed) {
441
+ allPassed = false;
442
+ for (const err of nr.validationErrors) {
443
+ console.log(` ${err}`);
444
+ }
445
+ }
446
+ if (opts.verbose && nr.output) {
447
+ console.log(` output: ${JSON.stringify(nr.output)}`);
448
+ }
449
+ }
450
+ console.log('');
451
+ if (allPassed) {
452
+ console.log(`All ${result.nodeResults.length} nodes passed validation.`);
453
+ }
454
+ else {
455
+ const failed = result.nodeResults.filter(r => !r.passed).length;
456
+ console.log(`${failed} of ${result.nodeResults.length} nodes failed validation.`);
457
+ process.exit(1);
458
+ }
459
+ console.log('');
460
+ });
461
+ program
462
+ .command('generate')
463
+ .description('Generate .gft from a natural language description (requires Claude Code CLI)')
464
+ .argument('<description>', 'what the pipeline should do')
465
+ .option('--output <file>', 'write .gft to file (default: stdout)')
466
+ .action(async (description, opts) => {
467
+ const { generateGft } = await import('./generator.js');
468
+ try {
469
+ const result = await generateGft(description, { output: opts.output });
470
+ if (result.errors.length > 0) {
471
+ console.error('\nGeneration completed with validation errors:\n');
472
+ for (const err of result.errors) {
473
+ console.error(err.format(result.source, 'generated.gft'));
474
+ console.error('');
475
+ }
476
+ console.error('--- Raw output (fix manually) ---\n');
477
+ console.log(result.source);
478
+ process.exit(1);
479
+ }
480
+ if (opts.output) {
481
+ fs.writeFileSync(path.resolve(opts.output), result.source, 'utf-8');
482
+ console.error(`✓ Generated ${opts.output}`);
483
+ }
484
+ else {
485
+ process.stdout.write(result.source);
486
+ }
487
+ }
488
+ catch (e) {
489
+ console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
490
+ process.exit(1);
491
+ }
492
+ });
293
493
  function formatExprForMermaid(expr) {
294
494
  if (expr.kind === 'binary') {
295
495
  const left = expr.left.kind === 'field_access' ? expr.left.segments[0] : '?';
package/dist/runner.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import { RunResult, SpawnerFn } from './runtime/executor.js';
2
+ import { Program } from './parser/ast.js';
2
3
  export type { RunResult, RunOptions, SpawnerFn } from './runtime/executor.js';
4
+ /** Get the Program from the last run() call (for validation/feedback). */
5
+ export declare function getProgram(): Program | undefined;
3
6
  export interface RunInput {
4
7
  sourceFile: string;
5
8
  inputFile?: string;
package/dist/runner.js CHANGED
@@ -2,6 +2,11 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { compile } from './compiler.js';
4
4
  import { Executor } from './runtime/executor.js';
5
+ let _lastProgram;
6
+ /** Get the Program from the last run() call (for validation/feedback). */
7
+ export function getProgram() {
8
+ return _lastProgram;
9
+ }
5
10
  export async function run(opts) {
6
11
  const sourceFile = path.resolve(opts.sourceFile);
7
12
  if (!fs.existsSync(sourceFile)) {
@@ -11,8 +16,10 @@ export async function run(opts) {
11
16
  const workDir = opts.workDir ?? path.dirname(sourceFile);
12
17
  const compileResult = compile(source, sourceFile);
13
18
  if (!compileResult.success || !compileResult.program) {
19
+ _lastProgram = undefined;
14
20
  return { success: false, graph: '', nodeResults: [], finalOutput: null, totalDurationMs: 0, errors: compileResult.errors.map(e => e.message) };
15
21
  }
22
+ _lastProgram = compileResult.program;
16
23
  const programIndex = compileResult.index;
17
24
  let input;
18
25
  if (opts.input) {
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Feedback generator — produces .gft modification suggestions from quality reports.
3
+ */
4
+ import { Program } from '../parser/ast.js';
5
+ import { QualityReport } from './result-validator.js';
6
+ export interface Suggestion {
7
+ type: 'budget' | 'retry' | 'transform' | 'schema';
8
+ severity: 'info' | 'warning' | 'error';
9
+ node: string;
10
+ message: string;
11
+ fix?: string;
12
+ }
13
+ /**
14
+ * Generate .gft modification suggestions from a quality report.
15
+ */
16
+ export declare function generateFeedback(report: QualityReport, program: Program): Suggestion[];
17
+ /**
18
+ * Format suggestions for human-readable output.
19
+ */
20
+ export declare function formatSuggestions(suggestions: Suggestion[]): string;
@@ -0,0 +1,152 @@
1
+ import { ProgramIndex } from '../program-index.js';
2
+ /**
3
+ * Generate .gft modification suggestions from a quality report.
4
+ */
5
+ export function generateFeedback(report, program) {
6
+ const suggestions = [];
7
+ const index = new ProgramIndex(program);
8
+ for (const check of report.checks) {
9
+ if (check.status === 'pass')
10
+ continue;
11
+ const suggestion = checkToSuggestion(check, index);
12
+ if (suggestion)
13
+ suggestions.push(suggestion);
14
+ }
15
+ return suggestions;
16
+ }
17
+ function checkToSuggestion(check, index) {
18
+ switch (check.category) {
19
+ case 'budget':
20
+ return budgetSuggestion(check, index);
21
+ case 'empty':
22
+ return emptySuggestion(check, index);
23
+ case 'schema':
24
+ return schemaSuggestion(check, index);
25
+ case 'type':
26
+ return typeSuggestion(check);
27
+ case 'range':
28
+ return rangeSuggestion(check);
29
+ default:
30
+ return null;
31
+ }
32
+ }
33
+ function budgetSuggestion(check, index) {
34
+ if (check.status === 'fail') {
35
+ // Budget nearly exhausted — suggest adding edge transforms
36
+ const edges = [...index.edgesBySource.entries()];
37
+ const edgeHints = edges.length > 0
38
+ ? `Add edge transforms to reduce token flow. Example:\n edge ${edges[0][0]} -> ... | truncate(500)`
39
+ : 'Increase graph budget or reduce node budgets.';
40
+ return {
41
+ type: 'transform',
42
+ severity: 'error',
43
+ node: '*',
44
+ message: 'Token budget nearly exhausted.',
45
+ fix: edgeHints,
46
+ };
47
+ }
48
+ return {
49
+ type: 'budget',
50
+ severity: 'warning',
51
+ node: '*',
52
+ message: 'Token budget usage is high. Consider adding compact or truncate transforms.',
53
+ };
54
+ }
55
+ function emptySuggestion(check, index) {
56
+ const nodeDecl = check.node !== '*' ? index.nodeMap.get(check.node) : undefined;
57
+ if (nodeDecl) {
58
+ const currentOut = nodeDecl.budgetOut;
59
+ const suggestedOut = currentOut * 2;
60
+ return {
61
+ type: 'budget',
62
+ severity: 'warning',
63
+ node: check.node,
64
+ message: `Field '${check.field}' is empty. The node may need more output tokens.`,
65
+ fix: `Increase ${check.node} output budget: budget: ${formatTokens(nodeDecl.budgetIn)}/${formatTokens(suggestedOut)}`,
66
+ };
67
+ }
68
+ return {
69
+ type: 'budget',
70
+ severity: 'warning',
71
+ node: check.node,
72
+ message: `Field '${check.field}' is empty.`,
73
+ };
74
+ }
75
+ function schemaSuggestion(check, index) {
76
+ if (check.message.includes('failed') || check.message.includes('Failed')) {
77
+ const nodeDecl = check.node !== '*' ? index.nodeMap.get(check.node) : undefined;
78
+ if (nodeDecl && !nodeDecl.onFailure) {
79
+ return {
80
+ type: 'retry',
81
+ severity: 'error',
82
+ node: check.node,
83
+ message: `Node '${check.node}' failed. Add a failure strategy.`,
84
+ fix: `Add to ${check.node}: on_failure: retry(2)`,
85
+ };
86
+ }
87
+ return {
88
+ type: 'retry',
89
+ severity: 'error',
90
+ node: check.node,
91
+ message: `Node '${check.node}' failed.`,
92
+ };
93
+ }
94
+ if (check.message.includes('Missing field')) {
95
+ return {
96
+ type: 'schema',
97
+ severity: 'error',
98
+ node: check.node,
99
+ message: check.message,
100
+ fix: `Check that ${check.node}'s prompt clearly requests the '${check.field}' field in its output.`,
101
+ };
102
+ }
103
+ if (check.message.includes('null')) {
104
+ return {
105
+ type: 'schema',
106
+ severity: 'error',
107
+ node: check.node,
108
+ message: `Node '${check.node}' produced null output. It may have silently failed.`,
109
+ fix: `Add to ${check.node}: on_failure: retry(2)`,
110
+ };
111
+ }
112
+ return null;
113
+ }
114
+ function typeSuggestion(check) {
115
+ return {
116
+ type: 'schema',
117
+ severity: 'error',
118
+ node: check.node,
119
+ message: `Type mismatch in ${check.node}.${check.field}: ${check.message}`,
120
+ };
121
+ }
122
+ function rangeSuggestion(check) {
123
+ return {
124
+ type: 'schema',
125
+ severity: 'error',
126
+ node: check.node,
127
+ message: `Range violation in ${check.node}.${check.field}: ${check.message}`,
128
+ };
129
+ }
130
+ function formatTokens(n) {
131
+ if (n >= 1000)
132
+ return `${Math.round(n / 1000)}k`;
133
+ return String(n);
134
+ }
135
+ /**
136
+ * Format suggestions for human-readable output.
137
+ */
138
+ export function formatSuggestions(suggestions) {
139
+ if (suggestions.length === 0)
140
+ return '';
141
+ const lines = [];
142
+ lines.push('\n\u2500\u2500 Suggestions ' + '\u2500'.repeat(35));
143
+ for (const s of suggestions) {
144
+ const icon = s.severity === 'error' ? '\u2717' : s.severity === 'warning' ? '\u26a0' : '\u2139';
145
+ lines.push(` ${icon} ${s.message}`);
146
+ if (s.fix) {
147
+ lines.push(` \u2192 ${s.fix}`);
148
+ }
149
+ }
150
+ lines.push('\u2500'.repeat(48));
151
+ return lines.join('\n');
152
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Result formatter — formats RunResult into human-readable output.
3
+ */
4
+ import { RunResult } from './executor.js';
5
+ import { Program } from '../parser/ast.js';
6
+ export interface FormatOptions {
7
+ json?: boolean;
8
+ verbose?: boolean;
9
+ }
10
+ /**
11
+ * Format a RunResult for human-readable CLI output.
12
+ */
13
+ export declare function formatRunResult(result: RunResult, program?: Program, options?: FormatOptions): string;
@@ -0,0 +1,142 @@
1
+ import { ProgramIndex } from '../program-index.js';
2
+ /**
3
+ * Format a RunResult for human-readable CLI output.
4
+ */
5
+ export function formatRunResult(result, program, options) {
6
+ if (options?.json) {
7
+ return JSON.stringify({
8
+ success: result.success,
9
+ graph: result.graph,
10
+ duration_ms: result.totalDurationMs,
11
+ nodes: result.nodeResults.map(nr => ({
12
+ node: nr.node,
13
+ success: nr.success,
14
+ duration_ms: nr.durationMs,
15
+ tokens: nr.tokenUsage
16
+ ? { input: nr.tokenUsage.inputTokens, output: nr.tokenUsage.outputTokens }
17
+ : undefined,
18
+ error: nr.error,
19
+ })),
20
+ token_usage: result.tokenUsage,
21
+ output: result.finalOutput,
22
+ errors: result.errors,
23
+ }, null, 2);
24
+ }
25
+ const lines = [];
26
+ const durationSec = (result.totalDurationMs / 1000).toFixed(1);
27
+ if (result.success) {
28
+ lines.push(`Graph '${result.graph}' completed in ${durationSec}s\n`);
29
+ }
30
+ else {
31
+ lines.push(`Graph '${result.graph}' FAILED after ${durationSec}s\n`);
32
+ }
33
+ // Node results table
34
+ const index = program ? new ProgramIndex(program) : undefined;
35
+ for (const nr of result.nodeResults) {
36
+ const icon = nr.success ? '\u2713' : '\u2717';
37
+ const nodeName = nr.node.padEnd(20);
38
+ const model = getModelShortName(nr.node, index);
39
+ const modelStr = model.padEnd(8);
40
+ const duration = `${(nr.durationMs / 1000).toFixed(1)}s`.padStart(6);
41
+ const tokens = formatNodeTokens(nr);
42
+ lines.push(` ${icon} ${nodeName} ${modelStr} ${duration} ${tokens}`);
43
+ if (!nr.success && nr.error) {
44
+ lines.push(` Error: ${nr.error}`);
45
+ }
46
+ }
47
+ // Token usage summary
48
+ if (result.tokenUsage) {
49
+ const { budget, consumed, fraction } = result.tokenUsage;
50
+ const pct = Math.round(fraction * 100);
51
+ const bar = renderBar(fraction, 30);
52
+ lines.push('');
53
+ lines.push(`Token usage: ${consumed.toLocaleString()} / ${budget.toLocaleString()} (${pct}%)`);
54
+ lines.push(` ${bar}`);
55
+ if (fraction > 0.9) {
56
+ lines.push(' \u26a0 WARNING: >90% of budget consumed');
57
+ }
58
+ }
59
+ // Final output summary
60
+ if (result.finalOutput !== null && result.finalOutput !== undefined) {
61
+ lines.push('');
62
+ const outputName = getOutputName(result, program);
63
+ lines.push(`\u2500\u2500 Final Output (${outputName}) ${'─'.repeat(Math.max(0, 40 - outputName.length))}`);
64
+ lines.push(formatOutput(result.finalOutput, options?.verbose));
65
+ lines.push('─'.repeat(48));
66
+ }
67
+ // Errors
68
+ if (result.errors.length > 0) {
69
+ lines.push('');
70
+ lines.push('Errors:');
71
+ for (const err of result.errors) {
72
+ lines.push(` \u2717 ${err}`);
73
+ }
74
+ }
75
+ return lines.join('\n');
76
+ }
77
+ function getModelShortName(nodeName, index) {
78
+ if (!index)
79
+ return '';
80
+ const node = index.nodeMap.get(nodeName);
81
+ if (!node)
82
+ return '';
83
+ return node.model;
84
+ }
85
+ function formatNodeTokens(nr) {
86
+ if (!nr.tokenUsage)
87
+ return '';
88
+ const total = nr.tokenUsage.inputTokens + nr.tokenUsage.outputTokens;
89
+ return `${total.toLocaleString()} tok`;
90
+ }
91
+ function renderBar(fraction, width) {
92
+ const filled = Math.round(fraction * width);
93
+ const empty = width - filled;
94
+ const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(empty);
95
+ return `[${bar}]`;
96
+ }
97
+ function getOutputName(result, program) {
98
+ if (program && program.graphs.length > 0) {
99
+ return program.graphs[0].output;
100
+ }
101
+ return 'Result';
102
+ }
103
+ function formatOutput(output, verbose) {
104
+ if (output === null || output === undefined)
105
+ return ' (empty)';
106
+ if (typeof output === 'string') {
107
+ return verbose ? output : truncate(output, 200);
108
+ }
109
+ if (typeof output !== 'object') {
110
+ return String(output);
111
+ }
112
+ const lines = [];
113
+ const obj = output;
114
+ for (const [key, value] of Object.entries(obj)) {
115
+ if (Array.isArray(value)) {
116
+ lines.push(` ${key}: ${value.length} items`);
117
+ if (verbose) {
118
+ for (const item of value.slice(0, 5)) {
119
+ lines.push(` - ${truncate(String(item), 100)}`);
120
+ }
121
+ if (value.length > 5)
122
+ lines.push(` ... and ${value.length - 5} more`);
123
+ }
124
+ }
125
+ else if (typeof value === 'object' && value !== null) {
126
+ const keys = Object.keys(value);
127
+ lines.push(` ${key}: {${keys.length} fields}`);
128
+ }
129
+ else if (typeof value === 'string') {
130
+ lines.push(` ${key}: ${truncate(value, verbose ? 500 : 80)}`);
131
+ }
132
+ else {
133
+ lines.push(` ${key}: ${value}`);
134
+ }
135
+ }
136
+ return lines.join('\n');
137
+ }
138
+ function truncate(s, max) {
139
+ if (s.length <= max)
140
+ return s;
141
+ return s.slice(0, max - 3) + '...';
142
+ }