ultrahope 0.1.3 → 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.
@@ -1,10 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // commands/commit.ts
4
- import { execSync as execSync2, spawn as spawn2 } from "child_process";
5
- import { mkdtempSync as mkdtempSync2, readFileSync as readFileSync3, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "fs";
6
- import { tmpdir as tmpdir2 } from "os";
7
- import { join as join5 } from "path";
4
+ import { execSync as execSync2 } 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) {
@@ -110,12 +129,17 @@ function parseSseEvents(buffer) {
110
129
  return { events, remainder };
111
130
  }
112
131
  function handle402Error(error) {
113
- const errorBalance = error?.balance;
114
- if (typeof errorBalance === "number") {
132
+ const payload = error;
133
+ if (typeof payload?.balance === "number") {
115
134
  log("generate error (402 insufficient_balance)", error);
116
- 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
+ );
117
142
  }
118
- const payload = error;
119
143
  const count = typeof payload?.count === "number" ? payload.count : 0;
120
144
  const limit = typeof payload?.limit === "number" ? payload.limit : 0;
121
145
  const resetsAt = payload?.resetsAt ?? "";
@@ -238,12 +262,8 @@ function createApiClient(token) {
238
262
  throw new UnauthorizedError();
239
263
  }
240
264
  if (response.status === 402) {
241
- const payload = error;
242
- const count = typeof payload?.count === "number" ? payload.count : 0;
243
- const limit = typeof payload?.limit === "number" ? payload.limit : 0;
244
- const resetsAt = payload?.resetsAt ?? "";
245
265
  log("command_execution error (402)", error);
246
- throw new DailyLimitExceededError(count, limit, resetsAt);
266
+ handle402Error(error);
247
267
  }
248
268
  if (!response.ok) {
249
269
  const text = await getErrorText(response, error);
@@ -595,7 +615,7 @@ async function showDailyLimitPrompt(info) {
595
615
  }
596
616
  }
597
617
  function promptChoice() {
598
- return new Promise((resolve2) => {
618
+ return new Promise((resolve3) => {
599
619
  const fd = openSync("/dev/tty", "r");
600
620
  const ttyInput = new tty.ReadStream(fd);
601
621
  const rl = readline.createInterface({
@@ -618,17 +638,17 @@ function promptChoice() {
618
638
  if (!key && !str) return;
619
639
  if (str === "q" || str === "Q" || key?.name === "q" || key?.name === "c" && key.ctrl || key?.name === "escape") {
620
640
  cleanup();
621
- resolve2("q");
641
+ resolve3("q");
622
642
  return;
623
643
  }
624
644
  if (str === "1" || key?.name === "1" || key?.sequence === "1") {
625
645
  cleanup();
626
- resolve2("1");
646
+ resolve3("1");
627
647
  return;
628
648
  }
629
649
  if (str === "2" || key?.name === "2" || key?.sequence === "2") {
630
650
  cleanup();
631
- resolve2("2");
651
+ resolve3("2");
632
652
  return;
633
653
  }
634
654
  };
@@ -645,7 +665,7 @@ function handleRetryLater() {
645
665
  );
646
666
  console.log(` ${ui.link("ultrahope jj describe")}`);
647
667
  console.log("");
648
- return new Promise((resolve2) => {
668
+ return new Promise((resolve3) => {
649
669
  const fd = openSync("/dev/tty", "r");
650
670
  const ttyInput = new tty.ReadStream(fd);
651
671
  const rl = readline.createInterface({
@@ -665,7 +685,7 @@ function handleRetryLater() {
665
685
  if (!key && !str) return;
666
686
  if (str === "\r" || str === "\n" || str === "q" || str === "Q" || key?.name === "return" || key?.name === "q" || key?.name === "c" && key.ctrl) {
667
687
  cleanup();
668
- resolve2();
688
+ resolve3();
669
689
  }
670
690
  };
671
691
  ttyInput.on("keypress", handleKeypress);
@@ -729,6 +749,10 @@ async function handleCommandExecutionError(error, options) {
729
749
  });
730
750
  process.exit(1);
731
751
  }
752
+ if (error instanceof InsufficientBalanceError) {
753
+ console.error(error.formatMessage());
754
+ process.exit(1);
755
+ }
732
756
  const message = error instanceof Error ? error.message : String(error);
733
757
  console.error(`Error: Failed to start command execution. ${message}`);
734
758
  process.exit(1);
@@ -740,6 +764,81 @@ import * as os2 from "os";
740
764
  import * as path2 from "path";
741
765
  import { parse } from "smol-toml";
742
766
 
767
+ // ../shared/async-race.ts
768
+ async function* raceAsyncIterators({
769
+ iterators,
770
+ signal,
771
+ onError
772
+ }) {
773
+ if (iterators.length === 0) {
774
+ return;
775
+ }
776
+ if (signal?.aborted) {
777
+ return;
778
+ }
779
+ const abortPromise = signal ? new Promise((resolve3) => {
780
+ signal.addEventListener("abort", () => resolve3(null), { once: true });
781
+ }) : null;
782
+ const pending = /* @__PURE__ */ new Map();
783
+ const startNext = (index) => {
784
+ const iterator = iterators[index];
785
+ if (!iterator) {
786
+ return;
787
+ }
788
+ pending.set(
789
+ index,
790
+ iterator.next().then(
791
+ (result) => ({ kind: "result", index, result }),
792
+ (error) => ({ kind: "error", index, error })
793
+ )
794
+ );
795
+ };
796
+ const throwOrContinue = (error, index) => {
797
+ if (!onError) {
798
+ throw error;
799
+ }
800
+ const decision = onError(index, error);
801
+ if (decision === "throw") {
802
+ throw error;
803
+ }
804
+ };
805
+ try {
806
+ for (let index = 0; index < iterators.length; index++) {
807
+ startNext(index);
808
+ }
809
+ while (pending.size > 0) {
810
+ const winner = abortPromise ? await Promise.race([
811
+ ...pending.values(),
812
+ abortPromise.then(() => null)
813
+ ]) : await Promise.race([...pending.values()]);
814
+ if (winner === null) {
815
+ return;
816
+ }
817
+ const { index } = winner;
818
+ pending.delete(index);
819
+ if (winner.kind === "error") {
820
+ throwOrContinue(winner.error, winner.index);
821
+ continue;
822
+ }
823
+ const { result } = winner;
824
+ if (!result.done) {
825
+ startNext(index);
826
+ yield { index, result };
827
+ }
828
+ }
829
+ } finally {
830
+ for (const iterator of iterators) {
831
+ try {
832
+ await iterator.return?.();
833
+ } catch {
834
+ }
835
+ }
836
+ for (const promise of pending.values()) {
837
+ promise.catch(() => void 0);
838
+ }
839
+ }
840
+ }
841
+
743
842
  // lib/vcs-message-generator.ts
744
843
  var DEFAULT_MODELS = [
745
844
  // "mistral/ministral-3b",
@@ -750,14 +849,7 @@ var DEFAULT_MODELS = [
750
849
  ];
751
850
  var isAbortError = (error) => error instanceof Error && error.name === "AbortError";
752
851
  var isInvalidCliSessionIdError = (error) => error instanceof Error && error.message.includes("Invalid cliSessionId");
753
- var delay = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
754
- var createAbortPromise = (signal) => signal ? new Promise((resolve2) => {
755
- if (signal.aborted) {
756
- resolve2(null);
757
- return;
758
- }
759
- signal.addEventListener("abort", () => resolve2(null), { once: true });
760
- }) : null;
852
+ var delay = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
761
853
  async function* generateCommitMessages(options) {
762
854
  const {
763
855
  diff,
@@ -765,13 +857,18 @@ async function* generateCommitMessages(options) {
765
857
  signal,
766
858
  cliSessionId,
767
859
  commandExecutionPromise,
768
- useStream = false
860
+ useStream = false,
861
+ streamCaptureRecorder
769
862
  } = options;
770
863
  const resolvedCliSessionId = cliSessionId;
771
864
  if (!resolvedCliSessionId) {
772
865
  throw new Error("Missing cliSessionId for generate request.");
773
866
  }
774
867
  const requiredCliSessionId = resolvedCliSessionId;
868
+ const captureGenerationId = streamCaptureRecorder?.startGeneration({
869
+ cliSessionId: requiredCliSessionId,
870
+ models
871
+ });
775
872
  const token = await getToken();
776
873
  if (!token) {
777
874
  console.error("Error: Not authenticated. Run `ultrahope login` first.");
@@ -781,10 +878,18 @@ async function* generateCommitMessages(options) {
781
878
  const generateWithRetry = async function* (payload) {
782
879
  const maxAttempts = 3;
783
880
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
881
+ const attemptNumber = attempt + 1;
784
882
  try {
785
883
  for await (const event of api.streamCommitMessage(payload, {
786
884
  signal
787
885
  })) {
886
+ if (captureGenerationId != null) {
887
+ streamCaptureRecorder?.recordEvent(captureGenerationId, {
888
+ model: payload.model,
889
+ attempt: attemptNumber,
890
+ event
891
+ });
892
+ }
788
893
  log("generate", event);
789
894
  yield event;
790
895
  }
@@ -820,7 +925,8 @@ async function* generateCommitMessages(options) {
820
925
  for await (const event of generateWithRetry({
821
926
  cliSessionId: requiredCliSessionId,
822
927
  input: diff,
823
- model
928
+ model,
929
+ guide: options.guide
824
930
  })) {
825
931
  if (event.type === "commit-message") {
826
932
  lastCommitMessage = event.commitMessage;
@@ -858,55 +964,36 @@ async function* generateCommitMessages(options) {
858
964
  if (isInvalidCliSessionIdError(error)) throw error;
859
965
  }
860
966
  }
861
- const iterators = models.map((model, index) => ({
862
- iterator: generateForModel(model, index)[Symbol.asyncIterator](),
863
- index
864
- }));
865
- const pending = /* @__PURE__ */ new Map();
866
- const startNext = (it) => {
867
- const promise = it.iterator.next().then((result) => ({
868
- result,
869
- index: it.index
870
- }));
871
- pending.set(it.index, {
872
- iterator: it.iterator,
873
- promise,
874
- index: it.index
875
- });
876
- };
877
- for (const it of iterators) {
878
- startNext(it);
879
- }
880
- const abortPromise = createAbortPromise(signal);
967
+ const iterators = models.map(
968
+ (model, index) => generateForModel(model, index)[Symbol.asyncIterator]()
969
+ );
881
970
  try {
882
- while (pending.size > 0) {
883
- if (signal?.aborted) break;
884
- const next = Promise.race(
885
- Array.from(pending.values()).map((p) => p.promise)
886
- );
887
- const winner = abortPromise ? await Promise.race([next, abortPromise]) : await next;
888
- if (!winner || signal?.aborted) break;
889
- const { result, index } = winner;
890
- const entry = pending.get(index);
891
- if (!entry) continue;
892
- if (result.done) {
893
- pending.delete(index);
894
- } else {
895
- yield result.value;
896
- startNext({ iterator: entry.iterator, index });
971
+ for await (const { result } of raceAsyncIterators({
972
+ iterators,
973
+ signal,
974
+ onError(index, error) {
975
+ if (isAbortError(error) || signal?.aborted) {
976
+ return "continue";
977
+ }
978
+ void index;
979
+ return "throw";
897
980
  }
981
+ })) {
982
+ yield result.value;
898
983
  }
899
984
  } catch (error) {
900
985
  if (error instanceof InvalidModelError) {
901
986
  throw error;
902
987
  }
903
988
  if (error instanceof InsufficientBalanceError) {
904
- console.error(
905
- "Error: Token balance exhausted. Upgrade your plan at https://ultrahope.dev/pricing"
906
- );
989
+ console.error(error.formatMessage());
907
990
  process.exit(1);
908
991
  }
909
992
  throw error;
993
+ } finally {
994
+ if (captureGenerationId != null) {
995
+ streamCaptureRecorder?.finishGeneration(captureGenerationId);
996
+ }
910
997
  }
911
998
  }
912
999
 
@@ -1062,6 +1149,52 @@ import { join as join4 } from "path";
1062
1149
  import * as readline3 from "readline";
1063
1150
  import * as tty2 from "tty";
1064
1151
 
1152
+ // ../shared/terminal-selector-helpers.ts
1153
+ function formatModelName(model) {
1154
+ const parts = model.split("/");
1155
+ return parts.length > 1 ? parts[1] : model;
1156
+ }
1157
+ function formatCost(cost) {
1158
+ return `$${cost.toFixed(7).replace(/0+$/, "").replace(/\.$/, "")}`;
1159
+ }
1160
+ function formatTotalCostLabel(cost) {
1161
+ return `$${cost.toFixed(6)}`;
1162
+ }
1163
+ function getReadyCount(slots) {
1164
+ return slots.filter((slot) => slot.status === "ready").length;
1165
+ }
1166
+ function getTotalCost(slots) {
1167
+ return slots.reduce((sum, slot) => {
1168
+ if (slot.status === "ready" && slot.candidate.cost != null) {
1169
+ return sum + slot.candidate.cost;
1170
+ }
1171
+ return sum;
1172
+ }, 0);
1173
+ }
1174
+ function getLatestQuota(slots) {
1175
+ for (const slot of slots) {
1176
+ if (slot.status === "ready" && slot.candidate.quota) {
1177
+ return slot.candidate.quota;
1178
+ }
1179
+ }
1180
+ return void 0;
1181
+ }
1182
+ function hasReadySlot(slots) {
1183
+ return getReadyCount(slots) > 0;
1184
+ }
1185
+ function selectNearestReady(slots, startIndex, direction) {
1186
+ for (let index = startIndex + direction; index >= 0 && index < slots.length; index += direction) {
1187
+ if (slots[index]?.status === "ready") {
1188
+ return index;
1189
+ }
1190
+ }
1191
+ return startIndex;
1192
+ }
1193
+ function getSelectedCandidate(slots, selectedIndex) {
1194
+ const slot = slots[selectedIndex];
1195
+ return slot?.status === "ready" ? slot.candidate : void 0;
1196
+ }
1197
+
1065
1198
  // lib/renderer.ts
1066
1199
  import * as readline2 from "readline";
1067
1200
  var SPINNER_FRAMES = [
@@ -1121,47 +1254,6 @@ function createRenderer(output) {
1121
1254
 
1122
1255
  // lib/selector.ts
1123
1256
  var TTY_PATH = "/dev/tty";
1124
- function formatModelName(model) {
1125
- const parts = model.split("/");
1126
- return parts.length > 1 ? parts[1] : model;
1127
- }
1128
- function formatCost(cost) {
1129
- return `$${cost.toFixed(7).replace(/0+$/, "").replace(/\.$/, "")}`;
1130
- }
1131
- function getReadyCount(slots) {
1132
- return slots.filter((s) => s.status === "ready").length;
1133
- }
1134
- function getTotalCost(slots) {
1135
- return slots.reduce((sum, slot) => {
1136
- if (slot.status === "ready" && slot.candidate.cost != null) {
1137
- return sum + slot.candidate.cost;
1138
- }
1139
- return sum;
1140
- }, 0);
1141
- }
1142
- function getLatestQuota(slots) {
1143
- for (const slot of slots) {
1144
- if (slot.status === "ready" && slot.candidate.quota) {
1145
- return slot.candidate.quota;
1146
- }
1147
- }
1148
- return void 0;
1149
- }
1150
- function hasReadySlot(slots) {
1151
- return slots.some((s) => s.status === "ready");
1152
- }
1153
- function getSelectedCandidate(slots, selectedIndex) {
1154
- const slot = slots[selectedIndex];
1155
- return slot?.status === "ready" ? slot.candidate : void 0;
1156
- }
1157
- function selectNearestReady(slots, startIndex, direction) {
1158
- for (let i = startIndex + direction; i >= 0 && i < slots.length; i += direction) {
1159
- if (slots[i]?.status === "ready") {
1160
- return i;
1161
- }
1162
- }
1163
- return startIndex;
1164
- }
1165
1257
  function collapseToReady(slots) {
1166
1258
  const readySlots = slots.filter((s) => s.status === "ready");
1167
1259
  slots.length = 0;
@@ -1176,6 +1268,11 @@ function formatSlot(slot, selected) {
1176
1268
  const meta2 = slot.model ? `${theme.dim} ${formatModelName(slot.model)}${theme.reset}` : "";
1177
1269
  return meta2 ? [line2, meta2] : [line2];
1178
1270
  }
1271
+ if (slot.status === "error") {
1272
+ const radio2 = "\u25CB";
1273
+ const line2 = `${theme.dim} ${radio2} ${slot.content}${theme.reset}`;
1274
+ return [line2];
1275
+ }
1179
1276
  const candidate = slot.candidate;
1180
1277
  const title = candidate.content.split("\n")[0]?.trim() || "";
1181
1278
  const modelInfo = candidate.model ? candidate.cost ? `${formatModelName(candidate.model)} ${formatCost(candidate.cost)}` : formatModelName(candidate.model) : "";
@@ -1190,9 +1287,6 @@ function formatSlot(slot, selected) {
1190
1287
  const meta = modelInfo ? `${theme.dim} ${modelInfo}${theme.reset}` : "";
1191
1288
  return meta ? [line, meta] : [line];
1192
1289
  }
1193
- function formatTotalCostLabel(cost) {
1194
- return `$${cost.toFixed(6)}`;
1195
- }
1196
1290
  function renderSelector(state, nowMs, renderer) {
1197
1291
  const { slots, selectedIndex, isGenerating, totalSlots } = state;
1198
1292
  const lines = [];
@@ -1244,7 +1338,7 @@ function renderError(error, slots, totalSlots, output) {
1244
1338
  }
1245
1339
  }
1246
1340
  function openEditor(content) {
1247
- return new Promise((resolve2, reject) => {
1341
+ return new Promise((resolve3, reject) => {
1248
1342
  const editor = process.env.GIT_EDITOR || process.env.EDITOR || "vi";
1249
1343
  const tmpDir = mkdtempSync(join4(tmpdir(), "ultrahope-"));
1250
1344
  const tmpFile = join4(tmpDir, "EDIT_MESSAGE");
@@ -1258,7 +1352,7 @@ function openEditor(content) {
1258
1352
  }
1259
1353
  const result = readFileSync2(tmpFile, "utf-8").trim();
1260
1354
  unlinkSync(tmpFile);
1261
- resolve2(result);
1355
+ resolve3(result);
1262
1356
  });
1263
1357
  child.on("error", (err) => {
1264
1358
  try {
@@ -1318,12 +1412,12 @@ async function selectCandidate(options) {
1318
1412
  );
1319
1413
  }
1320
1414
  async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1321
- return new Promise((resolve2) => {
1415
+ return new Promise((resolve3) => {
1322
1416
  let resolved = false;
1323
1417
  const resolveOnce = (result) => {
1324
1418
  if (resolved) return;
1325
1419
  resolved = true;
1326
- resolve2(result);
1420
+ resolve3(result);
1327
1421
  };
1328
1422
  const slots = [...initialSlots];
1329
1423
  const state = {
@@ -1383,10 +1477,10 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1383
1477
  };
1384
1478
  const nextCandidate = async (iterator) => {
1385
1479
  const abortPromise = new Promise(
1386
- (resolve3) => {
1480
+ (resolve4) => {
1387
1481
  asyncCtx?.abortController.signal.addEventListener(
1388
1482
  "abort",
1389
- () => resolve3({ done: true, value: void 0 }),
1483
+ () => resolve4({ done: true, value: void 0 }),
1390
1484
  { once: true }
1391
1485
  );
1392
1486
  }
@@ -1411,9 +1505,12 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1411
1505
  const result = await nextCandidate(iterator);
1412
1506
  if (result.done || cleanedUp) break;
1413
1507
  const candidate = result.value;
1414
- const targetIndex = slots.findIndex(
1415
- (slot) => slot.status === "pending" ? slot.slotId === candidate.slotId : slot.candidate.slotId === candidate.slotId
1416
- );
1508
+ const targetIndex = slots.findIndex((slot) => {
1509
+ if (slot.status === "ready") {
1510
+ return slot.candidate.slotId === candidate.slotId;
1511
+ }
1512
+ return slot.status === "pending" && slot.slotId === candidate.slotId;
1513
+ });
1417
1514
  if (targetIndex >= 0 && targetIndex < slots.length) {
1418
1515
  const isNewSlot = slots[targetIndex].status === "pending";
1419
1516
  updateState((draft) => {
@@ -1554,7 +1651,162 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1554
1651
  });
1555
1652
  }
1556
1653
 
1654
+ // lib/stream-capture.ts
1655
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
1656
+ import { dirname as dirname3, resolve as resolve2 } from "path";
1657
+ var noopRecorder = {
1658
+ enabled: false,
1659
+ startGeneration: () => 0,
1660
+ recordEvent: () => {
1661
+ },
1662
+ finishGeneration: () => {
1663
+ },
1664
+ flush: () => null
1665
+ };
1666
+ function sanitizeEvent(event) {
1667
+ if (event.type === "provider-metadata") {
1668
+ return {
1669
+ type: "provider-metadata",
1670
+ providerMetadata: event.providerMetadata
1671
+ };
1672
+ }
1673
+ if (event.type === "usage") {
1674
+ return {
1675
+ type: "usage",
1676
+ usage: {
1677
+ inputTokens: event.usage.inputTokens,
1678
+ outputTokens: event.usage.outputTokens,
1679
+ totalTokens: event.usage.totalTokens
1680
+ }
1681
+ };
1682
+ }
1683
+ if (event.type === "commit-message") {
1684
+ return {
1685
+ type: "commit-message",
1686
+ commitMessage: event.commitMessage
1687
+ };
1688
+ }
1689
+ return {
1690
+ type: "error",
1691
+ message: event.message
1692
+ };
1693
+ }
1694
+ function toSerializableCapture(capture) {
1695
+ return {
1696
+ ...capture,
1697
+ runs: capture.runs.map((run) => ({
1698
+ ...run,
1699
+ args: [...run.args],
1700
+ generations: run.generations.map((generation) => ({
1701
+ ...generation,
1702
+ models: [...generation.models],
1703
+ events: generation.events.map((event) => ({
1704
+ atMs: event.atMs,
1705
+ model: event.model,
1706
+ attempt: event.attempt,
1707
+ event: sanitizeEvent(event.event)
1708
+ }))
1709
+ }))
1710
+ }))
1711
+ };
1712
+ }
1713
+ function createStreamCaptureRecorder(options) {
1714
+ if (!options.path) {
1715
+ return noopRecorder;
1716
+ }
1717
+ const outputPath = resolve2(process.cwd(), options.path);
1718
+ const runStartedAtMs = Date.now();
1719
+ const run = {
1720
+ id: "run-1",
1721
+ command: options.command,
1722
+ args: options.args,
1723
+ apiPath: options.apiPath,
1724
+ startedAt: new Date(runStartedAtMs).toISOString(),
1725
+ generations: []
1726
+ };
1727
+ const capture = {
1728
+ version: 2,
1729
+ source: "ultrahope-commit-message-stream",
1730
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
1731
+ runs: [run]
1732
+ };
1733
+ let generationSequence = 0;
1734
+ let runFinished = false;
1735
+ const activeGenerations = /* @__PURE__ */ new Map();
1736
+ const writeCapture = () => {
1737
+ capture.capturedAt = (/* @__PURE__ */ new Date()).toISOString();
1738
+ mkdirSync2(dirname3(outputPath), { recursive: true });
1739
+ writeFileSync2(
1740
+ outputPath,
1741
+ `${JSON.stringify(toSerializableCapture(capture), null, 2)}
1742
+ `,
1743
+ "utf-8"
1744
+ );
1745
+ };
1746
+ const ensureRunFinished = () => {
1747
+ if (runFinished) {
1748
+ return;
1749
+ }
1750
+ runFinished = true;
1751
+ run.endedAt = (/* @__PURE__ */ new Date()).toISOString();
1752
+ };
1753
+ return {
1754
+ enabled: true,
1755
+ startGeneration: ({ cliSessionId, models }) => {
1756
+ generationSequence += 1;
1757
+ const startedAtMs = Date.now();
1758
+ const generation = {
1759
+ id: `generation-${generationSequence}`,
1760
+ cliSessionId,
1761
+ models: [...models],
1762
+ startedAt: new Date(startedAtMs).toISOString(),
1763
+ events: []
1764
+ };
1765
+ run.generations.push(generation);
1766
+ activeGenerations.set(generationSequence, { generation, startedAtMs });
1767
+ return generationSequence;
1768
+ },
1769
+ recordEvent: (generationId, record) => {
1770
+ const activeGeneration = activeGenerations.get(generationId);
1771
+ if (!activeGeneration) {
1772
+ return;
1773
+ }
1774
+ const eventAtMs = typeof record.event.atMs === "number" && Number.isFinite(record.event.atMs) ? record.event.atMs : Date.now() - activeGeneration.startedAtMs;
1775
+ activeGeneration.generation.events.push({
1776
+ atMs: Math.max(0, Math.round(eventAtMs)),
1777
+ model: record.model,
1778
+ attempt: record.attempt,
1779
+ event: sanitizeEvent(record.event)
1780
+ });
1781
+ },
1782
+ finishGeneration: (generationId) => {
1783
+ const activeGeneration = activeGenerations.get(generationId);
1784
+ if (!activeGeneration) {
1785
+ return;
1786
+ }
1787
+ activeGeneration.generation.endedAt = (/* @__PURE__ */ new Date()).toISOString();
1788
+ activeGenerations.delete(generationId);
1789
+ writeCapture();
1790
+ },
1791
+ flush: () => {
1792
+ for (const [generationId, activeGeneration] of activeGenerations) {
1793
+ activeGeneration.generation.endedAt = (/* @__PURE__ */ new Date()).toISOString();
1794
+ activeGenerations.delete(generationId);
1795
+ }
1796
+ ensureRunFinished();
1797
+ writeCapture();
1798
+ return outputPath;
1799
+ }
1800
+ };
1801
+ }
1802
+
1557
1803
  // commands/commit.ts
1804
+ function normalizeGuide(value) {
1805
+ if (!value) return void 0;
1806
+ const trimmed = value.trim();
1807
+ if (!trimmed) return void 0;
1808
+ return trimmed.length > 1024 ? trimmed.slice(0, 1024) : trimmed;
1809
+ }
1558
1810
  function exitWithInvalidModelError(error) {
1559
1811
  console.error(`Error: Model '${error.model}' is not supported.`);
1560
1812
  if (error.allowedModels.length > 0) {
@@ -1573,6 +1825,8 @@ function showQuotaInfo(quota) {
1573
1825
  }
1574
1826
  function parseArgs(args2) {
1575
1827
  let cliModels;
1828
+ let captureStreamPath;
1829
+ let guide;
1576
1830
  for (let i = 0; i < args2.length; i++) {
1577
1831
  const arg = args2[i];
1578
1832
  if (arg === "--models") {
@@ -1583,12 +1837,29 @@ function parseArgs(args2) {
1583
1837
  }
1584
1838
  cliModels = parseModelsArg(value);
1585
1839
  i++;
1840
+ } else if (arg === "--capture-stream") {
1841
+ const value = args2[i + 1];
1842
+ if (!value) {
1843
+ console.error("Error: --capture-stream requires a file path");
1844
+ process.exit(1);
1845
+ }
1846
+ captureStreamPath = value;
1847
+ i++;
1848
+ } else if (arg === "--guide") {
1849
+ const value = args2[i + 1];
1850
+ if (!value) {
1851
+ console.error("Error: --guide requires a text value.");
1852
+ process.exit(1);
1853
+ }
1854
+ guide = normalizeGuide(value);
1855
+ i++;
1586
1856
  }
1587
1857
  }
1588
1858
  return {
1589
- message: args2.includes("-m") || args2.includes("--message"),
1590
1859
  interactive: !args2.includes("--no-interactive"),
1591
- cliModels
1860
+ cliModels,
1861
+ captureStreamPath,
1862
+ guide
1592
1863
  };
1593
1864
  }
1594
1865
  function getStagedDiff() {
@@ -1601,31 +1872,6 @@ function getStagedDiff() {
1601
1872
  process.exit(1);
1602
1873
  }
1603
1874
  }
1604
- function openEditor2(initialMessage) {
1605
- return new Promise((resolve2, reject) => {
1606
- const editor = process.env.GIT_EDITOR || process.env.EDITOR || "vi";
1607
- const tmpDir = mkdtempSync2(join5(tmpdir2(), "ultrahope-"));
1608
- const tmpFile = join5(tmpDir, "COMMIT_EDITMSG");
1609
- writeFileSync2(tmpFile, initialMessage);
1610
- const child = spawn2(editor, [tmpFile], {
1611
- stdio: "inherit"
1612
- });
1613
- child.on("close", (code) => {
1614
- if (code !== 0) {
1615
- unlinkSync2(tmpFile);
1616
- reject(new Error(`Editor exited with code ${code}`));
1617
- return;
1618
- }
1619
- const message = readFileSync3(tmpFile, "utf-8").trim();
1620
- unlinkSync2(tmpFile);
1621
- resolve2(message);
1622
- });
1623
- child.on("error", (err) => {
1624
- unlinkSync2(tmpFile);
1625
- reject(err);
1626
- });
1627
- });
1628
- }
1629
1875
  function commitWithMessage(message) {
1630
1876
  try {
1631
1877
  execSync2(`git commit -m ${JSON.stringify(message)}`, { stdio: "inherit" });
@@ -1635,6 +1881,12 @@ function commitWithMessage(message) {
1635
1881
  }
1636
1882
  async function commit(args2) {
1637
1883
  const options = parseArgs(args2);
1884
+ const captureRecorder = createStreamCaptureRecorder({
1885
+ path: options.captureStreamPath,
1886
+ command: "git ultrahope commit",
1887
+ args: ["commit", ...args2],
1888
+ apiPath: "/v1/commit-message/stream"
1889
+ });
1638
1890
  const models = resolveModels(options.cliModels);
1639
1891
  const diff = getStagedDiff();
1640
1892
  if (!diff.trim()) {
@@ -1643,131 +1895,123 @@ async function commit(args2) {
1643
1895
  );
1644
1896
  process.exit(1);
1645
1897
  }
1646
- const token = await getToken();
1647
- if (!token) {
1648
- console.error("Error: Not authenticated. Run `ultrahope login` first.");
1649
- process.exit(1);
1650
- }
1651
- const api = createApiClient(token);
1652
- const {
1653
- commandExecutionPromise: promise,
1654
- abortController,
1655
- cliSessionId: id
1656
- } = startCommandExecution({
1657
- api,
1658
- command: "commit",
1659
- args: args2,
1660
- apiPath: "/v1/commit-message",
1661
- requestPayload: {
1662
- input: diff,
1663
- target: "vcs-commit-message",
1664
- models
1898
+ try {
1899
+ const token = await getToken();
1900
+ if (!token) {
1901
+ console.error("Error: Not authenticated. Run `ultrahope login` first.");
1902
+ process.exit(1);
1665
1903
  }
1666
- });
1667
- const cliSessionId = id;
1668
- const commandExecutionSignal = abortController.signal;
1669
- const commandExecutionPromise = promise;
1670
- const apiClient = api;
1671
- commandExecutionPromise.catch(async (error) => {
1672
- abortController.abort(abortReasonForError(error));
1673
- await handleCommandExecutionError(error, {
1674
- progress: { ready: 0, total: models.length }
1904
+ const api = createApiClient(token);
1905
+ const {
1906
+ commandExecutionPromise: promise,
1907
+ abortController,
1908
+ cliSessionId: id
1909
+ } = startCommandExecution({
1910
+ api,
1911
+ command: "commit",
1912
+ args: args2,
1913
+ apiPath: "/v1/commit-message",
1914
+ requestPayload: {
1915
+ input: diff,
1916
+ target: "vcs-commit-message",
1917
+ model: models[0],
1918
+ ...options.guide ? { guide: options.guide } : {}
1919
+ }
1675
1920
  });
1676
- });
1677
- const recordSelection = async (generationId) => {
1678
- if (!generationId || !apiClient) return;
1679
- try {
1680
- await apiClient.recordGenerationScore({
1681
- generationId,
1682
- value: 1
1921
+ const cliSessionId = id;
1922
+ const commandExecutionSignal = abortController.signal;
1923
+ const commandExecutionPromise = promise;
1924
+ const apiClient = api;
1925
+ commandExecutionPromise.catch(async (error) => {
1926
+ abortController.abort(abortReasonForError(error));
1927
+ await handleCommandExecutionError(error, {
1928
+ progress: { ready: 0, total: models.length }
1683
1929
  });
1684
- } catch (error) {
1685
- const message = error instanceof Error ? error.message : String(error);
1686
- if (message.includes("Generation not found")) {
1687
- return;
1930
+ });
1931
+ const recordSelection = async (generationId) => {
1932
+ if (!generationId || !apiClient) return;
1933
+ try {
1934
+ await apiClient.recordGenerationScore({
1935
+ generationId,
1936
+ value: 1
1937
+ });
1938
+ } catch (error) {
1939
+ const message = error instanceof Error ? error.message : String(error);
1940
+ if (message.includes("Generation not found")) {
1941
+ return;
1942
+ }
1943
+ console.error(`Warning: Failed to record selection. ${message}`);
1688
1944
  }
1689
- console.error(`Warning: Failed to record selection. ${message}`);
1690
- }
1691
- };
1692
- const createCandidates = (signal) => generateCommitMessages({
1693
- diff,
1694
- models,
1695
- signal: mergeAbortSignals(signal, commandExecutionSignal),
1696
- cliSessionId,
1697
- commandExecutionPromise
1698
- });
1699
- if (!options.interactive) {
1700
- const gen = generateCommitMessages({
1945
+ };
1946
+ const createCandidates = (signal) => generateCommitMessages({
1701
1947
  diff,
1702
- models: models.slice(0, 1),
1703
- signal: commandExecutionSignal,
1948
+ models,
1949
+ guide: options.guide,
1950
+ signal: mergeAbortSignals(signal, commandExecutionSignal),
1704
1951
  cliSessionId,
1705
- commandExecutionPromise
1952
+ commandExecutionPromise,
1953
+ streamCaptureRecorder: captureRecorder
1706
1954
  });
1707
- const first = await gen.next().catch((error) => {
1708
- if (error instanceof InvalidModelError) {
1709
- exitWithInvalidModelError(error);
1710
- }
1711
- throw error;
1712
- });
1713
- await recordSelection(first.value?.generationId);
1714
- const message = first.value?.content ?? "";
1715
- if (options.message) {
1955
+ if (!options.interactive) {
1956
+ const gen = generateCommitMessages({
1957
+ diff,
1958
+ models: models.slice(0, 1),
1959
+ guide: options.guide,
1960
+ signal: commandExecutionSignal,
1961
+ cliSessionId,
1962
+ commandExecutionPromise,
1963
+ streamCaptureRecorder: captureRecorder
1964
+ });
1965
+ const first = await gen.next().catch((error) => {
1966
+ if (error instanceof InvalidModelError) {
1967
+ exitWithInvalidModelError(error);
1968
+ }
1969
+ throw error;
1970
+ });
1971
+ await recordSelection(first.value?.generationId);
1972
+ const message = first.value?.content ?? "";
1716
1973
  commitWithMessage(message);
1717
1974
  return;
1718
1975
  }
1719
- const editedMessage = await openEditor2(message);
1720
- if (!editedMessage) {
1721
- console.error("Aborting commit due to empty message.");
1722
- process.exit(1);
1723
- }
1724
- commitWithMessage(editedMessage);
1725
- return;
1726
- }
1727
- const stats = getGitStagedStats();
1728
- console.log(ui.success(`Found ${formatDiffStats(stats)}`));
1729
- while (true) {
1730
- const result = await selectCandidate({
1731
- createCandidates,
1732
- maxSlots: models.length,
1733
- abortSignal: commandExecutionSignal,
1734
- models
1735
- });
1736
- if (result.action === "abort") {
1737
- if (result.error instanceof InvalidModelError) {
1738
- exitWithInvalidModelError(result.error);
1976
+ const stats = getGitStagedStats();
1977
+ console.log(ui.success(`Found ${formatDiffStats(stats)}`));
1978
+ while (true) {
1979
+ const result = await selectCandidate({
1980
+ createCandidates,
1981
+ maxSlots: models.length,
1982
+ abortSignal: commandExecutionSignal,
1983
+ models
1984
+ });
1985
+ if (result.action === "abort") {
1986
+ if (result.error instanceof InvalidModelError) {
1987
+ exitWithInvalidModelError(result.error);
1988
+ }
1989
+ if (isCommandExecutionAbort(commandExecutionSignal)) {
1990
+ return;
1991
+ }
1992
+ console.error("Aborting commit.");
1993
+ process.exit(1);
1739
1994
  }
1740
- if (isCommandExecutionAbort(commandExecutionSignal)) {
1741
- return;
1995
+ if (result.action === "reroll") {
1996
+ continue;
1742
1997
  }
1743
- console.error("Aborting commit.");
1744
- process.exit(1);
1745
- }
1746
- if (result.action === "reroll") {
1747
- continue;
1748
- }
1749
- if (result.action === "confirm" && result.selected) {
1750
- await recordSelection(result.selectedCandidate?.generationId);
1751
- const costLabel = result.totalCost != null ? ` (total: ${formatTotalCost(result.totalCost)})` : "";
1752
- console.log(ui.success(`Message selected${costLabel}`));
1753
- if (options.message) {
1998
+ if (result.action === "confirm" && result.selected) {
1999
+ await recordSelection(result.selectedCandidate?.generationId);
2000
+ const costLabel = result.totalCost != null ? ` (total: ${formatTotalCost(result.totalCost)})` : "";
2001
+ console.log(ui.success(`Message selected${costLabel}`));
1754
2002
  console.log(`${ui.success("Running git commit")}
1755
2003
  `);
1756
2004
  commitWithMessage(result.selected);
1757
- } else {
1758
- const editedMessage = await openEditor2(result.selected);
1759
- if (!editedMessage) {
1760
- console.error("Aborting commit due to empty message.");
1761
- process.exit(1);
2005
+ if (result.quota) {
2006
+ showQuotaInfo(result.quota);
1762
2007
  }
1763
- console.log(`${ui.success("Running git commit")}
1764
- `);
1765
- commitWithMessage(editedMessage);
1766
- }
1767
- if (result.quota) {
1768
- showQuotaInfo(result.quota);
2008
+ return;
1769
2009
  }
1770
- return;
2010
+ }
2011
+ } finally {
2012
+ const capturePath = captureRecorder.flush();
2013
+ if (capturePath) {
2014
+ console.log(ui.hint(`Captured stream replay to ${capturePath}`));
1771
2015
  }
1772
2016
  }
1773
2017
  }
@@ -1797,14 +2041,16 @@ Commands:
1797
2041
  commit Generate commit message from staged changes
1798
2042
 
1799
2043
  Commit options:
1800
- -m, --message Commit directly with generated message
1801
- --no-interactive Single candidate, open in editor
2044
+ --no-interactive Single candidate, commit directly
2045
+ --guide <text> Additional context to guide message generation
1802
2046
  --models <list> Comma-separated model list (overrides config)
2047
+ --capture-stream <path> Save commit-message stream as replay JSON
1803
2048
 
1804
2049
  Examples:
1805
2050
  git ultrahope commit # interactive selector (default)
1806
- git ultrahope commit -m # select and commit directly
1807
- git ultrahope commit --no-interactive # single candidate, open editor`);
2051
+ git ultrahope commit --guide "GHSA-gq3j-xvxp-8hrf: override reason"
2052
+ git ultrahope commit --capture-stream /tmp/git-commit-stream.capture.json
2053
+ git ultrahope commit --no-interactive # single candidate, commit directly`);
1808
2054
  }
1809
2055
  main().catch((err) => {
1810
2056
  console.error(err);