ultrahope 0.1.2 → 0.1.4

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
@@ -1,10 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // index.ts
4
- import { createRequire } from "module";
5
-
6
3
  // commands/jj.ts
7
- import { execSync as execSync2, spawnSync } from "child_process";
4
+ import { execFileSync, execSync as execSync2, spawnSync } from "child_process";
8
5
 
9
6
  // lib/api-client.ts
10
7
  import createClient from "openapi-fetch";
@@ -39,11 +36,33 @@ function log(message, data) {
39
36
  // lib/api-client.ts
40
37
  var API_BASE_URL = process.env.ULTRAHOPE_API_URL ?? "https://ultrahope.dev";
41
38
  var InsufficientBalanceError = class extends Error {
42
- constructor(balance) {
39
+ constructor(balance, plan = "free", hint, actions) {
43
40
  super("Token balance exhausted");
44
41
  this.balance = balance;
42
+ this.plan = plan;
43
+ this.hint = hint;
44
+ this.actions = actions;
45
45
  this.name = "InsufficientBalanceError";
46
46
  }
47
+ formatMessage() {
48
+ const lines = [];
49
+ if (this.plan === "pro") {
50
+ lines.push(
51
+ "Error: Your usage credit has been exhausted. Purchase additional credits to continue."
52
+ );
53
+ if (this.actions?.buyCredits) {
54
+ lines.push(` Buy credits: ${this.actions.buyCredits}`);
55
+ }
56
+ } else {
57
+ lines.push(
58
+ "Error: Your free credit has been exhausted. Upgrade to Pro for unlimited requests with $5 included credit."
59
+ );
60
+ if (this.actions?.upgrade) {
61
+ lines.push(` Upgrade: ${this.actions.upgrade}`);
62
+ }
63
+ }
64
+ return lines.join("\n");
65
+ }
47
66
  };
48
67
  var DailyLimitExceededError = class extends Error {
49
68
  constructor(count, limit, resetsAt) {
@@ -60,6 +79,14 @@ var UnauthorizedError = class extends Error {
60
79
  this.name = "UnauthorizedError";
61
80
  }
62
81
  };
82
+ var InvalidModelError = class extends Error {
83
+ constructor(model, allowedModels, message) {
84
+ super(message ?? `Model '${model}' is not supported.`);
85
+ this.model = model;
86
+ this.allowedModels = allowedModels;
87
+ this.name = "InvalidModelError";
88
+ }
89
+ };
63
90
  async function getErrorText(response, error) {
64
91
  if (error) {
65
92
  try {
@@ -102,18 +129,34 @@ function parseSseEvents(buffer) {
102
129
  return { events, remainder };
103
130
  }
104
131
  function handle402Error(error) {
105
- const errorBalance = error?.balance;
106
- if (typeof errorBalance === "number") {
132
+ const payload = error;
133
+ if (typeof payload?.balance === "number") {
107
134
  log("generate error (402 insufficient_balance)", error);
108
- throw new InsufficientBalanceError(errorBalance);
135
+ const plan = payload.plan === "pro" || payload.plan === "free" ? payload.plan : "free";
136
+ throw new InsufficientBalanceError(
137
+ payload.balance,
138
+ plan,
139
+ payload.hint,
140
+ payload.actions
141
+ );
109
142
  }
110
- const payload = error;
111
143
  const count = typeof payload?.count === "number" ? payload.count : 0;
112
144
  const limit = typeof payload?.limit === "number" ? payload.limit : 0;
113
145
  const resetsAt = payload?.resetsAt ?? "";
114
146
  log("generate error (402 daily_limit)", error);
115
147
  throw new DailyLimitExceededError(count, limit, resetsAt);
116
148
  }
149
+ function throwInvalidModelError(error) {
150
+ const payload = error;
151
+ const message = payload?.message ?? "Model is not supported.";
152
+ const modelMatch = message.match(/Model '([^']+)' is not supported\./);
153
+ const model = payload?.model ?? modelMatch?.[1] ?? "unknown";
154
+ const allowedModels = Array.isArray(payload?.allowedModels) ? payload.allowedModels.filter(
155
+ (value) => typeof value === "string"
156
+ ) : [];
157
+ log("generate error (400 invalid_model)", error);
158
+ throw new InvalidModelError(model, allowedModels, message);
159
+ }
117
160
  function createApiClient(token) {
118
161
  const headers = {
119
162
  "Content-Type": "application/json"
@@ -150,6 +193,15 @@ function createApiClient(token) {
150
193
  }
151
194
  handle402Error(errorPayload);
152
195
  }
196
+ if (res.status === 400) {
197
+ let errorPayload;
198
+ try {
199
+ errorPayload = await res.json();
200
+ } catch {
201
+ errorPayload = await getErrorText(res, null);
202
+ }
203
+ throwInvalidModelError(errorPayload);
204
+ }
153
205
  if (!res.ok) {
154
206
  const text = await getErrorText(res, null);
155
207
  log("streamCommitMessage error", {
@@ -210,12 +262,8 @@ function createApiClient(token) {
210
262
  throw new UnauthorizedError();
211
263
  }
212
264
  if (response.status === 402) {
213
- const payload = error;
214
- const count = typeof payload?.count === "number" ? payload.count : 0;
215
- const limit = typeof payload?.limit === "number" ? payload.limit : 0;
216
- const resetsAt = payload?.resetsAt ?? "";
217
265
  log("command_execution error (402)", error);
218
- throw new DailyLimitExceededError(count, limit, resetsAt);
266
+ handle402Error(error);
219
267
  }
220
268
  if (!response.ok) {
221
269
  const text = await getErrorText(response, error);
@@ -244,6 +292,9 @@ function createApiClient(token) {
244
292
  if (response.status === 402) {
245
293
  handle402Error(error);
246
294
  }
295
+ if (response.status === 400) {
296
+ throwInvalidModelError(error);
297
+ }
247
298
  if (!response.ok) {
248
299
  const text = await getErrorText(response, error);
249
300
  log("generateCommitMessage error", { status: response.status, text });
@@ -295,6 +346,9 @@ function createApiClient(token) {
295
346
  if (response.status === 402) {
296
347
  handle402Error(error);
297
348
  }
349
+ if (response.status === 400) {
350
+ throwInvalidModelError(error);
351
+ }
298
352
  if (!response.ok) {
299
353
  const text = await getErrorText(response, error);
300
354
  log("generatePrTitleBody error", { status: response.status, text });
@@ -319,6 +373,9 @@ function createApiClient(token) {
319
373
  if (response.status === 402) {
320
374
  handle402Error(error);
321
375
  }
376
+ if (response.status === 400) {
377
+ throwInvalidModelError(error);
378
+ }
322
379
  if (!response.ok) {
323
380
  const text = await getErrorText(response, error);
324
381
  log("generatePrIntent error", { status: response.status, text });
@@ -568,7 +625,7 @@ async function showDailyLimitPrompt(info) {
568
625
  }
569
626
  }
570
627
  function promptChoice() {
571
- return new Promise((resolve) => {
628
+ return new Promise((resolve3) => {
572
629
  const fd = openSync("/dev/tty", "r");
573
630
  const ttyInput = new tty.ReadStream(fd);
574
631
  const rl = readline.createInterface({
@@ -591,17 +648,17 @@ function promptChoice() {
591
648
  if (!key && !str) return;
592
649
  if (str === "q" || str === "Q" || key?.name === "q" || key?.name === "c" && key.ctrl || key?.name === "escape") {
593
650
  cleanup();
594
- resolve("q");
651
+ resolve3("q");
595
652
  return;
596
653
  }
597
654
  if (str === "1" || key?.name === "1" || key?.sequence === "1") {
598
655
  cleanup();
599
- resolve("1");
656
+ resolve3("1");
600
657
  return;
601
658
  }
602
659
  if (str === "2" || key?.name === "2" || key?.sequence === "2") {
603
660
  cleanup();
604
- resolve("2");
661
+ resolve3("2");
605
662
  return;
606
663
  }
607
664
  };
@@ -618,7 +675,7 @@ function handleRetryLater() {
618
675
  );
619
676
  console.log(` ${ui.link("ultrahope jj describe")}`);
620
677
  console.log("");
621
- return new Promise((resolve) => {
678
+ return new Promise((resolve3) => {
622
679
  const fd = openSync("/dev/tty", "r");
623
680
  const ttyInput = new tty.ReadStream(fd);
624
681
  const rl = readline.createInterface({
@@ -638,7 +695,7 @@ function handleRetryLater() {
638
695
  if (!key && !str) return;
639
696
  if (str === "\r" || str === "\n" || str === "q" || str === "Q" || key?.name === "return" || key?.name === "q" || key?.name === "c" && key.ctrl) {
640
697
  cleanup();
641
- resolve();
698
+ resolve3();
642
699
  }
643
700
  };
644
701
  ttyInput.on("keypress", handleKeypress);
@@ -702,11 +759,374 @@ async function handleCommandExecutionError(error, options) {
702
759
  });
703
760
  process.exit(1);
704
761
  }
762
+ if (error instanceof InsufficientBalanceError) {
763
+ console.error(error.formatMessage());
764
+ process.exit(1);
765
+ }
705
766
  const message = error instanceof Error ? error.message : String(error);
706
767
  console.error(`Error: Failed to start command execution. ${message}`);
707
768
  process.exit(1);
708
769
  }
709
770
 
771
+ // lib/config.ts
772
+ import * as fs2 from "fs";
773
+ import * as os2 from "os";
774
+ import * as path2 from "path";
775
+ import { parse } from "smol-toml";
776
+
777
+ // ../shared/async-race.ts
778
+ async function* raceAsyncIterators({
779
+ iterators,
780
+ signal,
781
+ onError
782
+ }) {
783
+ if (iterators.length === 0) {
784
+ return;
785
+ }
786
+ if (signal?.aborted) {
787
+ return;
788
+ }
789
+ const abortPromise = signal ? new Promise((resolve3) => {
790
+ signal.addEventListener("abort", () => resolve3(null), { once: true });
791
+ }) : null;
792
+ const pending = /* @__PURE__ */ new Map();
793
+ const startNext = (index) => {
794
+ const iterator = iterators[index];
795
+ if (!iterator) {
796
+ return;
797
+ }
798
+ pending.set(
799
+ index,
800
+ iterator.next().then(
801
+ (result) => ({ kind: "result", index, result }),
802
+ (error) => ({ kind: "error", index, error })
803
+ )
804
+ );
805
+ };
806
+ const throwOrContinue = (error, index) => {
807
+ if (!onError) {
808
+ throw error;
809
+ }
810
+ const decision = onError(index, error);
811
+ if (decision === "throw") {
812
+ throw error;
813
+ }
814
+ };
815
+ try {
816
+ for (let index = 0; index < iterators.length; index++) {
817
+ startNext(index);
818
+ }
819
+ while (pending.size > 0) {
820
+ const winner = abortPromise ? await Promise.race([
821
+ ...pending.values(),
822
+ abortPromise.then(() => null)
823
+ ]) : await Promise.race([...pending.values()]);
824
+ if (winner === null) {
825
+ return;
826
+ }
827
+ const { index } = winner;
828
+ pending.delete(index);
829
+ if (winner.kind === "error") {
830
+ throwOrContinue(winner.error, winner.index);
831
+ continue;
832
+ }
833
+ const { result } = winner;
834
+ if (!result.done) {
835
+ startNext(index);
836
+ yield { index, result };
837
+ }
838
+ }
839
+ } finally {
840
+ for (const iterator of iterators) {
841
+ try {
842
+ await iterator.return?.();
843
+ } catch {
844
+ }
845
+ }
846
+ for (const promise of pending.values()) {
847
+ promise.catch(() => void 0);
848
+ }
849
+ }
850
+ }
851
+
852
+ // lib/vcs-message-generator.ts
853
+ var DEFAULT_MODELS = [
854
+ // "mistral/ministral-3b",
855
+ // "cerebras/qwen-3-235b",
856
+ // "openai/gpt-5.1",
857
+ "mistral/ministral-3b",
858
+ "xai/grok-code-fast-1"
859
+ ];
860
+ var isAbortError = (error) => error instanceof Error && error.name === "AbortError";
861
+ var isInvalidCliSessionIdError = (error) => error instanceof Error && error.message.includes("Invalid cliSessionId");
862
+ var delay = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
863
+ async function* generateCommitMessages(options) {
864
+ const {
865
+ diff,
866
+ models,
867
+ signal,
868
+ cliSessionId,
869
+ commandExecutionPromise,
870
+ useStream = false,
871
+ streamCaptureRecorder
872
+ } = options;
873
+ const resolvedCliSessionId = cliSessionId;
874
+ if (!resolvedCliSessionId) {
875
+ throw new Error("Missing cliSessionId for generate request.");
876
+ }
877
+ const requiredCliSessionId = resolvedCliSessionId;
878
+ const captureGenerationId = streamCaptureRecorder?.startGeneration({
879
+ cliSessionId: requiredCliSessionId,
880
+ models
881
+ });
882
+ const token = await getToken();
883
+ if (!token) {
884
+ console.error("Error: Not authenticated. Run `ultrahope login` first.");
885
+ process.exit(1);
886
+ }
887
+ const api = createApiClient(token);
888
+ const generateWithRetry = async function* (payload) {
889
+ const maxAttempts = 3;
890
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
891
+ const attemptNumber = attempt + 1;
892
+ try {
893
+ for await (const event of api.streamCommitMessage(payload, {
894
+ signal
895
+ })) {
896
+ if (captureGenerationId != null) {
897
+ streamCaptureRecorder?.recordEvent(captureGenerationId, {
898
+ model: payload.model,
899
+ attempt: attemptNumber,
900
+ event
901
+ });
902
+ }
903
+ log("generate", event);
904
+ yield event;
905
+ }
906
+ return;
907
+ } catch (error) {
908
+ if (signal?.aborted || isAbortError(error)) throw error;
909
+ if (isInvalidCliSessionIdError(error)) {
910
+ if (commandExecutionPromise) {
911
+ try {
912
+ await commandExecutionPromise;
913
+ continue;
914
+ } catch {
915
+ const abortError = new Error("Aborted");
916
+ abortError.name = "AbortError";
917
+ throw abortError;
918
+ }
919
+ }
920
+ if (attempt < maxAttempts - 1) {
921
+ await delay(80 * (attempt + 1));
922
+ continue;
923
+ }
924
+ }
925
+ throw error;
926
+ }
927
+ }
928
+ throw new Error("Failed to generate after retries.");
929
+ };
930
+ async function* generateForModel(model, slotIndex) {
931
+ try {
932
+ if (signal?.aborted) return;
933
+ let lastCommitMessage = "";
934
+ let providerMetadata;
935
+ for await (const event of generateWithRetry({
936
+ cliSessionId: requiredCliSessionId,
937
+ input: diff,
938
+ model,
939
+ guide: options.guide
940
+ })) {
941
+ if (event.type === "commit-message") {
942
+ lastCommitMessage = event.commitMessage;
943
+ if (useStream) {
944
+ yield {
945
+ content: lastCommitMessage,
946
+ slotId: model,
947
+ model,
948
+ isPartial: true,
949
+ slotIndex
950
+ };
951
+ }
952
+ } else if (event.type === "provider-metadata") {
953
+ providerMetadata = event.providerMetadata;
954
+ } else if (event.type === "error") {
955
+ throw new Error(event.message);
956
+ }
957
+ }
958
+ if (lastCommitMessage) {
959
+ const { generationId, cost } = extractGatewayMetadata(providerMetadata);
960
+ yield {
961
+ content: lastCommitMessage,
962
+ slotId: model,
963
+ model,
964
+ cost,
965
+ generationId,
966
+ ...useStream ? { isPartial: false } : {},
967
+ slotIndex
968
+ };
969
+ }
970
+ } catch (error) {
971
+ if (signal?.aborted || isAbortError(error)) return;
972
+ if (error instanceof InvalidModelError) throw error;
973
+ if (error instanceof InsufficientBalanceError) throw error;
974
+ if (isInvalidCliSessionIdError(error)) throw error;
975
+ }
976
+ }
977
+ const iterators = models.map(
978
+ (model, index) => generateForModel(model, index)[Symbol.asyncIterator]()
979
+ );
980
+ try {
981
+ for await (const { result } of raceAsyncIterators({
982
+ iterators,
983
+ signal,
984
+ onError(index, error) {
985
+ if (isAbortError(error) || signal?.aborted) {
986
+ return "continue";
987
+ }
988
+ void index;
989
+ return "throw";
990
+ }
991
+ })) {
992
+ yield result.value;
993
+ }
994
+ } catch (error) {
995
+ if (error instanceof InvalidModelError) {
996
+ throw error;
997
+ }
998
+ if (error instanceof InsufficientBalanceError) {
999
+ console.error(error.formatMessage());
1000
+ process.exit(1);
1001
+ }
1002
+ throw error;
1003
+ } finally {
1004
+ if (captureGenerationId != null) {
1005
+ streamCaptureRecorder?.finishGeneration(captureGenerationId);
1006
+ }
1007
+ }
1008
+ }
1009
+
1010
+ // lib/config.ts
1011
+ var PROJECT_CONFIG_FILENAMES = [".ultrahope.toml", "ultrahope.toml"];
1012
+ function fail(message) {
1013
+ console.error(`Error: ${message}`);
1014
+ process.exit(1);
1015
+ }
1016
+ function validateModels(models, sourcePath) {
1017
+ if (!Array.isArray(models)) {
1018
+ fail(
1019
+ `Invalid config in ${sourcePath}: models must be an array of strings.`
1020
+ );
1021
+ }
1022
+ if (models.length === 0) {
1023
+ fail(`Invalid config in ${sourcePath}: models must not be empty.`);
1024
+ }
1025
+ const normalized = models.map((model, index) => {
1026
+ if (typeof model !== "string") {
1027
+ fail(
1028
+ `Invalid config in ${sourcePath}: models[${index}] must be a string.`
1029
+ );
1030
+ }
1031
+ const trimmed = model.trim();
1032
+ if (!trimmed) {
1033
+ fail(
1034
+ `Invalid config in ${sourcePath}: models[${index}] must not be empty.`
1035
+ );
1036
+ }
1037
+ return trimmed;
1038
+ });
1039
+ return normalized;
1040
+ }
1041
+ function parseModelsArg(value) {
1042
+ const rawModels = value.split(",");
1043
+ const models = rawModels.map((m) => m.trim());
1044
+ if (models.length === 0 || models.every((m) => m.length === 0)) {
1045
+ fail("--models requires at least one non-empty model.");
1046
+ }
1047
+ const emptyIndex = models.findIndex((m) => m.length === 0);
1048
+ if (emptyIndex !== -1) {
1049
+ fail(`--models contains an empty value at position ${emptyIndex + 1}.`);
1050
+ }
1051
+ return models;
1052
+ }
1053
+ function getGlobalConfigPath() {
1054
+ const configDir = process.env.XDG_CONFIG_HOME ?? path2.join(os2.homedir(), ".config");
1055
+ return path2.join(configDir, "ultrahope", "config.toml");
1056
+ }
1057
+ function findNearestProjectConfig(cwd) {
1058
+ let current = path2.resolve(cwd);
1059
+ while (true) {
1060
+ for (const filename of PROJECT_CONFIG_FILENAMES) {
1061
+ const candidate = path2.join(current, filename);
1062
+ if (fs2.existsSync(candidate)) {
1063
+ return candidate;
1064
+ }
1065
+ }
1066
+ const parent = path2.dirname(current);
1067
+ if (parent === current) {
1068
+ return null;
1069
+ }
1070
+ current = parent;
1071
+ }
1072
+ }
1073
+ function readConfigModels(configPath) {
1074
+ let raw = "";
1075
+ try {
1076
+ raw = fs2.readFileSync(configPath, "utf-8");
1077
+ } catch (error) {
1078
+ const message = error instanceof Error ? error.message : String(error);
1079
+ fail(`Failed to read config file ${configPath}: ${message}`);
1080
+ }
1081
+ let parsed;
1082
+ try {
1083
+ parsed = parse(raw);
1084
+ } catch (error) {
1085
+ const message = error instanceof Error ? error.message : String(error);
1086
+ fail(`Failed to parse TOML config ${configPath}: ${message}`);
1087
+ }
1088
+ if (parsed.models === void 0) {
1089
+ return void 0;
1090
+ }
1091
+ return validateModels(parsed.models, configPath);
1092
+ }
1093
+ function resolveModels(cliModels) {
1094
+ if (cliModels && cliModels.length > 0) {
1095
+ return cliModels;
1096
+ }
1097
+ const projectConfigPath = findNearestProjectConfig(process.cwd());
1098
+ if (projectConfigPath) {
1099
+ const projectModels = readConfigModels(projectConfigPath);
1100
+ if (projectModels) {
1101
+ return projectModels;
1102
+ }
1103
+ }
1104
+ const globalConfigPath = getGlobalConfigPath();
1105
+ if (fs2.existsSync(globalConfigPath)) {
1106
+ const globalModels = readConfigModels(globalConfigPath);
1107
+ if (globalModels) {
1108
+ return globalModels;
1109
+ }
1110
+ }
1111
+ return DEFAULT_MODELS;
1112
+ }
1113
+ async function ensureGlobalConfigFile() {
1114
+ const configPath = getGlobalConfigPath();
1115
+ if (fs2.existsSync(configPath)) {
1116
+ return;
1117
+ }
1118
+ const dir = path2.dirname(configPath);
1119
+ await fs2.promises.mkdir(dir, { recursive: true });
1120
+ const content = `# Ultrahope CLI configuration
1121
+ # https://github.com/toyamarinyon/ultrahope
1122
+
1123
+ # Models to use for generation (each model produces one candidate)
1124
+ # See available models: https://ultrahope.dev/models
1125
+ models = ["mistral/ministral-3b", "xai/grok-code-fast-1"]
1126
+ `;
1127
+ await fs2.promises.writeFile(configPath, content, { mode: 420, flag: "wx" });
1128
+ }
1129
+
710
1130
  // lib/diff-stats.ts
711
1131
  import { execSync } from "child_process";
712
1132
  function getJjDiffStats(revision) {
@@ -745,39 +1165,85 @@ import {
745
1165
  constants as constants2,
746
1166
  mkdtempSync,
747
1167
  openSync as openSync2,
748
- readFileSync,
1168
+ readFileSync as readFileSync2,
749
1169
  unlinkSync,
750
1170
  writeFileSync
751
1171
  } from "fs";
752
1172
  import { tmpdir } from "os";
753
- import { join as join3 } from "path";
1173
+ import { join as join4 } from "path";
754
1174
  import * as readline3 from "readline";
755
1175
  import * as tty2 from "tty";
756
1176
 
757
- // lib/renderer.ts
758
- import * as readline2 from "readline";
759
- var SPINNER_FRAMES = [
760
- "\u280B",
761
- "\u2819",
762
- "\u2839",
763
- "\u2838",
764
- "\u283C",
765
- "\u2834",
766
- "\u2826",
767
- "\u2827",
768
- "\u2807",
769
- "\u280F"
770
- ];
771
- function isTTY(output) {
772
- return output.isTTY === true;
1177
+ // ../shared/terminal-selector-helpers.ts
1178
+ function formatModelName(model) {
1179
+ const parts = model.split("/");
1180
+ return parts.length > 1 ? parts[1] : model;
773
1181
  }
774
- function createRenderer(output) {
775
- let pendingHeight = 0;
776
- let committedHeight = 0;
777
- const render = (content) => {
778
- if (!isTTY(output)) {
779
- output.write(content);
780
- return;
1182
+ function formatCost(cost) {
1183
+ return `$${cost.toFixed(7).replace(/0+$/, "").replace(/\.$/, "")}`;
1184
+ }
1185
+ function formatTotalCostLabel(cost) {
1186
+ return `$${cost.toFixed(6)}`;
1187
+ }
1188
+ function getReadyCount(slots) {
1189
+ return slots.filter((slot) => slot.status === "ready").length;
1190
+ }
1191
+ function getTotalCost(slots) {
1192
+ return slots.reduce((sum, slot) => {
1193
+ if (slot.status === "ready" && slot.candidate.cost != null) {
1194
+ return sum + slot.candidate.cost;
1195
+ }
1196
+ return sum;
1197
+ }, 0);
1198
+ }
1199
+ function getLatestQuota(slots) {
1200
+ for (const slot of slots) {
1201
+ if (slot.status === "ready" && slot.candidate.quota) {
1202
+ return slot.candidate.quota;
1203
+ }
1204
+ }
1205
+ return void 0;
1206
+ }
1207
+ function hasReadySlot(slots) {
1208
+ return getReadyCount(slots) > 0;
1209
+ }
1210
+ function selectNearestReady(slots, startIndex, direction) {
1211
+ for (let index = startIndex + direction; index >= 0 && index < slots.length; index += direction) {
1212
+ if (slots[index]?.status === "ready") {
1213
+ return index;
1214
+ }
1215
+ }
1216
+ return startIndex;
1217
+ }
1218
+ function getSelectedCandidate(slots, selectedIndex) {
1219
+ const slot = slots[selectedIndex];
1220
+ return slot?.status === "ready" ? slot.candidate : void 0;
1221
+ }
1222
+
1223
+ // lib/renderer.ts
1224
+ import * as readline2 from "readline";
1225
+ var SPINNER_FRAMES = [
1226
+ "\u280B",
1227
+ "\u2819",
1228
+ "\u2839",
1229
+ "\u2838",
1230
+ "\u283C",
1231
+ "\u2834",
1232
+ "\u2826",
1233
+ "\u2827",
1234
+ "\u2807",
1235
+ "\u280F"
1236
+ ];
1237
+ function isTTY(output) {
1238
+ return output.isTTY === true;
1239
+ }
1240
+ function createRenderer(output) {
1241
+ let pendingHeight = 0;
1242
+ let committedHeight = 0;
1243
+ const render = (content) => {
1244
+ if (!isTTY(output)) {
1245
+ output.write(content);
1246
+ return;
781
1247
  }
782
1248
  if (pendingHeight > 0) {
783
1249
  readline2.moveCursor(output, 0, -pendingHeight);
@@ -813,47 +1279,6 @@ function createRenderer(output) {
813
1279
 
814
1280
  // lib/selector.ts
815
1281
  var TTY_PATH = "/dev/tty";
816
- function formatModelName(model) {
817
- const parts = model.split("/");
818
- return parts.length > 1 ? parts[1] : model;
819
- }
820
- function formatCost(cost) {
821
- return `$${cost.toFixed(7).replace(/0+$/, "").replace(/\.$/, "")}`;
822
- }
823
- function getReadyCount(slots) {
824
- return slots.filter((s) => s.status === "ready").length;
825
- }
826
- function getTotalCost(slots) {
827
- return slots.reduce((sum, slot) => {
828
- if (slot.status === "ready" && slot.candidate.cost != null) {
829
- return sum + slot.candidate.cost;
830
- }
831
- return sum;
832
- }, 0);
833
- }
834
- function getLatestQuota(slots) {
835
- for (const slot of slots) {
836
- if (slot.status === "ready" && slot.candidate.quota) {
837
- return slot.candidate.quota;
838
- }
839
- }
840
- return void 0;
841
- }
842
- function hasReadySlot(slots) {
843
- return slots.some((s) => s.status === "ready");
844
- }
845
- function getSelectedCandidate(slots, selectedIndex) {
846
- const slot = slots[selectedIndex];
847
- return slot?.status === "ready" ? slot.candidate : void 0;
848
- }
849
- function selectNearestReady(slots, startIndex, direction) {
850
- for (let i = startIndex + direction; i >= 0 && i < slots.length; i += direction) {
851
- if (slots[i]?.status === "ready") {
852
- return i;
853
- }
854
- }
855
- return startIndex;
856
- }
857
1282
  function collapseToReady(slots) {
858
1283
  const readySlots = slots.filter((s) => s.status === "ready");
859
1284
  slots.length = 0;
@@ -868,6 +1293,11 @@ function formatSlot(slot, selected) {
868
1293
  const meta2 = slot.model ? `${theme.dim} ${formatModelName(slot.model)}${theme.reset}` : "";
869
1294
  return meta2 ? [line2, meta2] : [line2];
870
1295
  }
1296
+ if (slot.status === "error") {
1297
+ const radio2 = "\u25CB";
1298
+ const line2 = `${theme.dim} ${radio2} ${slot.content}${theme.reset}`;
1299
+ return [line2];
1300
+ }
871
1301
  const candidate = slot.candidate;
872
1302
  const title = candidate.content.split("\n")[0]?.trim() || "";
873
1303
  const modelInfo = candidate.model ? candidate.cost ? `${formatModelName(candidate.model)} ${formatCost(candidate.cost)}` : formatModelName(candidate.model) : "";
@@ -882,9 +1312,6 @@ function formatSlot(slot, selected) {
882
1312
  const meta = modelInfo ? `${theme.dim} ${modelInfo}${theme.reset}` : "";
883
1313
  return meta ? [line, meta] : [line];
884
1314
  }
885
- function formatTotalCostLabel(cost) {
886
- return `$${cost.toFixed(6)}`;
887
- }
888
1315
  function renderSelector(state, nowMs, renderer) {
889
1316
  const { slots, selectedIndex, isGenerating, totalSlots } = state;
890
1317
  const lines = [];
@@ -936,10 +1363,10 @@ function renderError(error, slots, totalSlots, output) {
936
1363
  }
937
1364
  }
938
1365
  function openEditor(content) {
939
- return new Promise((resolve, reject) => {
1366
+ return new Promise((resolve3, reject) => {
940
1367
  const editor = process.env.GIT_EDITOR || process.env.EDITOR || "vi";
941
- const tmpDir = mkdtempSync(join3(tmpdir(), "ultrahope-"));
942
- const tmpFile = join3(tmpDir, "EDIT_MESSAGE");
1368
+ const tmpDir = mkdtempSync(join4(tmpdir(), "ultrahope-"));
1369
+ const tmpFile = join4(tmpDir, "EDIT_MESSAGE");
943
1370
  writeFileSync(tmpFile, content);
944
1371
  const child = spawn(editor, [tmpFile], { stdio: "inherit" });
945
1372
  child.on("close", (code) => {
@@ -948,9 +1375,9 @@ function openEditor(content) {
948
1375
  reject(new Error(`Editor exited with code ${code}`));
949
1376
  return;
950
1377
  }
951
- const result = readFileSync(tmpFile, "utf-8").trim();
1378
+ const result = readFileSync2(tmpFile, "utf-8").trim();
952
1379
  unlinkSync(tmpFile);
953
- resolve(result);
1380
+ resolve3(result);
954
1381
  });
955
1382
  child.on("error", (err) => {
956
1383
  try {
@@ -1010,12 +1437,12 @@ async function selectCandidate(options) {
1010
1437
  );
1011
1438
  }
1012
1439
  async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1013
- return new Promise((resolve) => {
1440
+ return new Promise((resolve3) => {
1014
1441
  let resolved = false;
1015
1442
  const resolveOnce = (result) => {
1016
1443
  if (resolved) return;
1017
1444
  resolved = true;
1018
- resolve(result);
1445
+ resolve3(result);
1019
1446
  };
1020
1447
  const slots = [...initialSlots];
1021
1448
  const state = {
@@ -1075,10 +1502,10 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1075
1502
  };
1076
1503
  const nextCandidate = async (iterator) => {
1077
1504
  const abortPromise = new Promise(
1078
- (resolve2) => {
1505
+ (resolve4) => {
1079
1506
  asyncCtx?.abortController.signal.addEventListener(
1080
1507
  "abort",
1081
- () => resolve2({ done: true, value: void 0 }),
1508
+ () => resolve4({ done: true, value: void 0 }),
1082
1509
  { once: true }
1083
1510
  );
1084
1511
  }
@@ -1103,9 +1530,12 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1103
1530
  const result = await nextCandidate(iterator);
1104
1531
  if (result.done || cleanedUp) break;
1105
1532
  const candidate = result.value;
1106
- const targetIndex = slots.findIndex(
1107
- (slot) => slot.status === "pending" ? slot.slotId === candidate.slotId : slot.candidate.slotId === candidate.slotId
1108
- );
1533
+ const targetIndex = slots.findIndex((slot) => {
1534
+ if (slot.status === "ready") {
1535
+ return slot.candidate.slotId === candidate.slotId;
1536
+ }
1537
+ return slot.status === "pending" && slot.slotId === candidate.slotId;
1538
+ });
1109
1539
  if (targetIndex >= 0 && targetIndex < slots.length) {
1110
1540
  const isNewSlot = slots[targetIndex].status === "pending";
1111
1541
  updateState((draft) => {
@@ -1124,10 +1554,15 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1124
1554
  }
1125
1555
  if (!cleanedUp) {
1126
1556
  cancelGeneration();
1557
+ if (err instanceof InvalidModelError) {
1558
+ cleanup();
1559
+ resolveOnce({ action: "abort", error: err });
1560
+ return;
1561
+ }
1127
1562
  renderer.clearAll();
1128
1563
  renderError(err, slots, state.totalSlots, ttyOutput);
1129
1564
  cleanup(false);
1130
- resolveOnce({ action: "abort" });
1565
+ resolveOnce({ action: "abort", error: err });
1131
1566
  }
1132
1567
  } finally {
1133
1568
  iterator.return?.();
@@ -1241,173 +1676,163 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1241
1676
  });
1242
1677
  }
1243
1678
 
1244
- // lib/vcs-message-generator.ts
1245
- var DEFAULT_MODELS = [
1246
- // "mistral/ministral-3b",
1247
- // "cerebras/qwen-3-235b",
1248
- // "openai/gpt-5.1",
1249
- "mistral/ministral-3b",
1250
- "xai/grok-code-fast-1"
1251
- ];
1252
- var isAbortError = (error) => error instanceof Error && error.name === "AbortError";
1253
- var isInvalidCliSessionIdError = (error) => error instanceof Error && error.message.includes("Invalid cliSessionId");
1254
- var delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1255
- var createAbortPromise = (signal) => signal ? new Promise((resolve) => {
1256
- if (signal.aborted) {
1257
- resolve(null);
1258
- return;
1679
+ // lib/stream-capture.ts
1680
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
1681
+ import { dirname as dirname3, resolve as resolve2 } from "path";
1682
+ var noopRecorder = {
1683
+ enabled: false,
1684
+ startGeneration: () => 0,
1685
+ recordEvent: () => {
1686
+ },
1687
+ finishGeneration: () => {
1688
+ },
1689
+ flush: () => null
1690
+ };
1691
+ function sanitizeEvent(event) {
1692
+ if (event.type === "provider-metadata") {
1693
+ return {
1694
+ type: "provider-metadata",
1695
+ providerMetadata: event.providerMetadata
1696
+ };
1259
1697
  }
1260
- signal.addEventListener("abort", () => resolve(null), { once: true });
1261
- }) : null;
1262
- async function* generateCommitMessages(options) {
1263
- const {
1264
- diff,
1265
- models,
1266
- signal,
1267
- cliSessionId,
1268
- commandExecutionPromise,
1269
- useStream = false
1270
- } = options;
1271
- const resolvedCliSessionId = cliSessionId;
1272
- if (!resolvedCliSessionId) {
1273
- throw new Error("Missing cliSessionId for generate request.");
1698
+ if (event.type === "usage") {
1699
+ return {
1700
+ type: "usage",
1701
+ usage: {
1702
+ inputTokens: event.usage.inputTokens,
1703
+ outputTokens: event.usage.outputTokens,
1704
+ totalTokens: event.usage.totalTokens
1705
+ }
1706
+ };
1274
1707
  }
1275
- const requiredCliSessionId = resolvedCliSessionId;
1276
- const token = await getToken();
1277
- if (!token) {
1278
- console.error("Error: Not authenticated. Run `ultrahope login` first.");
1279
- process.exit(1);
1708
+ if (event.type === "commit-message") {
1709
+ return {
1710
+ type: "commit-message",
1711
+ commitMessage: event.commitMessage
1712
+ };
1280
1713
  }
1281
- const api = createApiClient(token);
1282
- const generateWithRetry = async function* (payload) {
1283
- const maxAttempts = 3;
1284
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
1285
- try {
1286
- for await (const event of api.streamCommitMessage(payload, {
1287
- signal
1288
- })) {
1289
- log("generate", event);
1290
- yield event;
1291
- }
1292
- return;
1293
- } catch (error) {
1294
- if (signal?.aborted || isAbortError(error)) throw error;
1295
- if (isInvalidCliSessionIdError(error)) {
1296
- if (commandExecutionPromise) {
1297
- try {
1298
- await commandExecutionPromise;
1299
- continue;
1300
- } catch {
1301
- const abortError = new Error("Aborted");
1302
- abortError.name = "AbortError";
1303
- throw abortError;
1304
- }
1305
- }
1306
- if (attempt < maxAttempts - 1) {
1307
- await delay(80 * (attempt + 1));
1308
- continue;
1309
- }
1310
- }
1311
- throw error;
1312
- }
1714
+ return {
1715
+ type: "error",
1716
+ message: event.message
1717
+ };
1718
+ }
1719
+ function toSerializableCapture(capture) {
1720
+ return {
1721
+ ...capture,
1722
+ runs: capture.runs.map((run) => ({
1723
+ ...run,
1724
+ args: [...run.args],
1725
+ generations: run.generations.map((generation) => ({
1726
+ ...generation,
1727
+ models: [...generation.models],
1728
+ events: generation.events.map((event) => ({
1729
+ atMs: event.atMs,
1730
+ model: event.model,
1731
+ attempt: event.attempt,
1732
+ event: sanitizeEvent(event.event)
1733
+ }))
1734
+ }))
1735
+ }))
1736
+ };
1737
+ }
1738
+ function createStreamCaptureRecorder(options) {
1739
+ if (!options.path) {
1740
+ return noopRecorder;
1741
+ }
1742
+ const outputPath = resolve2(process.cwd(), options.path);
1743
+ const runStartedAtMs = Date.now();
1744
+ const run = {
1745
+ id: "run-1",
1746
+ command: options.command,
1747
+ args: options.args,
1748
+ apiPath: options.apiPath,
1749
+ startedAt: new Date(runStartedAtMs).toISOString(),
1750
+ generations: []
1751
+ };
1752
+ const capture = {
1753
+ version: 2,
1754
+ source: "ultrahope-commit-message-stream",
1755
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
1756
+ runs: [run]
1757
+ };
1758
+ let generationSequence = 0;
1759
+ let runFinished = false;
1760
+ const activeGenerations = /* @__PURE__ */ new Map();
1761
+ const writeCapture = () => {
1762
+ capture.capturedAt = (/* @__PURE__ */ new Date()).toISOString();
1763
+ mkdirSync2(dirname3(outputPath), { recursive: true });
1764
+ writeFileSync2(
1765
+ outputPath,
1766
+ `${JSON.stringify(toSerializableCapture(capture), null, 2)}
1767
+ `,
1768
+ "utf-8"
1769
+ );
1770
+ };
1771
+ const ensureRunFinished = () => {
1772
+ if (runFinished) {
1773
+ return;
1313
1774
  }
1314
- throw new Error("Failed to generate after retries.");
1775
+ runFinished = true;
1776
+ run.endedAt = (/* @__PURE__ */ new Date()).toISOString();
1315
1777
  };
1316
- async function* generateForModel(model, slotIndex) {
1317
- try {
1318
- if (signal?.aborted) return;
1319
- let lastCommitMessage = "";
1320
- let providerMetadata;
1321
- for await (const event of generateWithRetry({
1322
- cliSessionId: requiredCliSessionId,
1323
- input: diff,
1324
- model
1325
- })) {
1326
- if (event.type === "commit-message") {
1327
- lastCommitMessage = event.commitMessage;
1328
- if (useStream) {
1329
- yield {
1330
- content: lastCommitMessage,
1331
- slotId: model,
1332
- model,
1333
- isPartial: true,
1334
- slotIndex
1335
- };
1336
- }
1337
- } else if (event.type === "provider-metadata") {
1338
- providerMetadata = event.providerMetadata;
1339
- } else if (event.type === "error") {
1340
- throw new Error(event.message);
1341
- }
1778
+ return {
1779
+ enabled: true,
1780
+ startGeneration: ({ cliSessionId, models }) => {
1781
+ generationSequence += 1;
1782
+ const startedAtMs = Date.now();
1783
+ const generation = {
1784
+ id: `generation-${generationSequence}`,
1785
+ cliSessionId,
1786
+ models: [...models],
1787
+ startedAt: new Date(startedAtMs).toISOString(),
1788
+ events: []
1789
+ };
1790
+ run.generations.push(generation);
1791
+ activeGenerations.set(generationSequence, { generation, startedAtMs });
1792
+ return generationSequence;
1793
+ },
1794
+ recordEvent: (generationId, record) => {
1795
+ const activeGeneration = activeGenerations.get(generationId);
1796
+ if (!activeGeneration) {
1797
+ return;
1342
1798
  }
1343
- if (lastCommitMessage) {
1344
- const { generationId, cost } = extractGatewayMetadata(providerMetadata);
1345
- yield {
1346
- content: lastCommitMessage,
1347
- slotId: model,
1348
- model,
1349
- cost,
1350
- generationId,
1351
- ...useStream ? { isPartial: false } : {},
1352
- slotIndex
1353
- };
1799
+ const eventAtMs = typeof record.event.atMs === "number" && Number.isFinite(record.event.atMs) ? record.event.atMs : Date.now() - activeGeneration.startedAtMs;
1800
+ activeGeneration.generation.events.push({
1801
+ atMs: Math.max(0, Math.round(eventAtMs)),
1802
+ model: record.model,
1803
+ attempt: record.attempt,
1804
+ event: sanitizeEvent(record.event)
1805
+ });
1806
+ },
1807
+ finishGeneration: (generationId) => {
1808
+ const activeGeneration = activeGenerations.get(generationId);
1809
+ if (!activeGeneration) {
1810
+ return;
1354
1811
  }
1355
- } catch (error) {
1356
- if (signal?.aborted || isAbortError(error)) return;
1357
- if (error instanceof InsufficientBalanceError) throw error;
1358
- if (isInvalidCliSessionIdError(error)) throw error;
1359
- }
1360
- }
1361
- const iterators = models.map((model, index) => ({
1362
- iterator: generateForModel(model, index)[Symbol.asyncIterator](),
1363
- index
1364
- }));
1365
- const pending = /* @__PURE__ */ new Map();
1366
- const startNext = (it) => {
1367
- const promise = it.iterator.next().then((result) => ({
1368
- result,
1369
- index: it.index
1370
- }));
1371
- pending.set(it.index, {
1372
- iterator: it.iterator,
1373
- promise,
1374
- index: it.index
1375
- });
1376
- };
1377
- for (const it of iterators) {
1378
- startNext(it);
1379
- }
1380
- const abortPromise = createAbortPromise(signal);
1381
- try {
1382
- while (pending.size > 0) {
1383
- if (signal?.aborted) break;
1384
- const next = Promise.race(
1385
- Array.from(pending.values()).map((p) => p.promise)
1386
- );
1387
- const winner = abortPromise ? await Promise.race([next, abortPromise]) : await next;
1388
- if (!winner || signal?.aborted) break;
1389
- const { result, index } = winner;
1390
- const entry = pending.get(index);
1391
- if (!entry) continue;
1392
- if (result.done) {
1393
- pending.delete(index);
1394
- } else {
1395
- yield result.value;
1396
- startNext({ iterator: entry.iterator, index });
1812
+ activeGeneration.generation.endedAt = (/* @__PURE__ */ new Date()).toISOString();
1813
+ activeGenerations.delete(generationId);
1814
+ writeCapture();
1815
+ },
1816
+ flush: () => {
1817
+ for (const [generationId, activeGeneration] of activeGenerations) {
1818
+ activeGeneration.generation.endedAt = (/* @__PURE__ */ new Date()).toISOString();
1819
+ activeGenerations.delete(generationId);
1397
1820
  }
1821
+ ensureRunFinished();
1822
+ writeCapture();
1823
+ return outputPath;
1398
1824
  }
1399
- } catch (error) {
1400
- if (error instanceof InsufficientBalanceError) {
1401
- console.error(
1402
- "Error: Token balance exhausted. Upgrade your plan at https://ultrahope.dev/pricing"
1403
- );
1404
- process.exit(1);
1405
- }
1406
- throw error;
1407
- }
1825
+ };
1408
1826
  }
1409
1827
 
1410
1828
  // commands/jj.ts
1829
+ function exitWithInvalidModelError(error) {
1830
+ console.error(`Error: Model '${error.model}' is not supported.`);
1831
+ if (error.allowedModels.length > 0) {
1832
+ console.error(`Available models: ${error.allowedModels.join(", ")}`);
1833
+ }
1834
+ process.exit(1);
1835
+ }
1411
1836
  function showQuotaInfo(quota) {
1412
1837
  const { relative, local } = formatResetTime(quota.resetsAt);
1413
1838
  console.log("");
@@ -1417,24 +1842,48 @@ function showQuotaInfo(quota) {
1417
1842
  )
1418
1843
  );
1419
1844
  }
1845
+ function normalizeGuide(value) {
1846
+ if (!value) return void 0;
1847
+ const trimmed = value.trim();
1848
+ if (!trimmed) return void 0;
1849
+ return trimmed.length > 1024 ? trimmed.slice(0, 1024) : trimmed;
1850
+ }
1420
1851
  function parseDescribeArgs(args2) {
1421
1852
  let revision = "@";
1422
1853
  let interactive = true;
1423
- let models = [];
1854
+ let cliModels;
1855
+ let captureStreamPath;
1856
+ let guide;
1424
1857
  for (let i = 0; i < args2.length; i++) {
1425
1858
  const arg = args2[i];
1426
1859
  if (arg === "-r") {
1427
1860
  revision = args2[++i] || "@";
1428
1861
  } else if (arg === "--no-interactive") {
1429
1862
  interactive = false;
1430
- } else if (arg === "--models" && args2[i + 1]) {
1431
- models = args2[++i].split(",").map((m) => m.trim());
1863
+ } else if (arg === "--models") {
1864
+ const value = args2[++i];
1865
+ if (!value) {
1866
+ console.error("Error: --models requires a value");
1867
+ process.exit(1);
1868
+ }
1869
+ cliModels = parseModelsArg(value);
1870
+ } else if (arg === "--capture-stream") {
1871
+ const value = args2[++i];
1872
+ if (!value) {
1873
+ console.error("Error: --capture-stream requires a file path");
1874
+ process.exit(1);
1875
+ }
1876
+ captureStreamPath = value;
1877
+ } else if (arg === "--guide") {
1878
+ const value = args2[++i];
1879
+ if (!value) {
1880
+ console.error("Error: --guide requires a text value.");
1881
+ process.exit(1);
1882
+ }
1883
+ guide = normalizeGuide(value);
1432
1884
  }
1433
1885
  }
1434
- if (models.length === 0) {
1435
- models = DEFAULT_MODELS;
1436
- }
1437
- return { revision, interactive, models };
1886
+ return { revision, interactive, cliModels, captureStreamPath, guide };
1438
1887
  }
1439
1888
  function getJjDiff(revision) {
1440
1889
  try {
@@ -1466,7 +1915,7 @@ function assertDiffAvailable(revision, diff) {
1466
1915
  process.exit(1);
1467
1916
  }
1468
1917
  }
1469
- async function initCommandExecutionContext(args2, options, diff) {
1918
+ async function initCommandExecutionContext(args2, models, diff, guide) {
1470
1919
  const token = await getToken();
1471
1920
  if (!token) {
1472
1921
  console.error("Error: Not authenticated. Run `ultrahope login` first.");
@@ -1481,13 +1930,14 @@ async function initCommandExecutionContext(args2, options, diff) {
1481
1930
  requestPayload: {
1482
1931
  input: diff,
1483
1932
  target: "vcs-commit-message",
1484
- models: options.models
1933
+ models,
1934
+ ...guide ? { guide } : {}
1485
1935
  }
1486
1936
  });
1487
1937
  commandExecutionPromise.catch(async (error) => {
1488
1938
  abortController.abort(abortReasonForError(error));
1489
1939
  await handleCommandExecutionError(error, {
1490
- progress: { ready: 0, total: options.models.length }
1940
+ progress: { ready: 0, total: models.length }
1491
1941
  });
1492
1942
  });
1493
1943
  return {
@@ -1512,41 +1962,53 @@ async function recordSelection(apiClient, generationId) {
1512
1962
  console.error(`Warning: Failed to record selection. ${message}`);
1513
1963
  }
1514
1964
  }
1515
- function createCandidateFactory(diff, options, context) {
1965
+ function createCandidateFactory(diff, models, context, captureRecorder, guide) {
1516
1966
  return (signal) => generateCommitMessages({
1517
1967
  diff,
1518
- models: options.models,
1968
+ models,
1969
+ guide,
1519
1970
  signal: mergeAbortSignals(signal, context.commandExecutionSignal),
1520
1971
  cliSessionId: context.cliSessionId,
1521
1972
  commandExecutionPromise: context.commandExecutionPromise,
1522
- useStream: true
1973
+ useStream: true,
1974
+ streamCaptureRecorder: captureRecorder
1523
1975
  });
1524
1976
  }
1525
- async function runNonInteractiveDescribe(options, diff, context) {
1977
+ async function runNonInteractiveDescribe(revision, models, diff, context, captureRecorder, guide) {
1526
1978
  const gen = generateCommitMessages({
1527
1979
  diff,
1528
- models: options.models.slice(0, 1),
1980
+ models: models.slice(0, 1),
1981
+ guide,
1529
1982
  signal: context.commandExecutionSignal,
1530
1983
  cliSessionId: context.cliSessionId,
1531
1984
  commandExecutionPromise: context.commandExecutionPromise,
1532
- useStream: true
1985
+ useStream: true,
1986
+ streamCaptureRecorder: captureRecorder
1987
+ });
1988
+ const first = await gen.next().catch((error) => {
1989
+ if (error instanceof InvalidModelError) {
1990
+ exitWithInvalidModelError(error);
1991
+ }
1992
+ throw error;
1533
1993
  });
1534
- const first = await gen.next();
1535
1994
  await recordSelection(context.apiClient, first.value?.generationId);
1536
1995
  const message = first.value?.content ?? "";
1537
- describeRevision(options.revision, message);
1996
+ describeRevision(revision, message);
1538
1997
  }
1539
- async function runInteractiveDescribe(options, createCandidates, context) {
1998
+ async function runInteractiveDescribe(options, models, createCandidates, context) {
1540
1999
  const stats = getJjDiffStats(options.revision);
1541
2000
  console.log(ui.success(`Found ${formatDiffStats(stats)}`));
1542
2001
  while (true) {
1543
2002
  const result = await selectCandidate({
1544
2003
  createCandidates,
1545
- maxSlots: options.models.length,
2004
+ maxSlots: models.length,
1546
2005
  abortSignal: context.commandExecutionSignal,
1547
- models: options.models
2006
+ models
1548
2007
  });
1549
2008
  if (result.action === "abort") {
2009
+ if (result.error instanceof InvalidModelError) {
2010
+ exitWithInvalidModelError(result.error);
2011
+ }
1550
2012
  if (isCommandExecutionAbort(context.commandExecutionSignal)) {
1551
2013
  return;
1552
2014
  }
@@ -1577,29 +2039,100 @@ async function runInteractiveDescribe(options, createCandidates, context) {
1577
2039
  }
1578
2040
  async function describe(args2) {
1579
2041
  const options = parseDescribeArgs(args2);
2042
+ const models = resolveModels(options.cliModels);
1580
2043
  const diff = getJjDiff(options.revision);
1581
2044
  assertDiffAvailable(options.revision, diff);
1582
- const context = await initCommandExecutionContext(args2, options, diff);
1583
- const createCandidates = createCandidateFactory(diff, options, context);
1584
- if (!options.interactive) {
1585
- await runNonInteractiveDescribe(options, diff, context);
1586
- return;
2045
+ const captureRecorder = createStreamCaptureRecorder({
2046
+ path: options.captureStreamPath,
2047
+ command: "ultrahope jj describe",
2048
+ args: ["describe", ...args2],
2049
+ apiPath: "/v1/commit-message/stream"
2050
+ });
2051
+ try {
2052
+ const context = await initCommandExecutionContext(
2053
+ args2,
2054
+ models,
2055
+ diff,
2056
+ options.guide
2057
+ );
2058
+ const createCandidates = createCandidateFactory(
2059
+ diff,
2060
+ models,
2061
+ context,
2062
+ captureRecorder,
2063
+ options.guide
2064
+ );
2065
+ if (!options.interactive) {
2066
+ await runNonInteractiveDescribe(
2067
+ options.revision,
2068
+ models,
2069
+ diff,
2070
+ context,
2071
+ captureRecorder,
2072
+ options.guide
2073
+ );
2074
+ return;
2075
+ }
2076
+ await runInteractiveDescribe(options, models, createCandidates, context);
2077
+ } finally {
2078
+ const capturePath = captureRecorder.flush();
2079
+ if (capturePath) {
2080
+ console.log(ui.hint(`Captured stream replay to ${capturePath}`));
2081
+ }
1587
2082
  }
1588
- await runInteractiveDescribe(options, createCandidates, context);
2083
+ }
2084
+ function setup() {
2085
+ try {
2086
+ const existing = execFileSync(
2087
+ "jj",
2088
+ ["config", "get", "aliases.ultrahope"],
2089
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
2090
+ ).trim();
2091
+ if (existing) {
2092
+ console.log(ui.success("jj alias 'ultrahope' is already configured."));
2093
+ console.log(ui.hint(" Run `jj ultrahope describe` to use it."));
2094
+ return;
2095
+ }
2096
+ } catch {
2097
+ }
2098
+ execFileSync(
2099
+ "jj",
2100
+ [
2101
+ "config",
2102
+ "set",
2103
+ "--user",
2104
+ "aliases.ultrahope",
2105
+ '["util", "exec", "--", "ultrahope", "jj"]'
2106
+ ],
2107
+ { stdio: "inherit" }
2108
+ );
2109
+ console.log(ui.success("Added jj alias 'ultrahope'."));
2110
+ console.log(
2111
+ ui.hint(
2112
+ " You can now run `jj ultrahope describe` instead of `ultrahope jj describe`."
2113
+ )
2114
+ );
1589
2115
  }
1590
2116
  function printHelp() {
1591
2117
  console.log(`Usage: ultrahope jj <command>
1592
2118
 
1593
2119
  Commands:
1594
2120
  describe Generate commit description from changes
2121
+ setup Register 'ultrahope' as a jj alias
1595
2122
 
1596
2123
  Describe options:
1597
2124
  -r <revset> Revision to describe (default: @)
1598
2125
  --no-interactive Single candidate, no selection
2126
+ --guide <text> Additional context to guide message generation
2127
+ --models <list> Comma-separated model list (overrides config)
2128
+ --capture-stream <path> Save candidate stream as replay JSON
1599
2129
 
1600
2130
  Examples:
1601
2131
  ultrahope jj describe # interactive mode
1602
- ultrahope jj describe -r @- # for parent revision`);
2132
+ ultrahope jj describe -r @- # for parent revision
2133
+ ultrahope jj describe --guide "GHSA-gq3j-xvxp-8hrf: override reason"
2134
+ ultrahope jj describe --capture-stream packages/web/lib/demo/commit-message-stream.capture.json
2135
+ ultrahope jj setup # enable \`jj ultrahope\` alias`);
1603
2136
  }
1604
2137
  async function jj(args2) {
1605
2138
  const [command2, ...rest] = args2;
@@ -1607,6 +2140,9 @@ async function jj(args2) {
1607
2140
  case "describe":
1608
2141
  await describe(rest);
1609
2142
  break;
2143
+ case "setup":
2144
+ setup();
2145
+ break;
1610
2146
  case "--help":
1611
2147
  case "-h":
1612
2148
  case void 0:
@@ -1636,6 +2172,7 @@ async function login(_args) {
1636
2172
  deviceCode.expires_in
1637
2173
  );
1638
2174
  await saveToken(token);
2175
+ await ensureGlobalConfigFile();
1639
2176
  console.log("Successfully authenticated!");
1640
2177
  }
1641
2178
  async function pollForToken(api, deviceCode, interval, expiresIn) {
@@ -1653,7 +2190,7 @@ async function pollForToken(api, deviceCode, interval, expiresIn) {
1653
2190
  throw new Error("Authentication timed out");
1654
2191
  }
1655
2192
  function sleep(ms) {
1656
- return new Promise((resolve) => setTimeout(resolve, ms));
2193
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
1657
2194
  }
1658
2195
 
1659
2196
  // lib/stdin.ts
@@ -1676,6 +2213,13 @@ var TARGET_TO_API_PATH = {
1676
2213
  "pr-title-body": "/v1/pr-title-body",
1677
2214
  "pr-intent": "/v1/pr-intent"
1678
2215
  };
2216
+ function exitWithInvalidModelError2(error) {
2217
+ console.error(`Error: Model '${error.model}' is not supported.`);
2218
+ if (error.allowedModels.length > 0) {
2219
+ console.error(`Available models: ${error.allowedModels.join(", ")}`);
2220
+ }
2221
+ process.exit(1);
2222
+ }
1679
2223
  async function translate(args2) {
1680
2224
  const options = parseArgs(args2);
1681
2225
  const input = await stdin();
@@ -1685,6 +2229,12 @@ async function translate(args2) {
1685
2229
  );
1686
2230
  process.exit(1);
1687
2231
  }
2232
+ if (options.captureStreamPath && options.target !== "vcs-commit-message") {
2233
+ console.error(
2234
+ "Error: --capture-stream is only supported with --target vcs-commit-message."
2235
+ );
2236
+ process.exit(1);
2237
+ }
1688
2238
  if (options.target === "vcs-commit-message") {
1689
2239
  await handleVcsCommitMessage(input, options, args2);
1690
2240
  return;
@@ -1692,90 +2242,113 @@ async function translate(args2) {
1692
2242
  await handleGenericTarget(input, options, args2);
1693
2243
  }
1694
2244
  async function handleVcsCommitMessage(input, options, args2) {
1695
- const models = options.model ? [options.model] : DEFAULT_MODELS;
1696
- const token = await getToken();
1697
- if (!token) {
1698
- console.error("Error: Not authenticated. Run `ultrahope login` first.");
1699
- process.exit(1);
1700
- }
1701
- const api = createApiClient(token);
1702
- const {
1703
- commandExecutionPromise: promise,
1704
- abortController,
1705
- cliSessionId: id
1706
- } = startCommandExecution({
1707
- api,
1708
- command: "translate",
2245
+ const captureRecorder = createStreamCaptureRecorder({
2246
+ path: options.captureStreamPath,
2247
+ command: "ultrahope translate",
1709
2248
  args: args2,
1710
- apiPath: TARGET_TO_API_PATH[options.target],
1711
- requestPayload: models.length === 1 ? { input, target: "vcs-commit-message", model: models[0] } : { input, target: "vcs-commit-message", models }
2249
+ apiPath: "/v1/commit-message/stream"
1712
2250
  });
1713
- const cliSessionId = id;
1714
- const commandExecutionSignal = abortController.signal;
1715
- const commandExecutionPromise = promise;
1716
- const apiClient = api;
1717
- commandExecutionPromise.catch(async (error) => {
1718
- abortController.abort(abortReasonForError(error));
1719
- await handleCommandExecutionError(error, {
1720
- progress: { ready: 0, total: models.length }
2251
+ const models = resolveModels(options.cliModels);
2252
+ try {
2253
+ const token = await getToken();
2254
+ if (!token) {
2255
+ console.error("Error: Not authenticated. Run `ultrahope login` first.");
2256
+ process.exit(1);
2257
+ }
2258
+ const api = createApiClient(token);
2259
+ const {
2260
+ commandExecutionPromise: promise,
2261
+ abortController,
2262
+ cliSessionId: id
2263
+ } = startCommandExecution({
2264
+ api,
2265
+ command: "translate",
2266
+ args: args2,
2267
+ apiPath: TARGET_TO_API_PATH[options.target],
2268
+ requestPayload: models.length === 1 ? { input, target: "vcs-commit-message", model: models[0] } : { input, target: "vcs-commit-message", models }
1721
2269
  });
1722
- });
1723
- const recordSelection2 = async (generationId) => {
1724
- if (!generationId || !apiClient) return;
1725
- try {
1726
- await apiClient.recordGenerationScore({
1727
- generationId,
1728
- value: 1
2270
+ const cliSessionId = id;
2271
+ const commandExecutionSignal = abortController.signal;
2272
+ const commandExecutionPromise = promise;
2273
+ const apiClient = api;
2274
+ commandExecutionPromise.catch(async (error) => {
2275
+ abortController.abort(abortReasonForError(error));
2276
+ await handleCommandExecutionError(error, {
2277
+ progress: { ready: 0, total: models.length }
1729
2278
  });
1730
- } catch (error) {
1731
- const message = error instanceof Error ? error.message : String(error);
1732
- if (message.includes("Generation not found")) {
1733
- return;
2279
+ });
2280
+ const recordSelection2 = async (generationId) => {
2281
+ if (!generationId || !apiClient) return;
2282
+ try {
2283
+ await apiClient.recordGenerationScore({
2284
+ generationId,
2285
+ value: 1
2286
+ });
2287
+ } catch (error) {
2288
+ const message = error instanceof Error ? error.message : String(error);
2289
+ if (message.includes("Generation not found")) {
2290
+ return;
2291
+ }
2292
+ console.error(`Warning: Failed to record selection. ${message}`);
1734
2293
  }
1735
- console.error(`Warning: Failed to record selection. ${message}`);
1736
- }
1737
- };
1738
- const createCandidates = (signal) => generateCommitMessages({
1739
- diff: input,
1740
- models,
1741
- signal: mergeAbortSignals(signal, commandExecutionSignal),
1742
- cliSessionId,
1743
- commandExecutionPromise
1744
- });
1745
- if (!options.interactive) {
1746
- const gen = generateCommitMessages({
2294
+ };
2295
+ const createCandidates = (signal) => generateCommitMessages({
1747
2296
  diff: input,
1748
- models: models.slice(0, 1),
1749
- signal: commandExecutionSignal,
2297
+ models,
2298
+ signal: mergeAbortSignals(signal, commandExecutionSignal),
1750
2299
  cliSessionId,
1751
- commandExecutionPromise
1752
- });
1753
- const first = await gen.next();
1754
- await recordSelection2(first.value?.generationId);
1755
- console.log(first.value?.content ?? "");
1756
- return;
1757
- }
1758
- while (true) {
1759
- const result = await selectCandidate({
1760
- createCandidates,
1761
- maxSlots: models.length,
1762
- abortSignal: commandExecutionSignal,
1763
- models
2300
+ commandExecutionPromise,
2301
+ streamCaptureRecorder: captureRecorder
1764
2302
  });
1765
- if (result.action === "abort") {
1766
- if (isCommandExecutionAbort(commandExecutionSignal)) {
2303
+ if (!options.interactive) {
2304
+ const gen = generateCommitMessages({
2305
+ diff: input,
2306
+ models: models.slice(0, 1),
2307
+ signal: commandExecutionSignal,
2308
+ cliSessionId,
2309
+ commandExecutionPromise,
2310
+ streamCaptureRecorder: captureRecorder
2311
+ });
2312
+ const first = await gen.next().catch((error) => {
2313
+ if (error instanceof InvalidModelError) {
2314
+ exitWithInvalidModelError2(error);
2315
+ }
2316
+ throw error;
2317
+ });
2318
+ await recordSelection2(first.value?.generationId);
2319
+ console.log(first.value?.content ?? "");
2320
+ return;
2321
+ }
2322
+ while (true) {
2323
+ const result = await selectCandidate({
2324
+ createCandidates,
2325
+ maxSlots: models.length,
2326
+ abortSignal: commandExecutionSignal,
2327
+ models
2328
+ });
2329
+ if (result.action === "abort") {
2330
+ if (result.error instanceof InvalidModelError) {
2331
+ exitWithInvalidModelError2(result.error);
2332
+ }
2333
+ if (isCommandExecutionAbort(commandExecutionSignal)) {
2334
+ return;
2335
+ }
2336
+ console.error("Aborted.");
2337
+ process.exit(1);
2338
+ }
2339
+ if (result.action === "reroll") {
2340
+ continue;
2341
+ }
2342
+ if (result.action === "confirm" && result.selected) {
2343
+ await recordSelection2(result.selectedCandidate?.generationId);
2344
+ console.log(result.selected);
1767
2345
  return;
1768
2346
  }
1769
- console.error("Aborted.");
1770
- process.exit(1);
1771
2347
  }
1772
- if (result.action === "reroll") {
1773
- continue;
1774
- }
1775
- if (result.action === "confirm" && result.selected) {
1776
- await recordSelection2(result.selectedCandidate?.generationId);
1777
- console.log(result.selected);
1778
- return;
2348
+ } finally {
2349
+ const capturePath = captureRecorder.flush();
2350
+ if (capturePath) {
2351
+ console.log(`Captured stream replay to ${capturePath}`);
1779
2352
  }
1780
2353
  }
1781
2354
  }
@@ -1786,7 +2359,7 @@ async function handleGenericTarget(input, options, args2) {
1786
2359
  process.exit(1);
1787
2360
  }
1788
2361
  const api = createApiClient(token);
1789
- const models = options.model ? [options.model] : DEFAULT_MODELS;
2362
+ const models = resolveModels(options.cliModels);
1790
2363
  const defaultModel = models[0];
1791
2364
  const requestPayload = models.length === 1 ? { input, target: options.target, model: models[0] } : { input, target: options.target, models };
1792
2365
  const {
@@ -1808,7 +2381,7 @@ async function handleGenericTarget(input, options, args2) {
1808
2381
  );
1809
2382
  const isAbortError2 = (error) => error instanceof Error && error.name === "AbortError";
1810
2383
  const isInvalidCliSessionIdError2 = (error) => error instanceof Error && error.message.includes("Invalid cliSessionId");
1811
- const delay2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2384
+ const delay2 = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
1812
2385
  const generateFn = (req) => {
1813
2386
  if (options.target === "pr-title-body") {
1814
2387
  return api.generatePrTitleBody(req, { signal: abortController.signal });
@@ -1858,10 +2431,11 @@ async function handleGenericTarget(input, options, args2) {
1858
2431
  if (isAbortError2(error) || abortController.signal.aborted) {
1859
2432
  return candidates2;
1860
2433
  }
2434
+ if (error instanceof InvalidModelError) {
2435
+ exitWithInvalidModelError2(error);
2436
+ }
1861
2437
  if (error instanceof InsufficientBalanceError) {
1862
- console.error(
1863
- "Error: Token balance exhausted. Upgrade your plan at https://ultrahope.dev/pricing"
1864
- );
2438
+ console.error(error.formatMessage());
1865
2439
  process.exit(1);
1866
2440
  }
1867
2441
  throw error;
@@ -1878,10 +2452,11 @@ async function handleGenericTarget(input, options, args2) {
1878
2452
  if (isAbortError2(error) || abortController.signal.aborted) {
1879
2453
  return null;
1880
2454
  }
2455
+ if (error instanceof InvalidModelError) {
2456
+ exitWithInvalidModelError2(error);
2457
+ }
1881
2458
  if (error instanceof InsufficientBalanceError) {
1882
- console.error(
1883
- "Error: Token balance exhausted. Upgrade your plan at https://ultrahope.dev/pricing"
1884
- );
2459
+ console.error(error.formatMessage());
1885
2460
  process.exit(1);
1886
2461
  }
1887
2462
  throw error;
@@ -1918,6 +2493,9 @@ async function handleGenericTarget(input, options, args2) {
1918
2493
  models: candidates.map((c) => c.model).filter(Boolean)
1919
2494
  });
1920
2495
  if (result.action === "abort") {
2496
+ if (result.error instanceof InvalidModelError) {
2497
+ exitWithInvalidModelError2(result.error);
2498
+ }
1921
2499
  console.error("Aborted.");
1922
2500
  process.exit(1);
1923
2501
  }
@@ -1947,7 +2525,8 @@ async function handleGenericTarget(input, options, args2) {
1947
2525
  function parseArgs(args2) {
1948
2526
  let target;
1949
2527
  let interactive = true;
1950
- let model;
2528
+ let cliModels;
2529
+ let captureStreamPath;
1951
2530
  for (let i = 0; i < args2.length; i++) {
1952
2531
  const arg = args2[i];
1953
2532
  if (arg === "--target" || arg === "-t") {
@@ -1960,13 +2539,23 @@ function parseArgs(args2) {
1960
2539
  target = value;
1961
2540
  } else if (arg === "--no-interactive") {
1962
2541
  interactive = false;
2542
+ } else if (arg === "--models") {
2543
+ const value = args2[++i];
2544
+ if (!value) {
2545
+ console.error("Error: --models requires a value");
2546
+ process.exit(1);
2547
+ }
2548
+ cliModels = parseModelsArg(value);
1963
2549
  } else if (arg === "--model") {
2550
+ console.error("Error: --model is no longer supported. Use --models.");
2551
+ process.exit(1);
2552
+ } else if (arg === "--capture-stream") {
1964
2553
  const value = args2[++i];
1965
2554
  if (!value) {
1966
- console.error("Error: --model requires a value");
2555
+ console.error("Error: --capture-stream requires a file path");
1967
2556
  process.exit(1);
1968
2557
  }
1969
- model = value;
2558
+ captureStreamPath = value;
1970
2559
  }
1971
2560
  }
1972
2561
  if (!target) {
@@ -1976,12 +2565,60 @@ function parseArgs(args2) {
1976
2565
  );
1977
2566
  process.exit(1);
1978
2567
  }
1979
- return { target, interactive, model };
2568
+ return { target, interactive, cliModels, captureStreamPath };
1980
2569
  }
1981
2570
 
2571
+ // package.json
2572
+ var package_default = {
2573
+ name: "ultrahope",
2574
+ version: "0.1.4",
2575
+ description: "LLM-powered development workflow assistant",
2576
+ type: "module",
2577
+ license: "MIT",
2578
+ repository: {
2579
+ type: "git",
2580
+ url: "git+https://github.com/toyamarinyon/ultrahope.git"
2581
+ },
2582
+ keywords: [
2583
+ "cli",
2584
+ "llm",
2585
+ "ai",
2586
+ "git",
2587
+ "commit-message",
2588
+ "pull-request"
2589
+ ],
2590
+ bin: {
2591
+ ultrahope: "dist/index.js",
2592
+ "git-ultrahope": "dist/git-ultrahope.js",
2593
+ "git-hope": "dist/git-ultrahope.js",
2594
+ "git-uh": "dist/git-ultrahope.js"
2595
+ },
2596
+ files: [
2597
+ "dist"
2598
+ ],
2599
+ scripts: {
2600
+ build: "tsup",
2601
+ prebuild: "bun run generate:client",
2602
+ "generate:client": "tsx scripts/generate-client.ts",
2603
+ typecheck: "tsc --noEmit",
2604
+ prepublishOnly: "bun run build"
2605
+ },
2606
+ dependencies: {
2607
+ open: "^11.0.0",
2608
+ "openapi-fetch": "^0.15.0",
2609
+ "smol-toml": "^1.4.1"
2610
+ },
2611
+ devDependencies: {
2612
+ "@ultrahope/web": "workspace:*",
2613
+ "@types/node": "^22.15.29",
2614
+ "openapi-typescript": "^7.10.1",
2615
+ tsup: "8.5.0",
2616
+ typescript: "^5.8.3"
2617
+ }
2618
+ };
2619
+
1982
2620
  // index.ts
1983
- var require2 = createRequire(import.meta.url);
1984
- var { version } = require2("../package.json");
2621
+ var { version } = package_default;
1985
2622
  var [command, ...args] = process.argv.slice(2);
1986
2623
  async function main() {
1987
2624
  switch (command) {