@tuttiai/cli 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -0
- package/dist/index.js +661 -226
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { config } from "dotenv";
|
|
5
|
-
import { createLogger as
|
|
5
|
+
import { createLogger as createLogger10 } from "@tuttiai/core";
|
|
6
6
|
import { Command } from "commander";
|
|
7
7
|
|
|
8
8
|
// src/commands/init.ts
|
|
@@ -356,7 +356,7 @@ function templatesCommand() {
|
|
|
356
356
|
|
|
357
357
|
// src/commands/run.ts
|
|
358
358
|
import { existsSync as existsSync2 } from "fs";
|
|
359
|
-
import { resolve } from "path";
|
|
359
|
+
import { resolve as resolve2 } from "path";
|
|
360
360
|
import { createInterface } from "readline/promises";
|
|
361
361
|
import chalk2 from "chalk";
|
|
362
362
|
import ora from "ora";
|
|
@@ -367,11 +367,122 @@ import {
|
|
|
367
367
|
OpenAIProvider,
|
|
368
368
|
GeminiProvider,
|
|
369
369
|
SecretsManager,
|
|
370
|
+
InMemorySessionStore,
|
|
370
371
|
createLogger as createLogger2
|
|
371
372
|
} from "@tuttiai/core";
|
|
373
|
+
|
|
374
|
+
// src/watch/score-watcher.ts
|
|
375
|
+
import { dirname, resolve } from "path";
|
|
376
|
+
import { EventEmitter } from "events";
|
|
377
|
+
import chokidar from "chokidar";
|
|
378
|
+
import { validateScore } from "@tuttiai/core";
|
|
379
|
+
var DEFAULT_DEBOUNCE_MS = 200;
|
|
380
|
+
var DEFAULT_IGNORED = [
|
|
381
|
+
/(^|[/\\])\../,
|
|
382
|
+
// dotfiles (.git, .env, etc.)
|
|
383
|
+
/node_modules/,
|
|
384
|
+
/[/\\]dist[/\\]/,
|
|
385
|
+
/[/\\]coverage[/\\]/
|
|
386
|
+
];
|
|
387
|
+
async function defaultLoadScore(path) {
|
|
388
|
+
const absolute = resolve(path);
|
|
389
|
+
const { pathToFileURL } = await import("url");
|
|
390
|
+
const url = pathToFileURL(absolute).href + "?t=" + Date.now().toString(36);
|
|
391
|
+
const mod = await import(url);
|
|
392
|
+
if (!mod.default) {
|
|
393
|
+
throw new Error(
|
|
394
|
+
"Score file has no default export: " + path + " \u2014 your score must export `defineScore({ ... })` as its default."
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
validateScore(mod.default);
|
|
398
|
+
return mod.default;
|
|
399
|
+
}
|
|
400
|
+
var ReactiveScore = class extends EventEmitter {
|
|
401
|
+
_current;
|
|
402
|
+
scorePath;
|
|
403
|
+
load;
|
|
404
|
+
debounceMs;
|
|
405
|
+
watcher;
|
|
406
|
+
debounceTimer;
|
|
407
|
+
closed = false;
|
|
408
|
+
_pendingReload = false;
|
|
409
|
+
constructor(initialScore, scorePath, options = {}) {
|
|
410
|
+
super();
|
|
411
|
+
this._current = initialScore;
|
|
412
|
+
this.scorePath = resolve(scorePath);
|
|
413
|
+
this.load = options.load ?? defaultLoadScore;
|
|
414
|
+
this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
415
|
+
const watchTargets = [
|
|
416
|
+
this.scorePath,
|
|
417
|
+
dirname(this.scorePath),
|
|
418
|
+
...options.extraPaths ?? []
|
|
419
|
+
];
|
|
420
|
+
this.watcher = chokidar.watch(watchTargets, {
|
|
421
|
+
ignored: DEFAULT_IGNORED,
|
|
422
|
+
ignoreInitial: true,
|
|
423
|
+
// awaitWriteFinish: guard against partial writes from editors that
|
|
424
|
+
// save atomically via rename/move.
|
|
425
|
+
awaitWriteFinish: {
|
|
426
|
+
stabilityThreshold: 50,
|
|
427
|
+
pollInterval: 20
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
this.watcher.on("change", (path) => this.handleChange(path));
|
|
431
|
+
this.watcher.on("add", (path) => this.handleChange(path));
|
|
432
|
+
}
|
|
433
|
+
/** The most recent successfully-loaded score. Never stale. */
|
|
434
|
+
get current() {
|
|
435
|
+
return this._current;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* True when a file change has been observed and a reload is pending
|
|
439
|
+
* (or just completed and not yet consumed). Readers call
|
|
440
|
+
* {@link consumePendingReload} to clear the flag when they've taken
|
|
441
|
+
* action on the new config.
|
|
442
|
+
*/
|
|
443
|
+
get pendingReload() {
|
|
444
|
+
return this._pendingReload;
|
|
445
|
+
}
|
|
446
|
+
consumePendingReload() {
|
|
447
|
+
this._pendingReload = false;
|
|
448
|
+
}
|
|
449
|
+
/** Release the underlying filesystem watchers. */
|
|
450
|
+
async close() {
|
|
451
|
+
this.closed = true;
|
|
452
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
453
|
+
await this.watcher.close();
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Force an immediate reload without waiting for a filesystem event.
|
|
457
|
+
* Exposed for tests and for the `reload` REPL command.
|
|
458
|
+
*/
|
|
459
|
+
async reloadNow() {
|
|
460
|
+
if (this.closed) return;
|
|
461
|
+
this.emit("reloading");
|
|
462
|
+
try {
|
|
463
|
+
const next = await this.load(this.scorePath);
|
|
464
|
+
this._current = next;
|
|
465
|
+
this._pendingReload = true;
|
|
466
|
+
this.emit("reloaded", next);
|
|
467
|
+
} catch (err) {
|
|
468
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
469
|
+
this.emit("reload-failed", error);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
handleChange(path) {
|
|
473
|
+
if (this.closed) return;
|
|
474
|
+
this.emit("file-change", path);
|
|
475
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
476
|
+
this.debounceTimer = setTimeout(() => {
|
|
477
|
+
void this.reloadNow();
|
|
478
|
+
}, this.debounceMs);
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
// src/commands/run.ts
|
|
372
483
|
var logger2 = createLogger2("tutti-cli");
|
|
373
|
-
async function runCommand(scorePath) {
|
|
374
|
-
const file =
|
|
484
|
+
async function runCommand(scorePath, options = {}) {
|
|
485
|
+
const file = resolve2(scorePath ?? "./tutti.score.ts");
|
|
375
486
|
if (!existsSync2(file)) {
|
|
376
487
|
logger2.error({ file }, "Score file not found");
|
|
377
488
|
console.error(chalk2.dim('Run "tutti-ai init" to create a new project.'));
|
|
@@ -401,90 +512,128 @@ async function runCommand(scorePath) {
|
|
|
401
512
|
}
|
|
402
513
|
}
|
|
403
514
|
}
|
|
404
|
-
|
|
405
|
-
agent.
|
|
406
|
-
|
|
407
|
-
|
|
515
|
+
const applyRunDefaults = (cfg) => {
|
|
516
|
+
for (const agent of Object.values(cfg.agents)) {
|
|
517
|
+
agent.streaming = true;
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
applyRunDefaults(score);
|
|
521
|
+
const sharedSessions = options.watch ? new InMemorySessionStore() : void 0;
|
|
408
522
|
const spinner = ora({ color: "cyan" });
|
|
409
523
|
let streaming = false;
|
|
410
|
-
runtime
|
|
411
|
-
|
|
412
|
-
});
|
|
413
|
-
runtime.events.on("llm:request", () => {
|
|
414
|
-
spinner.start("Thinking...");
|
|
415
|
-
});
|
|
416
|
-
runtime.events.on("token:stream", (e) => {
|
|
417
|
-
if (!streaming) {
|
|
418
|
-
spinner.stop();
|
|
419
|
-
streaming = true;
|
|
420
|
-
}
|
|
421
|
-
process.stdout.write(e.text);
|
|
422
|
-
});
|
|
423
|
-
runtime.events.on("llm:response", () => {
|
|
424
|
-
if (streaming) {
|
|
425
|
-
process.stdout.write("\n");
|
|
426
|
-
} else {
|
|
427
|
-
spinner.stop();
|
|
428
|
-
}
|
|
429
|
-
});
|
|
430
|
-
runtime.events.on("tool:start", (e) => {
|
|
431
|
-
if (streaming) {
|
|
432
|
-
process.stdout.write(chalk2.dim("\n [using: " + e.tool_name + "]"));
|
|
433
|
-
} else {
|
|
434
|
-
spinner.stop();
|
|
435
|
-
console.log(chalk2.dim(" [using: " + e.tool_name + "]"));
|
|
436
|
-
}
|
|
437
|
-
});
|
|
438
|
-
runtime.events.on("tool:end", (e) => {
|
|
439
|
-
if (streaming) {
|
|
440
|
-
process.stdout.write(chalk2.dim(" [done: " + e.tool_name + "]\n"));
|
|
441
|
-
}
|
|
442
|
-
});
|
|
443
|
-
runtime.events.on("tool:error", (e) => {
|
|
444
|
-
spinner.stop();
|
|
445
|
-
logger2.error({ tool: e.tool_name }, "Tool error");
|
|
446
|
-
});
|
|
447
|
-
runtime.events.on("security:injection_detected", (e) => {
|
|
448
|
-
logger2.warn({ tool: e.tool_name }, "Potential prompt injection detected");
|
|
449
|
-
});
|
|
450
|
-
runtime.events.on("budget:warning", () => {
|
|
451
|
-
logger2.warn("Approaching token budget (80%)");
|
|
452
|
-
});
|
|
453
|
-
runtime.events.on("budget:exceeded", () => {
|
|
454
|
-
logger2.error("Token budget exceeded \u2014 stopping");
|
|
455
|
-
});
|
|
524
|
+
let runtime = buildRuntime(score, sharedSessions);
|
|
525
|
+
attachListeners(runtime);
|
|
456
526
|
const rl = createInterface({
|
|
457
527
|
input: process.stdin,
|
|
458
528
|
output: process.stdout
|
|
459
529
|
});
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
530
|
+
function attachListeners(r) {
|
|
531
|
+
r.events.on("agent:start", (e) => {
|
|
532
|
+
logger2.info({ agent: e.agent_name }, "Running agent");
|
|
533
|
+
});
|
|
534
|
+
r.events.on("llm:request", () => {
|
|
535
|
+
spinner.start("Thinking...");
|
|
536
|
+
});
|
|
537
|
+
r.events.on("token:stream", (e) => {
|
|
538
|
+
if (!streaming) {
|
|
539
|
+
spinner.stop();
|
|
540
|
+
streaming = true;
|
|
541
|
+
}
|
|
542
|
+
process.stdout.write(e.text);
|
|
543
|
+
});
|
|
544
|
+
r.events.on("llm:response", () => {
|
|
545
|
+
if (streaming) {
|
|
546
|
+
process.stdout.write("\n");
|
|
547
|
+
} else {
|
|
548
|
+
spinner.stop();
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
r.events.on("tool:start", (e) => {
|
|
552
|
+
if (streaming) {
|
|
553
|
+
process.stdout.write(chalk2.dim("\n [using: " + e.tool_name + "]"));
|
|
554
|
+
} else {
|
|
555
|
+
spinner.stop();
|
|
556
|
+
console.log(chalk2.dim(" [using: " + e.tool_name + "]"));
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
r.events.on("tool:end", (e) => {
|
|
560
|
+
if (streaming) {
|
|
561
|
+
process.stdout.write(chalk2.dim(" [done: " + e.tool_name + "]\n"));
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
r.events.on("tool:error", (e) => {
|
|
565
|
+
spinner.stop();
|
|
566
|
+
logger2.error({ tool: e.tool_name }, "Tool error");
|
|
567
|
+
});
|
|
568
|
+
r.events.on("security:injection_detected", (e) => {
|
|
569
|
+
logger2.warn({ tool: e.tool_name }, "Potential prompt injection detected");
|
|
570
|
+
});
|
|
571
|
+
r.events.on("budget:warning", () => {
|
|
572
|
+
logger2.warn("Approaching token budget (80%)");
|
|
573
|
+
});
|
|
574
|
+
r.events.on("budget:exceeded", () => {
|
|
575
|
+
logger2.error("Token budget exceeded \u2014 stopping");
|
|
576
|
+
});
|
|
577
|
+
r.events.on("hitl:requested", (e) => {
|
|
578
|
+
spinner.stop();
|
|
579
|
+
if (streaming) {
|
|
580
|
+
process.stdout.write("\n");
|
|
581
|
+
streaming = false;
|
|
582
|
+
}
|
|
583
|
+
console.log();
|
|
584
|
+
console.log(
|
|
585
|
+
chalk2.yellow(
|
|
586
|
+
" " + chalk2.bold("[Agent needs input]") + " " + e.question
|
|
587
|
+
)
|
|
588
|
+
);
|
|
589
|
+
if (e.options) {
|
|
590
|
+
e.options.forEach((opt, i) => {
|
|
591
|
+
console.log(chalk2.yellow(" " + (i + 1) + ". " + opt));
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
void rl.question(chalk2.yellow(" > ")).then((answer) => {
|
|
595
|
+
runtime.answer(e.session_id, answer.trim());
|
|
471
596
|
});
|
|
472
|
-
}
|
|
473
|
-
void rl.question(chalk2.yellow(" > ")).then((answer) => {
|
|
474
|
-
runtime.answer(e.session_id, answer.trim());
|
|
475
597
|
});
|
|
476
|
-
}
|
|
598
|
+
}
|
|
599
|
+
let reactive;
|
|
600
|
+
if (options.watch) {
|
|
601
|
+
reactive = new ReactiveScore(score, file);
|
|
602
|
+
reactive.on("file-change", () => {
|
|
603
|
+
console.log(chalk2.cyan("\n[tutti] Score changed, reloading..."));
|
|
604
|
+
});
|
|
605
|
+
reactive.on("reloaded", () => {
|
|
606
|
+
console.log(chalk2.green("[tutti] Score reloaded. Changes applied."));
|
|
607
|
+
});
|
|
608
|
+
reactive.on("reload-failed", (err) => {
|
|
609
|
+
logger2.error(
|
|
610
|
+
{ error: err instanceof Error ? err.message : String(err) },
|
|
611
|
+
"[tutti] Reload failed \u2014 using previous config"
|
|
612
|
+
);
|
|
613
|
+
});
|
|
614
|
+
}
|
|
477
615
|
console.log(chalk2.dim('Tutti REPL \u2014 type "exit" to quit\n'));
|
|
616
|
+
if (options.watch) {
|
|
617
|
+
console.log(chalk2.dim("Watching " + file + " for changes\u2026\n"));
|
|
618
|
+
}
|
|
478
619
|
let sessionId;
|
|
479
620
|
process.on("SIGINT", () => {
|
|
480
621
|
if (streaming) process.stdout.write("\n");
|
|
481
622
|
spinner.stop();
|
|
482
623
|
console.log(chalk2.dim("Goodbye!"));
|
|
483
624
|
rl.close();
|
|
625
|
+
if (reactive) void reactive.close();
|
|
484
626
|
process.exit(0);
|
|
485
627
|
});
|
|
486
628
|
try {
|
|
487
629
|
while (true) {
|
|
630
|
+
if (reactive?.pendingReload) {
|
|
631
|
+
const nextScore = reactive.current;
|
|
632
|
+
applyRunDefaults(nextScore);
|
|
633
|
+
runtime = buildRuntime(nextScore, sharedSessions);
|
|
634
|
+
attachListeners(runtime);
|
|
635
|
+
reactive.consumePendingReload();
|
|
636
|
+
}
|
|
488
637
|
const input = await rl.question(chalk2.cyan("> "));
|
|
489
638
|
const trimmed = input.trim();
|
|
490
639
|
if (!trimmed) continue;
|
|
@@ -511,55 +660,322 @@ async function runCommand(scorePath) {
|
|
|
511
660
|
}
|
|
512
661
|
console.log(chalk2.dim("Goodbye!"));
|
|
513
662
|
rl.close();
|
|
663
|
+
if (reactive) await reactive.close();
|
|
514
664
|
process.exit(0);
|
|
515
665
|
}
|
|
666
|
+
function buildRuntime(score, sessionStore) {
|
|
667
|
+
return new TuttiRuntime(
|
|
668
|
+
score,
|
|
669
|
+
sessionStore ? { sessionStore } : {}
|
|
670
|
+
);
|
|
671
|
+
}
|
|
516
672
|
|
|
517
|
-
// src/commands/
|
|
518
|
-
import { existsSync as existsSync3
|
|
519
|
-
import { resolve as
|
|
520
|
-
import {
|
|
673
|
+
// src/commands/resume.ts
|
|
674
|
+
import { existsSync as existsSync3 } from "fs";
|
|
675
|
+
import { resolve as resolve3 } from "path";
|
|
676
|
+
import { createInterface as createInterface2 } from "readline/promises";
|
|
521
677
|
import chalk3 from "chalk";
|
|
522
678
|
import ora2 from "ora";
|
|
523
|
-
import {
|
|
679
|
+
import {
|
|
680
|
+
AnthropicProvider as AnthropicProvider2,
|
|
681
|
+
GeminiProvider as GeminiProvider2,
|
|
682
|
+
OpenAIProvider as OpenAIProvider2,
|
|
683
|
+
ScoreLoader as ScoreLoader2,
|
|
684
|
+
SecretsManager as SecretsManager2,
|
|
685
|
+
TuttiRuntime as TuttiRuntime2,
|
|
686
|
+
createCheckpointStore,
|
|
687
|
+
createLogger as createLogger3
|
|
688
|
+
} from "@tuttiai/core";
|
|
524
689
|
var logger3 = createLogger3("tutti-cli");
|
|
690
|
+
async function resumeCommand(sessionId, opts) {
|
|
691
|
+
const scoreFile = resolve3(opts.score ?? "./tutti.score.ts");
|
|
692
|
+
if (!existsSync3(scoreFile)) {
|
|
693
|
+
logger3.error({ file: scoreFile }, "Score file not found");
|
|
694
|
+
console.error(chalk3.dim('Run "tutti-ai init" to create a new project.'));
|
|
695
|
+
process.exit(1);
|
|
696
|
+
}
|
|
697
|
+
let score;
|
|
698
|
+
try {
|
|
699
|
+
score = await ScoreLoader2.load(scoreFile);
|
|
700
|
+
} catch (err) {
|
|
701
|
+
logger3.error(
|
|
702
|
+
{ error: err instanceof Error ? err.message : String(err) },
|
|
703
|
+
"Failed to load score"
|
|
704
|
+
);
|
|
705
|
+
process.exit(1);
|
|
706
|
+
}
|
|
707
|
+
const providerKeyMap = [
|
|
708
|
+
[AnthropicProvider2, "ANTHROPIC_API_KEY"],
|
|
709
|
+
[OpenAIProvider2, "OPENAI_API_KEY"],
|
|
710
|
+
[GeminiProvider2, "GEMINI_API_KEY"]
|
|
711
|
+
];
|
|
712
|
+
for (const [ProviderClass, envVar] of providerKeyMap) {
|
|
713
|
+
if (score.provider instanceof ProviderClass) {
|
|
714
|
+
if (!SecretsManager2.optional(envVar)) {
|
|
715
|
+
logger3.error({ envVar }, "Missing API key");
|
|
716
|
+
process.exit(1);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const agentName = resolveAgentName(score, opts.agent);
|
|
721
|
+
const agent = score.agents[agentName];
|
|
722
|
+
if (!agent) {
|
|
723
|
+
logger3.error(
|
|
724
|
+
{ agent: agentName, available: Object.keys(score.agents) },
|
|
725
|
+
"Agent not found in score"
|
|
726
|
+
);
|
|
727
|
+
process.exit(1);
|
|
728
|
+
}
|
|
729
|
+
if (!agent.durable) {
|
|
730
|
+
console.error(
|
|
731
|
+
chalk3.yellow(
|
|
732
|
+
"Agent '" + agentName + "' does not have `durable: true` set \u2014 resume has nothing to restore."
|
|
733
|
+
)
|
|
734
|
+
);
|
|
735
|
+
console.error(
|
|
736
|
+
chalk3.dim(
|
|
737
|
+
"Enable durable checkpointing on the agent before the run that created this session."
|
|
738
|
+
)
|
|
739
|
+
);
|
|
740
|
+
process.exit(1);
|
|
741
|
+
}
|
|
742
|
+
const spinner = ora2({ color: "cyan" }).start("Loading checkpoint...");
|
|
743
|
+
let checkpointStore;
|
|
744
|
+
let checkpoint;
|
|
745
|
+
try {
|
|
746
|
+
checkpointStore = createCheckpointStore({ store: opts.store });
|
|
747
|
+
checkpoint = await checkpointStore.loadLatest(sessionId);
|
|
748
|
+
} catch (err) {
|
|
749
|
+
spinner.fail("Failed to load checkpoint");
|
|
750
|
+
logger3.error(
|
|
751
|
+
{ error: err instanceof Error ? err.message : String(err), store: opts.store },
|
|
752
|
+
"Checkpoint store error"
|
|
753
|
+
);
|
|
754
|
+
process.exit(1);
|
|
755
|
+
}
|
|
756
|
+
spinner.stop();
|
|
757
|
+
if (!checkpoint) {
|
|
758
|
+
console.error(
|
|
759
|
+
chalk3.red("No checkpoint found for session " + sessionId + ".")
|
|
760
|
+
);
|
|
761
|
+
console.error(
|
|
762
|
+
chalk3.dim(
|
|
763
|
+
"Verify TUTTI_" + (opts.store === "redis" ? "REDIS" : "PG") + "_URL points to the same " + opts.store + " the original run used."
|
|
764
|
+
)
|
|
765
|
+
);
|
|
766
|
+
process.exit(1);
|
|
767
|
+
}
|
|
768
|
+
printSummary(checkpoint);
|
|
769
|
+
if (!opts.yes && !await confirmResume(checkpoint.turn)) {
|
|
770
|
+
console.log(chalk3.dim("Cancelled."));
|
|
771
|
+
process.exit(0);
|
|
772
|
+
}
|
|
773
|
+
const runtime = new TuttiRuntime2(score, { checkpointStore });
|
|
774
|
+
const sessions = runtime.sessions;
|
|
775
|
+
if ("save" in sessions && typeof sessions.save === "function") {
|
|
776
|
+
sessions.save({
|
|
777
|
+
id: sessionId,
|
|
778
|
+
agent_name: agentName,
|
|
779
|
+
messages: [...checkpoint.messages],
|
|
780
|
+
created_at: checkpoint.saved_at,
|
|
781
|
+
updated_at: /* @__PURE__ */ new Date()
|
|
782
|
+
});
|
|
783
|
+
} else {
|
|
784
|
+
console.error(
|
|
785
|
+
chalk3.red(
|
|
786
|
+
"Session store does not support resume seeding. Use the default InMemorySessionStore or PostgresSessionStore."
|
|
787
|
+
)
|
|
788
|
+
);
|
|
789
|
+
process.exit(1);
|
|
790
|
+
}
|
|
791
|
+
wireProgress(runtime);
|
|
792
|
+
try {
|
|
793
|
+
const result = await runtime.run(agentName, "[resume]", sessionId);
|
|
794
|
+
console.log();
|
|
795
|
+
console.log(chalk3.green("\u2713 Resumed run complete."));
|
|
796
|
+
console.log(chalk3.dim(" Final turn: " + result.turns));
|
|
797
|
+
console.log(chalk3.dim(" Session ID: " + result.session_id));
|
|
798
|
+
console.log(
|
|
799
|
+
chalk3.dim(
|
|
800
|
+
" Token usage: " + result.usage.input_tokens + " in / " + result.usage.output_tokens + " out"
|
|
801
|
+
)
|
|
802
|
+
);
|
|
803
|
+
console.log();
|
|
804
|
+
console.log(result.output);
|
|
805
|
+
} catch (err) {
|
|
806
|
+
logger3.error(
|
|
807
|
+
{ error: err instanceof Error ? err.message : String(err) },
|
|
808
|
+
"Resume failed"
|
|
809
|
+
);
|
|
810
|
+
process.exit(1);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
function resolveAgentName(score, override) {
|
|
814
|
+
if (override) return override;
|
|
815
|
+
if (typeof score.entry === "string") return score.entry;
|
|
816
|
+
const first = Object.keys(score.agents)[0];
|
|
817
|
+
if (!first) {
|
|
818
|
+
console.error(chalk3.red("Score has no agents defined."));
|
|
819
|
+
process.exit(1);
|
|
820
|
+
}
|
|
821
|
+
return first;
|
|
822
|
+
}
|
|
823
|
+
function printSummary(checkpoint) {
|
|
824
|
+
console.log();
|
|
825
|
+
console.log(chalk3.cyan.bold("Checkpoint summary"));
|
|
826
|
+
console.log(
|
|
827
|
+
chalk3.dim(" Session ID: ") + checkpoint.session_id
|
|
828
|
+
);
|
|
829
|
+
console.log(
|
|
830
|
+
chalk3.dim(" Last turn: ") + String(checkpoint.turn)
|
|
831
|
+
);
|
|
832
|
+
console.log(
|
|
833
|
+
chalk3.dim(" Saved at: ") + checkpoint.saved_at.toISOString()
|
|
834
|
+
);
|
|
835
|
+
console.log(
|
|
836
|
+
chalk3.dim(" Messages: ") + String(checkpoint.messages.length) + " total"
|
|
837
|
+
);
|
|
838
|
+
console.log();
|
|
839
|
+
console.log(chalk3.cyan("First messages"));
|
|
840
|
+
const preview = checkpoint.messages.slice(0, 3);
|
|
841
|
+
for (const msg of preview) {
|
|
842
|
+
const text = excerpt(messageToText(msg), 200);
|
|
843
|
+
console.log(chalk3.dim(" [" + msg.role + "] ") + text);
|
|
844
|
+
}
|
|
845
|
+
if (checkpoint.messages.length > preview.length) {
|
|
846
|
+
console.log(
|
|
847
|
+
chalk3.dim(
|
|
848
|
+
" \u2026 " + String(checkpoint.messages.length - preview.length) + " more"
|
|
849
|
+
)
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
console.log();
|
|
853
|
+
}
|
|
854
|
+
function messageToText(msg) {
|
|
855
|
+
if (typeof msg.content === "string") return msg.content;
|
|
856
|
+
const parts = [];
|
|
857
|
+
for (const block of msg.content) {
|
|
858
|
+
if (block.type === "text") {
|
|
859
|
+
parts.push(block.text);
|
|
860
|
+
} else if (block.type === "tool_use") {
|
|
861
|
+
parts.push("[tool_use " + block.name + "]");
|
|
862
|
+
} else if (block.type === "tool_result") {
|
|
863
|
+
parts.push("[tool_result " + excerpt(block.content, 80) + "]");
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
return parts.join(" ");
|
|
867
|
+
}
|
|
868
|
+
function excerpt(text, max) {
|
|
869
|
+
const oneLine = text.replace(/\s+/g, " ").trim();
|
|
870
|
+
return oneLine.length > max ? oneLine.slice(0, max - 1) + "\u2026" : oneLine;
|
|
871
|
+
}
|
|
872
|
+
async function confirmResume(turn) {
|
|
873
|
+
const rl = createInterface2({
|
|
874
|
+
input: process.stdin,
|
|
875
|
+
output: process.stdout
|
|
876
|
+
});
|
|
877
|
+
try {
|
|
878
|
+
const answer = (await rl.question(
|
|
879
|
+
chalk3.cyan("Resume from turn " + turn + "? ") + chalk3.dim("(y/n) ")
|
|
880
|
+
)).trim().toLowerCase();
|
|
881
|
+
return answer === "y" || answer === "yes";
|
|
882
|
+
} finally {
|
|
883
|
+
rl.close();
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
function wireProgress(runtime) {
|
|
887
|
+
const spinner = ora2({ color: "cyan" });
|
|
888
|
+
let streaming = false;
|
|
889
|
+
runtime.events.on("checkpoint:restored", (e) => {
|
|
890
|
+
console.log(
|
|
891
|
+
chalk3.dim("\u21BB Restored from turn " + e.turn) + chalk3.dim(" (session " + e.session_id.slice(0, 8) + "\u2026)")
|
|
892
|
+
);
|
|
893
|
+
});
|
|
894
|
+
runtime.events.on("checkpoint:saved", (e) => {
|
|
895
|
+
console.log(chalk3.dim("\xB7 Checkpoint saved at turn " + e.turn));
|
|
896
|
+
});
|
|
897
|
+
runtime.events.on("llm:request", () => {
|
|
898
|
+
spinner.start("Thinking...");
|
|
899
|
+
});
|
|
900
|
+
runtime.events.on("token:stream", (e) => {
|
|
901
|
+
if (!streaming) {
|
|
902
|
+
spinner.stop();
|
|
903
|
+
streaming = true;
|
|
904
|
+
}
|
|
905
|
+
process.stdout.write(e.text);
|
|
906
|
+
});
|
|
907
|
+
runtime.events.on("llm:response", () => {
|
|
908
|
+
if (streaming) {
|
|
909
|
+
process.stdout.write("\n");
|
|
910
|
+
} else {
|
|
911
|
+
spinner.stop();
|
|
912
|
+
}
|
|
913
|
+
});
|
|
914
|
+
runtime.events.on("tool:start", (e) => {
|
|
915
|
+
if (streaming) {
|
|
916
|
+
process.stdout.write(chalk3.dim("\n [using: " + e.tool_name + "]"));
|
|
917
|
+
} else {
|
|
918
|
+
spinner.stop();
|
|
919
|
+
console.log(chalk3.dim(" [using: " + e.tool_name + "]"));
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
runtime.events.on("tool:end", (e) => {
|
|
923
|
+
if (streaming) {
|
|
924
|
+
process.stdout.write(chalk3.dim(" [done: " + e.tool_name + "]\n"));
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
runtime.events.on("tool:error", (e) => {
|
|
928
|
+
spinner.stop();
|
|
929
|
+
logger3.error({ tool: e.tool_name }, "Tool error");
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// src/commands/add.ts
|
|
934
|
+
import { existsSync as existsSync4, readFileSync } from "fs";
|
|
935
|
+
import { resolve as resolve4 } from "path";
|
|
936
|
+
import { execSync } from "child_process";
|
|
937
|
+
import chalk4 from "chalk";
|
|
938
|
+
import ora3 from "ora";
|
|
939
|
+
import { createLogger as createLogger4 } from "@tuttiai/core";
|
|
940
|
+
var logger4 = createLogger4("tutti-cli");
|
|
525
941
|
var OFFICIAL_VOICES = {
|
|
526
942
|
filesystem: {
|
|
527
943
|
package: "@tuttiai/filesystem",
|
|
528
944
|
setup: ` Add to your score:
|
|
529
|
-
${
|
|
530
|
-
${
|
|
945
|
+
${chalk4.cyan('import { FilesystemVoice } from "@tuttiai/filesystem"')}
|
|
946
|
+
${chalk4.cyan("voices: [new FilesystemVoice()]")}`
|
|
531
947
|
},
|
|
532
948
|
github: {
|
|
533
949
|
package: "@tuttiai/github",
|
|
534
|
-
setup: ` Add ${
|
|
535
|
-
${
|
|
950
|
+
setup: ` Add ${chalk4.bold("GITHUB_TOKEN")} to your .env file:
|
|
951
|
+
${chalk4.cyan("GITHUB_TOKEN=ghp_your_token_here")}
|
|
536
952
|
|
|
537
953
|
Add to your score:
|
|
538
|
-
${
|
|
539
|
-
${
|
|
954
|
+
${chalk4.cyan('import { GitHubVoice } from "@tuttiai/github"')}
|
|
955
|
+
${chalk4.cyan("voices: [new GitHubVoice()]")}`
|
|
540
956
|
},
|
|
541
957
|
playwright: {
|
|
542
958
|
package: "@tuttiai/playwright",
|
|
543
959
|
setup: ` Install the browser:
|
|
544
|
-
${
|
|
960
|
+
${chalk4.cyan("npx playwright install chromium")}
|
|
545
961
|
|
|
546
962
|
Add to your score:
|
|
547
|
-
${
|
|
548
|
-
${
|
|
963
|
+
${chalk4.cyan('import { PlaywrightVoice } from "@tuttiai/playwright"')}
|
|
964
|
+
${chalk4.cyan("voices: [new PlaywrightVoice()]")}`
|
|
549
965
|
},
|
|
550
966
|
postgres: {
|
|
551
967
|
package: "pg",
|
|
552
|
-
setup: ` Add ${
|
|
553
|
-
${
|
|
968
|
+
setup: ` Add ${chalk4.bold("DATABASE_URL")} to your .env file:
|
|
969
|
+
${chalk4.cyan("DATABASE_URL=postgres://user:pass@localhost:5432/tutti")}
|
|
554
970
|
|
|
555
971
|
Add to your score:
|
|
556
|
-
${
|
|
972
|
+
${chalk4.cyan("memory: { provider: 'postgres' }")}
|
|
557
973
|
|
|
558
974
|
Or with an explicit URL:
|
|
559
|
-
${
|
|
975
|
+
${chalk4.cyan("memory: { provider: 'postgres', url: process.env.DATABASE_URL }")}
|
|
560
976
|
|
|
561
977
|
Use the async factory for initialization:
|
|
562
|
-
${
|
|
978
|
+
${chalk4.cyan("const tutti = await TuttiRuntime.create(score)")}`
|
|
563
979
|
}
|
|
564
980
|
};
|
|
565
981
|
function resolvePackageName(input) {
|
|
@@ -572,8 +988,8 @@ function resolvePackageName(input) {
|
|
|
572
988
|
return `@tuttiai/${input}`;
|
|
573
989
|
}
|
|
574
990
|
function isAlreadyInstalled(packageName) {
|
|
575
|
-
const pkgPath =
|
|
576
|
-
if (!
|
|
991
|
+
const pkgPath = resolve4(process.cwd(), "package.json");
|
|
992
|
+
if (!existsSync4(pkgPath)) return false;
|
|
577
993
|
try {
|
|
578
994
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
579
995
|
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
@@ -584,17 +1000,17 @@ function isAlreadyInstalled(packageName) {
|
|
|
584
1000
|
}
|
|
585
1001
|
function addCommand(voiceName) {
|
|
586
1002
|
const packageName = resolvePackageName(voiceName);
|
|
587
|
-
const pkgPath =
|
|
588
|
-
if (!
|
|
589
|
-
|
|
590
|
-
console.error(
|
|
1003
|
+
const pkgPath = resolve4(process.cwd(), "package.json");
|
|
1004
|
+
if (!existsSync4(pkgPath)) {
|
|
1005
|
+
logger4.error("No package.json found in the current directory");
|
|
1006
|
+
console.error(chalk4.dim('Run "tutti-ai init" to create a new project first.'));
|
|
591
1007
|
process.exit(1);
|
|
592
1008
|
}
|
|
593
1009
|
if (isAlreadyInstalled(packageName)) {
|
|
594
|
-
console.log(
|
|
1010
|
+
console.log(chalk4.green(` \u2714 ${packageName} is already installed`));
|
|
595
1011
|
return;
|
|
596
1012
|
}
|
|
597
|
-
const spinner =
|
|
1013
|
+
const spinner = ora3(`Installing ${packageName}...`).start();
|
|
598
1014
|
try {
|
|
599
1015
|
execSync(`npm install ${packageName}`, {
|
|
600
1016
|
cwd: process.cwd(),
|
|
@@ -604,7 +1020,7 @@ function addCommand(voiceName) {
|
|
|
604
1020
|
} catch (error) {
|
|
605
1021
|
spinner.fail(`Failed to install ${packageName}`);
|
|
606
1022
|
const message = error instanceof Error ? error.message : String(error);
|
|
607
|
-
|
|
1023
|
+
logger4.error({ error: message, package: packageName }, "Installation failed");
|
|
608
1024
|
process.exit(1);
|
|
609
1025
|
}
|
|
610
1026
|
const official = OFFICIAL_VOICES[voiceName];
|
|
@@ -616,43 +1032,43 @@ function addCommand(voiceName) {
|
|
|
616
1032
|
} else {
|
|
617
1033
|
console.log();
|
|
618
1034
|
console.log(
|
|
619
|
-
|
|
1035
|
+
chalk4.dim(" Check the package README for setup instructions.")
|
|
620
1036
|
);
|
|
621
1037
|
console.log();
|
|
622
1038
|
}
|
|
623
1039
|
}
|
|
624
1040
|
|
|
625
1041
|
// src/commands/check.ts
|
|
626
|
-
import { existsSync as
|
|
627
|
-
import { resolve as
|
|
628
|
-
import
|
|
1042
|
+
import { existsSync as existsSync5 } from "fs";
|
|
1043
|
+
import { resolve as resolve5 } from "path";
|
|
1044
|
+
import chalk5 from "chalk";
|
|
629
1045
|
import {
|
|
630
|
-
ScoreLoader as
|
|
631
|
-
AnthropicProvider as
|
|
632
|
-
OpenAIProvider as
|
|
633
|
-
GeminiProvider as
|
|
634
|
-
SecretsManager as
|
|
635
|
-
createLogger as
|
|
1046
|
+
ScoreLoader as ScoreLoader3,
|
|
1047
|
+
AnthropicProvider as AnthropicProvider3,
|
|
1048
|
+
OpenAIProvider as OpenAIProvider3,
|
|
1049
|
+
GeminiProvider as GeminiProvider3,
|
|
1050
|
+
SecretsManager as SecretsManager3,
|
|
1051
|
+
createLogger as createLogger5
|
|
636
1052
|
} from "@tuttiai/core";
|
|
637
|
-
var
|
|
638
|
-
var ok = (msg) => console.log(
|
|
639
|
-
var fail = (msg) => console.log(
|
|
1053
|
+
var logger5 = createLogger5("tutti-cli");
|
|
1054
|
+
var ok = (msg) => console.log(chalk5.green(" \u2714 " + msg));
|
|
1055
|
+
var fail = (msg) => console.log(chalk5.red(" \u2718 " + msg));
|
|
640
1056
|
async function checkCommand(scorePath) {
|
|
641
|
-
const file =
|
|
642
|
-
console.log(
|
|
1057
|
+
const file = resolve5(scorePath ?? "./tutti.score.ts");
|
|
1058
|
+
console.log(chalk5.cyan(`
|
|
643
1059
|
Checking ${file}...
|
|
644
1060
|
`));
|
|
645
|
-
if (!
|
|
1061
|
+
if (!existsSync5(file)) {
|
|
646
1062
|
fail("Score file not found: " + file);
|
|
647
1063
|
process.exit(1);
|
|
648
1064
|
}
|
|
649
1065
|
let score;
|
|
650
1066
|
try {
|
|
651
|
-
score = await
|
|
1067
|
+
score = await ScoreLoader3.load(file);
|
|
652
1068
|
ok("Score file is valid");
|
|
653
1069
|
} catch (err) {
|
|
654
1070
|
fail("Score validation failed");
|
|
655
|
-
|
|
1071
|
+
logger5.error(
|
|
656
1072
|
{ error: err instanceof Error ? err.message : String(err) },
|
|
657
1073
|
"Score validation failed"
|
|
658
1074
|
);
|
|
@@ -660,15 +1076,15 @@ Checking ${file}...
|
|
|
660
1076
|
}
|
|
661
1077
|
let hasErrors = false;
|
|
662
1078
|
const providerChecks = [
|
|
663
|
-
[
|
|
664
|
-
[
|
|
665
|
-
[
|
|
1079
|
+
[AnthropicProvider3, "AnthropicProvider", "ANTHROPIC_API_KEY"],
|
|
1080
|
+
[OpenAIProvider3, "OpenAIProvider", "OPENAI_API_KEY"],
|
|
1081
|
+
[GeminiProvider3, "GeminiProvider", "GEMINI_API_KEY"]
|
|
666
1082
|
];
|
|
667
1083
|
let providerDetected = false;
|
|
668
1084
|
for (const [ProviderClass, name, envVar] of providerChecks) {
|
|
669
1085
|
if (score.provider instanceof ProviderClass) {
|
|
670
1086
|
providerDetected = true;
|
|
671
|
-
const key =
|
|
1087
|
+
const key = SecretsManager3.optional(envVar);
|
|
672
1088
|
if (key) {
|
|
673
1089
|
ok("Provider: " + name + " (" + envVar + " is set)");
|
|
674
1090
|
} else {
|
|
@@ -690,7 +1106,7 @@ Checking ${file}...
|
|
|
690
1106
|
};
|
|
691
1107
|
const envVar = voiceEnvMap[voiceName];
|
|
692
1108
|
if (envVar) {
|
|
693
|
-
const key =
|
|
1109
|
+
const key = SecretsManager3.optional(envVar);
|
|
694
1110
|
if (key) {
|
|
695
1111
|
ok(
|
|
696
1112
|
"Voice: " + voiceName + " on " + agentKey + " (" + envVar + " is set)"
|
|
@@ -709,28 +1125,28 @@ Checking ${file}...
|
|
|
709
1125
|
console.log("");
|
|
710
1126
|
if (hasErrors) {
|
|
711
1127
|
console.log(
|
|
712
|
-
|
|
1128
|
+
chalk5.yellow("Some checks failed. Fix the issues above and re-run.")
|
|
713
1129
|
);
|
|
714
1130
|
process.exit(1);
|
|
715
1131
|
} else {
|
|
716
1132
|
console.log(
|
|
717
|
-
|
|
1133
|
+
chalk5.green("All checks passed.") + chalk5.dim(" Run tutti-ai run to start.")
|
|
718
1134
|
);
|
|
719
1135
|
}
|
|
720
1136
|
}
|
|
721
1137
|
|
|
722
1138
|
// src/commands/studio.ts
|
|
723
|
-
import { existsSync as
|
|
724
|
-
import { resolve as
|
|
1139
|
+
import { existsSync as existsSync6 } from "fs";
|
|
1140
|
+
import { resolve as resolve6 } from "path";
|
|
725
1141
|
import { execFile } from "child_process";
|
|
726
1142
|
import express from "express";
|
|
727
|
-
import
|
|
1143
|
+
import chalk6 from "chalk";
|
|
728
1144
|
import {
|
|
729
|
-
TuttiRuntime as
|
|
730
|
-
ScoreLoader as
|
|
731
|
-
createLogger as
|
|
1145
|
+
TuttiRuntime as TuttiRuntime3,
|
|
1146
|
+
ScoreLoader as ScoreLoader4,
|
|
1147
|
+
createLogger as createLogger6
|
|
732
1148
|
} from "@tuttiai/core";
|
|
733
|
-
var
|
|
1149
|
+
var logger6 = createLogger6("tutti-studio");
|
|
734
1150
|
var envPort = Number.parseInt(process.env.PORT ?? "", 10);
|
|
735
1151
|
var PORT = Number.isInteger(envPort) && envPort > 0 && envPort <= 65535 ? envPort : 4747;
|
|
736
1152
|
function safeStringify(obj) {
|
|
@@ -749,20 +1165,20 @@ function openBrowser(url) {
|
|
|
749
1165
|
execFile(cmd, [url]);
|
|
750
1166
|
}
|
|
751
1167
|
async function studioCommand(scorePath) {
|
|
752
|
-
const file =
|
|
753
|
-
if (!
|
|
754
|
-
|
|
755
|
-
console.error(
|
|
1168
|
+
const file = resolve6(scorePath ?? "./tutti.score.ts");
|
|
1169
|
+
if (!existsSync6(file)) {
|
|
1170
|
+
logger6.error({ file }, "Score file not found");
|
|
1171
|
+
console.error(chalk6.dim('Run "tutti-ai init" to create a new project.'));
|
|
756
1172
|
process.exit(1);
|
|
757
1173
|
}
|
|
758
1174
|
let score;
|
|
759
1175
|
try {
|
|
760
|
-
score = await
|
|
1176
|
+
score = await ScoreLoader4.load(file);
|
|
761
1177
|
} catch (err) {
|
|
762
|
-
|
|
1178
|
+
logger6.error({ error: err instanceof Error ? err.message : String(err) }, "Failed to load score");
|
|
763
1179
|
process.exit(1);
|
|
764
1180
|
}
|
|
765
|
-
const runtime = new
|
|
1181
|
+
const runtime = new TuttiRuntime3(score);
|
|
766
1182
|
const sessionRegistry = /* @__PURE__ */ new Map();
|
|
767
1183
|
runtime.events.on("agent:start", (e) => {
|
|
768
1184
|
if (!sessionRegistry.has(e.session_id)) {
|
|
@@ -865,16 +1281,16 @@ async function studioCommand(scorePath) {
|
|
|
865
1281
|
app.listen(PORT, () => {
|
|
866
1282
|
const url = "http://localhost:" + PORT;
|
|
867
1283
|
console.log();
|
|
868
|
-
console.log(
|
|
869
|
-
console.log(
|
|
1284
|
+
console.log(chalk6.bold(" Tutti Studio"));
|
|
1285
|
+
console.log(chalk6.dim(" " + url));
|
|
870
1286
|
console.log();
|
|
871
|
-
console.log(
|
|
872
|
-
console.log(
|
|
1287
|
+
console.log(chalk6.dim(" Score: ") + (runtime.score.name ?? file));
|
|
1288
|
+
console.log(chalk6.dim(" Agents: ") + Object.keys(runtime.score.agents).join(", "));
|
|
873
1289
|
console.log();
|
|
874
1290
|
openBrowser(url);
|
|
875
1291
|
});
|
|
876
1292
|
process.on("SIGINT", () => {
|
|
877
|
-
console.log(
|
|
1293
|
+
console.log(chalk6.dim("\nShutting down Tutti Studio..."));
|
|
878
1294
|
process.exit(0);
|
|
879
1295
|
});
|
|
880
1296
|
}
|
|
@@ -883,12 +1299,12 @@ function getStudioHtml() {
|
|
|
883
1299
|
}
|
|
884
1300
|
|
|
885
1301
|
// src/commands/search.ts
|
|
886
|
-
import { existsSync as
|
|
887
|
-
import { resolve as
|
|
888
|
-
import
|
|
889
|
-
import
|
|
890
|
-
import { createLogger as
|
|
891
|
-
var
|
|
1302
|
+
import { existsSync as existsSync7, readFileSync as readFileSync2 } from "fs";
|
|
1303
|
+
import { resolve as resolve7 } from "path";
|
|
1304
|
+
import chalk7 from "chalk";
|
|
1305
|
+
import ora4 from "ora";
|
|
1306
|
+
import { createLogger as createLogger7 } from "@tuttiai/core";
|
|
1307
|
+
var logger7 = createLogger7("tutti-cli");
|
|
892
1308
|
var REGISTRY_URL = "https://raw.githubusercontent.com/tuttiai/voices/main/voices.json";
|
|
893
1309
|
var BUILTIN_VOICES = [
|
|
894
1310
|
{
|
|
@@ -939,7 +1355,7 @@ async function fetchRegistry() {
|
|
|
939
1355
|
if (voices.length === 0) throw new Error("Empty registry");
|
|
940
1356
|
return voices;
|
|
941
1357
|
} catch {
|
|
942
|
-
|
|
1358
|
+
logger7.debug("Registry unreachable, using built-in voice list");
|
|
943
1359
|
return BUILTIN_VOICES;
|
|
944
1360
|
}
|
|
945
1361
|
}
|
|
@@ -955,8 +1371,8 @@ function matchesQuery(voice, query) {
|
|
|
955
1371
|
return false;
|
|
956
1372
|
}
|
|
957
1373
|
function isInstalled(packageName) {
|
|
958
|
-
const pkgPath =
|
|
959
|
-
if (!
|
|
1374
|
+
const pkgPath = resolve7(process.cwd(), "package.json");
|
|
1375
|
+
if (!existsSync7(pkgPath)) return false;
|
|
960
1376
|
try {
|
|
961
1377
|
const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
|
|
962
1378
|
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
@@ -966,35 +1382,35 @@ function isInstalled(packageName) {
|
|
|
966
1382
|
}
|
|
967
1383
|
}
|
|
968
1384
|
function printVoice(voice, showInstallStatus) {
|
|
969
|
-
const badge = voice.official ?
|
|
1385
|
+
const badge = voice.official ? chalk7.green(" [official]") : chalk7.blue(" [community]");
|
|
970
1386
|
const installed = showInstallStatus && isInstalled(voice.package);
|
|
971
|
-
const status = showInstallStatus ? installed ?
|
|
1387
|
+
const status = showInstallStatus ? installed ? chalk7.green(" \u2714 installed") : chalk7.dim(" not installed") : "";
|
|
972
1388
|
console.log();
|
|
973
|
-
console.log(" " +
|
|
1389
|
+
console.log(" " + chalk7.bold(voice.package) + badge + status);
|
|
974
1390
|
console.log(" " + voice.description);
|
|
975
1391
|
const installCmd = voice.official && voice.name !== "postgres" ? "tutti-ai add " + voice.name : "npm install " + voice.package;
|
|
976
|
-
console.log(" " +
|
|
1392
|
+
console.log(" " + chalk7.dim("Install: ") + chalk7.cyan(installCmd));
|
|
977
1393
|
if (voice.tags.length > 0) {
|
|
978
|
-
console.log(" " +
|
|
1394
|
+
console.log(" " + chalk7.dim("Tags: ") + voice.tags.join(", "));
|
|
979
1395
|
}
|
|
980
1396
|
}
|
|
981
1397
|
async function searchCommand(query) {
|
|
982
|
-
const spinner =
|
|
1398
|
+
const spinner = ora4("Searching the Repertoire...").start();
|
|
983
1399
|
const voices = await fetchRegistry();
|
|
984
1400
|
const results = voices.filter((v) => matchesQuery(v, query));
|
|
985
1401
|
spinner.stop();
|
|
986
1402
|
if (results.length === 0) {
|
|
987
1403
|
console.log();
|
|
988
|
-
console.log(
|
|
1404
|
+
console.log(chalk7.yellow(' No voices found for "' + query + '"'));
|
|
989
1405
|
console.log();
|
|
990
|
-
console.log(
|
|
991
|
-
console.log(
|
|
1406
|
+
console.log(chalk7.dim(" Browse all: https://tutti-ai.com/voices"));
|
|
1407
|
+
console.log(chalk7.dim(" Build your own: tutti-ai create voice <name>"));
|
|
992
1408
|
console.log();
|
|
993
1409
|
return;
|
|
994
1410
|
}
|
|
995
1411
|
console.log();
|
|
996
1412
|
console.log(
|
|
997
|
-
" Found " +
|
|
1413
|
+
" Found " + chalk7.bold(String(results.length)) + " voice" + (results.length !== 1 ? "s" : "") + " matching " + chalk7.cyan("'" + query + "'") + ":"
|
|
998
1414
|
);
|
|
999
1415
|
for (const voice of results) {
|
|
1000
1416
|
printVoice(voice, false);
|
|
@@ -1002,12 +1418,12 @@ async function searchCommand(query) {
|
|
|
1002
1418
|
console.log();
|
|
1003
1419
|
}
|
|
1004
1420
|
async function voicesCommand() {
|
|
1005
|
-
const spinner =
|
|
1421
|
+
const spinner = ora4("Loading voices...").start();
|
|
1006
1422
|
const voices = await fetchRegistry();
|
|
1007
1423
|
const official = voices.filter((v) => v.official);
|
|
1008
1424
|
spinner.stop();
|
|
1009
1425
|
console.log();
|
|
1010
|
-
console.log(" " +
|
|
1426
|
+
console.log(" " + chalk7.bold("Official Tutti Voices"));
|
|
1011
1427
|
console.log();
|
|
1012
1428
|
for (const voice of official) {
|
|
1013
1429
|
printVoice(voice, true);
|
|
@@ -1015,49 +1431,49 @@ async function voicesCommand() {
|
|
|
1015
1431
|
const community = voices.filter((v) => !v.official);
|
|
1016
1432
|
if (community.length > 0) {
|
|
1017
1433
|
console.log();
|
|
1018
|
-
console.log(" " +
|
|
1434
|
+
console.log(" " + chalk7.bold("Community Voices"));
|
|
1019
1435
|
for (const voice of community) {
|
|
1020
1436
|
printVoice(voice, true);
|
|
1021
1437
|
}
|
|
1022
1438
|
}
|
|
1023
1439
|
console.log();
|
|
1024
|
-
console.log(
|
|
1025
|
-
console.log(
|
|
1440
|
+
console.log(chalk7.dim(" Search: tutti-ai search <query>"));
|
|
1441
|
+
console.log(chalk7.dim(" Browse: https://tutti-ai.com/voices"));
|
|
1026
1442
|
console.log();
|
|
1027
1443
|
}
|
|
1028
1444
|
|
|
1029
1445
|
// src/commands/publish.ts
|
|
1030
|
-
import { existsSync as
|
|
1031
|
-
import { resolve as
|
|
1446
|
+
import { existsSync as existsSync8, readFileSync as readFileSync3 } from "fs";
|
|
1447
|
+
import { resolve as resolve8 } from "path";
|
|
1032
1448
|
import { execSync as execSync2 } from "child_process";
|
|
1033
|
-
import
|
|
1034
|
-
import
|
|
1449
|
+
import chalk8 from "chalk";
|
|
1450
|
+
import ora5 from "ora";
|
|
1035
1451
|
import Enquirer2 from "enquirer";
|
|
1036
|
-
import { createLogger as
|
|
1452
|
+
import { createLogger as createLogger8, SecretsManager as SecretsManager4 } from "@tuttiai/core";
|
|
1037
1453
|
var { prompt: prompt2 } = Enquirer2;
|
|
1038
|
-
var
|
|
1454
|
+
var logger8 = createLogger8("tutti-cli");
|
|
1039
1455
|
function readPkg(dir) {
|
|
1040
|
-
const p =
|
|
1041
|
-
if (!
|
|
1456
|
+
const p = resolve8(dir, "package.json");
|
|
1457
|
+
if (!existsSync8(p)) return void 0;
|
|
1042
1458
|
return JSON.parse(readFileSync3(p, "utf-8"));
|
|
1043
1459
|
}
|
|
1044
1460
|
function run(cmd, cwd) {
|
|
1045
1461
|
return execSync2(cmd, { cwd, stdio: "pipe", encoding: "utf-8" });
|
|
1046
1462
|
}
|
|
1047
1463
|
function fail2(msg) {
|
|
1048
|
-
console.error(
|
|
1464
|
+
console.error(chalk8.red(" " + msg));
|
|
1049
1465
|
process.exit(1);
|
|
1050
1466
|
}
|
|
1051
|
-
var ok2 = (msg) => console.log(
|
|
1467
|
+
var ok2 = (msg) => console.log(chalk8.green(" \u2714 " + msg));
|
|
1052
1468
|
async function publishCommand(opts) {
|
|
1053
1469
|
const cwd = process.cwd();
|
|
1054
1470
|
const pkg = readPkg(cwd);
|
|
1055
1471
|
console.log();
|
|
1056
|
-
console.log(
|
|
1472
|
+
console.log(chalk8.bold(" Tutti Voice Publisher"));
|
|
1057
1473
|
console.log();
|
|
1058
|
-
const spinner =
|
|
1474
|
+
const spinner = ora5("Running pre-flight checks...").start();
|
|
1059
1475
|
if (!pkg) fail2("No package.json found in the current directory.");
|
|
1060
|
-
if (!
|
|
1476
|
+
if (!existsSync8(resolve8(cwd, "src/index.ts"))) fail2("No src/index.ts found \u2014 are you inside a voice directory?");
|
|
1061
1477
|
const missing = [];
|
|
1062
1478
|
if (!pkg.name) missing.push("name");
|
|
1063
1479
|
if (!pkg.version) missing.push("version");
|
|
@@ -1069,22 +1485,22 @@ async function publishCommand(opts) {
|
|
|
1069
1485
|
const version = pkg.version;
|
|
1070
1486
|
const validName = name.startsWith("@tuttiai/") || name.startsWith("tutti");
|
|
1071
1487
|
if (!validName) fail2("Package name must start with @tuttiai/ or tutti \u2014 got: " + name);
|
|
1072
|
-
const src = readFileSync3(
|
|
1488
|
+
const src = readFileSync3(resolve8(cwd, "src/index.ts"), "utf-8");
|
|
1073
1489
|
if (!src.includes("required_permissions")) {
|
|
1074
1490
|
fail2("Voice class must declare required_permissions in src/index.ts");
|
|
1075
1491
|
}
|
|
1076
1492
|
spinner.succeed("Pre-flight checks passed");
|
|
1077
|
-
const buildSpinner =
|
|
1493
|
+
const buildSpinner = ora5("Building...").start();
|
|
1078
1494
|
try {
|
|
1079
1495
|
run("npm run build", cwd);
|
|
1080
1496
|
buildSpinner.succeed("Build succeeded");
|
|
1081
1497
|
} catch (err) {
|
|
1082
1498
|
buildSpinner.fail("Build failed");
|
|
1083
1499
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1084
|
-
console.error(
|
|
1500
|
+
console.error(chalk8.dim(" " + msg.split("\n").slice(0, 5).join("\n ")));
|
|
1085
1501
|
process.exit(1);
|
|
1086
1502
|
}
|
|
1087
|
-
const testSpinner =
|
|
1503
|
+
const testSpinner = ora5("Running tests...").start();
|
|
1088
1504
|
try {
|
|
1089
1505
|
run("npx vitest run", cwd);
|
|
1090
1506
|
testSpinner.succeed("Tests passed");
|
|
@@ -1092,15 +1508,15 @@ async function publishCommand(opts) {
|
|
|
1092
1508
|
testSpinner.fail("Tests failed");
|
|
1093
1509
|
process.exit(1);
|
|
1094
1510
|
}
|
|
1095
|
-
const auditSpinner =
|
|
1511
|
+
const auditSpinner = ora5("Checking vulnerabilities...").start();
|
|
1096
1512
|
try {
|
|
1097
1513
|
run("npm audit --audit-level=high", cwd);
|
|
1098
1514
|
auditSpinner.succeed("No high/critical vulnerabilities");
|
|
1099
1515
|
} catch {
|
|
1100
|
-
auditSpinner.stopAndPersist({ symbol:
|
|
1516
|
+
auditSpinner.stopAndPersist({ symbol: chalk8.yellow("\u26A0"), text: "Vulnerabilities found (npm audit)" });
|
|
1101
1517
|
}
|
|
1102
1518
|
console.log();
|
|
1103
|
-
const drySpinner =
|
|
1519
|
+
const drySpinner = ora5("Packing (dry run)...").start();
|
|
1104
1520
|
let packOutput;
|
|
1105
1521
|
try {
|
|
1106
1522
|
packOutput = run("npm pack --dry-run 2>&1", cwd);
|
|
@@ -1108,24 +1524,24 @@ async function publishCommand(opts) {
|
|
|
1108
1524
|
} catch (err) {
|
|
1109
1525
|
drySpinner.fail("Pack dry-run failed");
|
|
1110
1526
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1111
|
-
console.error(
|
|
1527
|
+
console.error(chalk8.dim(" " + msg));
|
|
1112
1528
|
process.exit(1);
|
|
1113
1529
|
}
|
|
1114
1530
|
const fileLines = packOutput.split("\n").filter((l) => l.includes("npm notice") && /\d+(\.\d+)?\s*[kM]?B\s/.test(l)).map((l) => l.replace(/npm notice\s*/, ""));
|
|
1115
1531
|
if (fileLines.length > 0) {
|
|
1116
|
-
console.log(
|
|
1532
|
+
console.log(chalk8.dim(" Files:"));
|
|
1117
1533
|
for (const line of fileLines) {
|
|
1118
|
-
console.log(
|
|
1534
|
+
console.log(chalk8.dim(" " + line.trim()));
|
|
1119
1535
|
}
|
|
1120
1536
|
}
|
|
1121
1537
|
const sizeLine = packOutput.split("\n").find((l) => l.includes("package size"));
|
|
1122
1538
|
const totalLine = packOutput.split("\n").find((l) => l.includes("total files"));
|
|
1123
|
-
if (sizeLine) console.log(
|
|
1124
|
-
if (totalLine) console.log(
|
|
1539
|
+
if (sizeLine) console.log(chalk8.dim(" " + sizeLine.replace(/npm notice\s*/, "").trim()));
|
|
1540
|
+
if (totalLine) console.log(chalk8.dim(" " + totalLine.replace(/npm notice\s*/, "").trim()));
|
|
1125
1541
|
if (opts.dryRun) {
|
|
1126
1542
|
console.log();
|
|
1127
1543
|
ok2("Dry run complete \u2014 no packages were published");
|
|
1128
|
-
console.log(
|
|
1544
|
+
console.log(chalk8.dim(" Run without --dry-run to publish for real."));
|
|
1129
1545
|
console.log();
|
|
1130
1546
|
return;
|
|
1131
1547
|
}
|
|
@@ -1133,38 +1549,38 @@ async function publishCommand(opts) {
|
|
|
1133
1549
|
const { confirm } = await prompt2({
|
|
1134
1550
|
type: "confirm",
|
|
1135
1551
|
name: "confirm",
|
|
1136
|
-
message: "Publish " +
|
|
1552
|
+
message: "Publish " + chalk8.cyan(name + "@" + version) + "?"
|
|
1137
1553
|
});
|
|
1138
1554
|
if (!confirm) {
|
|
1139
|
-
console.log(
|
|
1555
|
+
console.log(chalk8.dim(" Cancelled."));
|
|
1140
1556
|
return;
|
|
1141
1557
|
}
|
|
1142
|
-
const pubSpinner =
|
|
1558
|
+
const pubSpinner = ora5("Publishing to npm...").start();
|
|
1143
1559
|
try {
|
|
1144
1560
|
run("npm publish --access public", cwd);
|
|
1145
|
-
pubSpinner.succeed("Published " +
|
|
1561
|
+
pubSpinner.succeed("Published " + chalk8.cyan(name + "@" + version));
|
|
1146
1562
|
} catch (err) {
|
|
1147
1563
|
pubSpinner.fail("Publish failed");
|
|
1148
1564
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1149
|
-
|
|
1565
|
+
logger8.error({ error: msg }, "npm publish failed");
|
|
1150
1566
|
process.exit(1);
|
|
1151
1567
|
}
|
|
1152
|
-
const ghToken =
|
|
1568
|
+
const ghToken = SecretsManager4.optional("GITHUB_TOKEN");
|
|
1153
1569
|
let prUrl;
|
|
1154
1570
|
if (ghToken) {
|
|
1155
|
-
const prSpinner =
|
|
1571
|
+
const prSpinner = ora5("Opening PR to voice registry...").start();
|
|
1156
1572
|
try {
|
|
1157
1573
|
prUrl = await openRegistryPR(name, version, pkg.description ?? "", ghToken);
|
|
1158
1574
|
prSpinner.succeed("PR opened: " + prUrl);
|
|
1159
1575
|
} catch (err) {
|
|
1160
1576
|
prSpinner.fail("Failed to open PR");
|
|
1161
1577
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1162
|
-
|
|
1578
|
+
logger8.error({ error: msg }, "Registry PR failed");
|
|
1163
1579
|
}
|
|
1164
1580
|
} else {
|
|
1165
1581
|
console.log();
|
|
1166
|
-
console.log(
|
|
1167
|
-
console.log(
|
|
1582
|
+
console.log(chalk8.dim(" To list in the Repertoire, set GITHUB_TOKEN and re-run"));
|
|
1583
|
+
console.log(chalk8.dim(" Or open a PR manually: github.com/tuttiai/voices"));
|
|
1168
1584
|
}
|
|
1169
1585
|
console.log();
|
|
1170
1586
|
ok2(name + "@" + version + " published to npm");
|
|
@@ -1249,78 +1665,97 @@ async function openRegistryPR(packageName, version, description, token) {
|
|
|
1249
1665
|
}
|
|
1250
1666
|
|
|
1251
1667
|
// src/commands/eval.ts
|
|
1252
|
-
import { existsSync as
|
|
1253
|
-
import { resolve as
|
|
1254
|
-
import
|
|
1255
|
-
import
|
|
1668
|
+
import { existsSync as existsSync9, readFileSync as readFileSync4 } from "fs";
|
|
1669
|
+
import { resolve as resolve9 } from "path";
|
|
1670
|
+
import chalk9 from "chalk";
|
|
1671
|
+
import ora6 from "ora";
|
|
1256
1672
|
import {
|
|
1257
|
-
ScoreLoader as
|
|
1673
|
+
ScoreLoader as ScoreLoader5,
|
|
1258
1674
|
EvalRunner,
|
|
1259
1675
|
printEvalTable,
|
|
1260
|
-
createLogger as
|
|
1676
|
+
createLogger as createLogger9
|
|
1261
1677
|
} from "@tuttiai/core";
|
|
1262
|
-
var
|
|
1678
|
+
var logger9 = createLogger9("tutti-cli");
|
|
1263
1679
|
async function evalCommand(suitePath, opts) {
|
|
1264
|
-
const suiteFile =
|
|
1265
|
-
if (!
|
|
1266
|
-
|
|
1680
|
+
const suiteFile = resolve9(suitePath);
|
|
1681
|
+
if (!existsSync9(suiteFile)) {
|
|
1682
|
+
logger9.error({ file: suiteFile }, "Suite file not found");
|
|
1267
1683
|
process.exit(1);
|
|
1268
1684
|
}
|
|
1269
1685
|
let suite;
|
|
1270
1686
|
try {
|
|
1271
1687
|
suite = JSON.parse(readFileSync4(suiteFile, "utf-8"));
|
|
1272
1688
|
} catch (err) {
|
|
1273
|
-
|
|
1689
|
+
logger9.error({ error: err instanceof Error ? err.message : String(err) }, "Failed to parse suite file");
|
|
1274
1690
|
process.exit(1);
|
|
1275
1691
|
}
|
|
1276
|
-
const scoreFile =
|
|
1277
|
-
if (!
|
|
1278
|
-
|
|
1692
|
+
const scoreFile = resolve9(opts.score ?? "./tutti.score.ts");
|
|
1693
|
+
if (!existsSync9(scoreFile)) {
|
|
1694
|
+
logger9.error({ file: scoreFile }, "Score file not found");
|
|
1279
1695
|
process.exit(1);
|
|
1280
1696
|
}
|
|
1281
|
-
const spinner =
|
|
1697
|
+
const spinner = ora6("Loading score...").start();
|
|
1282
1698
|
let score;
|
|
1283
1699
|
try {
|
|
1284
|
-
score = await
|
|
1700
|
+
score = await ScoreLoader5.load(scoreFile);
|
|
1285
1701
|
} catch (err) {
|
|
1286
1702
|
spinner.fail("Failed to load score");
|
|
1287
|
-
|
|
1703
|
+
logger9.error({ error: err instanceof Error ? err.message : String(err) }, "Score load failed");
|
|
1288
1704
|
process.exit(1);
|
|
1289
1705
|
}
|
|
1290
1706
|
spinner.succeed("Score loaded");
|
|
1291
|
-
const evalSpinner =
|
|
1707
|
+
const evalSpinner = ora6("Running " + suite.cases.length + " eval cases...").start();
|
|
1292
1708
|
const runner = new EvalRunner(score);
|
|
1293
1709
|
const report = await runner.run(suite);
|
|
1294
1710
|
evalSpinner.stop();
|
|
1295
1711
|
printEvalTable(report);
|
|
1296
1712
|
if (opts.ci && report.summary.failed > 0) {
|
|
1297
|
-
console.error(
|
|
1713
|
+
console.error(chalk9.red(" CI mode: " + report.summary.failed + " case(s) failed"));
|
|
1298
1714
|
process.exit(1);
|
|
1299
1715
|
}
|
|
1300
1716
|
}
|
|
1301
1717
|
|
|
1302
1718
|
// src/index.ts
|
|
1303
1719
|
config();
|
|
1304
|
-
var
|
|
1720
|
+
var logger10 = createLogger10("tutti-cli");
|
|
1305
1721
|
process.on("unhandledRejection", (reason) => {
|
|
1306
|
-
|
|
1722
|
+
logger10.error({ error: reason instanceof Error ? reason.message : String(reason) }, "Unhandled rejection");
|
|
1307
1723
|
process.exit(1);
|
|
1308
1724
|
});
|
|
1309
1725
|
process.on("uncaughtException", (err) => {
|
|
1310
|
-
|
|
1726
|
+
logger10.error({ error: err.message }, "Fatal error");
|
|
1311
1727
|
process.exit(1);
|
|
1312
1728
|
});
|
|
1313
1729
|
var program = new Command();
|
|
1314
|
-
program.name("tutti-ai").description("Tutti \u2014 multi-agent orchestration. All agents. All together.").version("0.
|
|
1730
|
+
program.name("tutti-ai").description("Tutti \u2014 multi-agent orchestration. All agents. All together.").version("0.10.0");
|
|
1315
1731
|
program.command("init [project-name]").description("Create a new Tutti project").option("-t, --template <id>", "Project template to use").action(async (projectName, opts) => {
|
|
1316
1732
|
await initCommand(projectName, opts.template);
|
|
1317
1733
|
});
|
|
1318
1734
|
program.command("templates").description("List all available project templates").action(() => {
|
|
1319
1735
|
templatesCommand();
|
|
1320
1736
|
});
|
|
1321
|
-
program.command("run [score]").description("Run a Tutti score interactively").action(async (score) => {
|
|
1322
|
-
await runCommand(score);
|
|
1737
|
+
program.command("run [score]").description("Run a Tutti score interactively").option("-w, --watch", "Reload the score on file changes").action(async (score, opts) => {
|
|
1738
|
+
await runCommand(score, { watch: opts.watch });
|
|
1323
1739
|
});
|
|
1740
|
+
program.command("resume <session-id>").description("Resume a crashed or interrupted run from its last checkpoint").option(
|
|
1741
|
+
"--store <backend>",
|
|
1742
|
+
"Durable store the checkpoint was written to (redis | postgres)",
|
|
1743
|
+
"redis"
|
|
1744
|
+
).option("-s, --score <path>", "Path to score file (default: ./tutti.score.ts)").option("-a, --agent <name>", "Agent key to resume (default: score.entry or the first agent)").option("-y, --yes", "Skip the confirmation prompt").action(
|
|
1745
|
+
async (sessionId, opts) => {
|
|
1746
|
+
if (opts.store !== "redis" && opts.store !== "postgres") {
|
|
1747
|
+
console.error("--store must be 'redis' or 'postgres'");
|
|
1748
|
+
process.exit(1);
|
|
1749
|
+
}
|
|
1750
|
+
const resolved = {
|
|
1751
|
+
store: opts.store,
|
|
1752
|
+
...opts.score !== void 0 ? { score: opts.score } : {},
|
|
1753
|
+
...opts.agent !== void 0 ? { agent: opts.agent } : {},
|
|
1754
|
+
...opts.yes !== void 0 ? { yes: opts.yes } : {}
|
|
1755
|
+
};
|
|
1756
|
+
await resumeCommand(sessionId, resolved);
|
|
1757
|
+
}
|
|
1758
|
+
);
|
|
1324
1759
|
program.command("add <voice>").description("Add a voice to your project").action((voice) => {
|
|
1325
1760
|
addCommand(voice);
|
|
1326
1761
|
});
|