ultrahope 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,16 +41,33 @@ git diff --staged | ultrahope translate --target vcs-commit-message --models mis
41
41
 
42
42
  ### Guide context for commit/message generation
43
43
 
44
- `git ultrahope commit` `ultrahope jj describe` では `--guide <text>` を使って、差分だけでは分からない生成意図を補足できます。
44
+ In `git ultrahope commit` and `ultrahope jj describe`, you can use `--guide <text>` to provide intent that is not obvious from the diff alone.
45
45
 
46
46
  ```bash
47
- # git commit の生成補足
47
+ # Additional guidance for git commit generation
48
48
  git add -A && git ultrahope commit --guide "GHSA-gq3j-xvxp-8hrf: override reason"
49
49
 
50
- # jj describe の生成補足
50
+ # Additional guidance for jj describe generation
51
51
  jj ultrahope describe --guide "GHSA-gq3j-xvxp-8hrf: override reason"
52
52
  ```
53
53
 
54
+ In interactive mode for `git ultrahope commit`, `ultrahope jj describe`, and `ultrahope translate --target vcs-commit-message`, use `r` to reroll and `R` (Shift+r) to reroll with additional instructions.
55
+
56
+ #### Difference Between `guide` And Refine Instructions
57
+
58
+ - `--guide`:
59
+ - Supplemental intent outside the diff (for example: ticket ID, background, change intent)
60
+ - `R refine`:
61
+ - Review generated results and enter inline instructions for the next reroll
62
+ - Examples: "more formal", "shorter"
63
+ - Press `Enter` with empty input to clear the previous refine instructions
64
+ - If specified multiple times, the last one overwrites previous values
65
+ - `R` means reroll with additional instructions (`refine`)
66
+ - At request time, refine instructions are merged into `guide` and sent to the API:
67
+ - `--guide` only: `guide = "<guide>"`
68
+ - `R refine` only: `guide = "<refine>"`
69
+ - both: `guide = "<guide>\n\nRefinement: <refine>"`
70
+
54
71
  #### Targets
55
72
 
56
73
  - `vcs-commit-message` - Generate a commit message
@@ -921,7 +921,19 @@ async function* generateCommitMessages(options) {
921
921
  try {
922
922
  if (signal?.aborted) return;
923
923
  let lastCommitMessage = "";
924
+ const generationStartedAtMs = Date.now();
925
+ let lastCommitMessageAtMs;
926
+ let providerMetadataAtMs;
924
927
  let providerMetadata;
928
+ const parseEventAtMs = (event) => {
929
+ if (typeof event.atMs !== "number" || !Number.isFinite(event.atMs)) {
930
+ return void 0;
931
+ }
932
+ if (event.atMs < 0) {
933
+ return 0;
934
+ }
935
+ return Math.round(event.atMs);
936
+ };
925
937
  for await (const event of generateWithRetry({
926
938
  cliSessionId: requiredCliSessionId,
927
939
  input: diff,
@@ -930,6 +942,7 @@ async function* generateCommitMessages(options) {
930
942
  })) {
931
943
  if (event.type === "commit-message") {
932
944
  lastCommitMessage = event.commitMessage;
945
+ lastCommitMessageAtMs = parseEventAtMs(event) ?? lastCommitMessageAtMs;
933
946
  if (useStream) {
934
947
  yield {
935
948
  content: lastCommitMessage,
@@ -940,6 +953,7 @@ async function* generateCommitMessages(options) {
940
953
  };
941
954
  }
942
955
  } else if (event.type === "provider-metadata") {
956
+ providerMetadataAtMs = parseEventAtMs(event) ?? providerMetadataAtMs;
943
957
  providerMetadata = event.providerMetadata;
944
958
  } else if (event.type === "error") {
945
959
  throw new Error(event.message);
@@ -947,11 +961,13 @@ async function* generateCommitMessages(options) {
947
961
  }
948
962
  if (lastCommitMessage) {
949
963
  const { generationId, cost } = extractGatewayMetadata(providerMetadata);
964
+ const generationMs = (providerMetadataAtMs ?? lastCommitMessageAtMs) != null ? providerMetadataAtMs ?? lastCommitMessageAtMs : Math.max(0, Date.now() - generationStartedAtMs);
950
965
  yield {
951
966
  content: lastCommitMessage,
952
967
  slotId: model,
953
968
  model,
954
969
  cost,
970
+ generationMs,
955
971
  generationId,
956
972
  ...useStream ? { isPartial: false } : {},
957
973
  slotIndex
@@ -1141,7 +1157,7 @@ import {
1141
1157
  mkdtempSync,
1142
1158
  openSync as openSync2,
1143
1159
  readFileSync as readFileSync2,
1144
- unlinkSync,
1160
+ rmSync,
1145
1161
  writeFileSync
1146
1162
  } from "fs";
1147
1163
  import { tmpdir } from "os";
@@ -1275,7 +1291,17 @@ function formatSlot(slot, selected) {
1275
1291
  }
1276
1292
  const candidate = slot.candidate;
1277
1293
  const title = candidate.content.split("\n")[0]?.trim() || "";
1278
- const modelInfo = candidate.model ? candidate.cost ? `${formatModelName(candidate.model)} ${formatCost(candidate.cost)}` : formatModelName(candidate.model) : "";
1294
+ const formatDuration = (ms) => {
1295
+ const safeMs = Math.max(0, Math.round(ms));
1296
+ if (safeMs < 1e3) {
1297
+ return `${safeMs}ms`;
1298
+ }
1299
+ const seconds = (safeMs / 1e3).toFixed(1).replace(/\.0$/, "");
1300
+ return `${seconds}s`;
1301
+ };
1302
+ const formattedModel = candidate.model ? formatModelName(candidate.model) : "";
1303
+ const formattedDuration = candidate.generationMs == null ? "" : ` ${formatDuration(candidate.generationMs)}`;
1304
+ const modelInfo = candidate.model ? candidate.cost ? `${formattedModel} ${formatCost(candidate.cost)}${formattedDuration}` : `${formattedModel}${formattedDuration}` : "";
1279
1305
  if (selected) {
1280
1306
  const radio2 = "\u25CF";
1281
1307
  const line2 = ` ${radio2} ${theme.bold}${title}${theme.reset}`;
@@ -1287,7 +1313,7 @@ function formatSlot(slot, selected) {
1287
1313
  const meta = modelInfo ? `${theme.dim} ${modelInfo}${theme.reset}` : "";
1288
1314
  return meta ? [line, meta] : [line];
1289
1315
  }
1290
- function renderSelector(state, nowMs, renderer) {
1316
+ function renderSelector(state, nowMs, renderer, editedSelections) {
1291
1317
  const { slots, selectedIndex, isGenerating, totalSlots } = state;
1292
1318
  const lines = [];
1293
1319
  const readyCount = getReadyCount(slots);
@@ -1304,10 +1330,18 @@ function renderSelector(state, nowMs, renderer) {
1304
1330
  const label = readyCount === 1 ? `1 commit message generated${costSuffix}` : `${readyCount} commit messages generated${costSuffix}`;
1305
1331
  lines.push(ui.success(label));
1306
1332
  }
1333
+ const selectedSlot = slots[selectedIndex];
1334
+ const isEditedSelection = selectedSlot?.status === "ready" && editedSelections?.has(selectedSlot.candidate.slotId) === true;
1307
1335
  const hasReady = readyCount > 0;
1308
1336
  if (hasReady) {
1309
- const hint = ui.hint("\u2191\u2193 navigate \u23CE confirm e edit r reroll q quit");
1310
- lines.push(ui.prompt(`Select a commit message ${hint}`));
1337
+ if (isEditedSelection) {
1338
+ lines.push(ui.success("Select a commit message"));
1339
+ } else {
1340
+ const hint = ui.hint(
1341
+ "\u2191\u2193 navigate \u23CE confirm e edit r reroll R refine q quit"
1342
+ );
1343
+ lines.push(ui.prompt(`Select a commit message ${hint}`));
1344
+ }
1311
1345
  } else {
1312
1346
  lines.push(ui.hint(" q quit"));
1313
1347
  }
@@ -1321,6 +1355,14 @@ function renderSelector(state, nowMs, renderer) {
1321
1355
  lines.push("");
1322
1356
  }
1323
1357
  }
1358
+ if (selectedSlot?.status === "ready") {
1359
+ const edited = editedSelections?.get(selectedSlot.candidate.slotId);
1360
+ if (edited) {
1361
+ const editedSummary = edited.split("\n")[0]?.slice(0, 120);
1362
+ lines.push(ui.success(`Edited: ${editedSummary}`));
1363
+ lines.push("");
1364
+ }
1365
+ }
1324
1366
  renderer.render(`${lines.join("\n")}
1325
1367
  `);
1326
1368
  }
@@ -1342,25 +1384,40 @@ function openEditor(content) {
1342
1384
  const editor = process.env.GIT_EDITOR || process.env.EDITOR || "vi";
1343
1385
  const tmpDir = mkdtempSync(join4(tmpdir(), "ultrahope-"));
1344
1386
  const tmpFile = join4(tmpDir, "EDIT_MESSAGE");
1345
- writeFileSync(tmpFile, content);
1346
- const child = spawn(editor, [tmpFile], { stdio: "inherit" });
1347
- child.on("close", (code) => {
1348
- if (code !== 0) {
1349
- unlinkSync(tmpFile);
1350
- reject(new Error(`Editor exited with code ${code}`));
1351
- return;
1352
- }
1353
- const result = readFileSync2(tmpFile, "utf-8").trim();
1354
- unlinkSync(tmpFile);
1355
- resolve3(result);
1356
- });
1357
- child.on("error", (err) => {
1387
+ let cleanupDone = false;
1388
+ const cleanupTempArtifacts = () => {
1389
+ if (cleanupDone) return;
1390
+ cleanupDone = true;
1358
1391
  try {
1359
- unlinkSync(tmpFile);
1392
+ rmSync(tmpDir, { recursive: true, force: true });
1360
1393
  } catch {
1361
1394
  }
1395
+ };
1396
+ try {
1397
+ writeFileSync(tmpFile, content);
1398
+ const child = spawn(editor, [tmpFile], { stdio: "inherit" });
1399
+ child.on("close", (code) => {
1400
+ try {
1401
+ if (code !== 0) {
1402
+ reject(new Error(`Editor exited with code ${code}`));
1403
+ return;
1404
+ }
1405
+ const result = readFileSync2(tmpFile, "utf-8").trim();
1406
+ resolve3(result);
1407
+ } catch (err) {
1408
+ reject(err);
1409
+ } finally {
1410
+ cleanupTempArtifacts();
1411
+ }
1412
+ });
1413
+ child.on("error", (err) => {
1414
+ cleanupTempArtifacts();
1415
+ reject(err);
1416
+ });
1417
+ } catch (err) {
1418
+ cleanupTempArtifacts();
1362
1419
  reject(err);
1363
- });
1420
+ }
1364
1421
  });
1365
1422
  }
1366
1423
  async function selectCandidate(options) {
@@ -1380,8 +1437,12 @@ async function selectCandidate(options) {
1380
1437
  let ttyOutput = null;
1381
1438
  try {
1382
1439
  accessSync2(TTY_PATH, constants2.R_OK | constants2.W_OK);
1383
- const inputFd = openSync2(TTY_PATH, "r");
1384
- ttyInput = new tty2.ReadStream(inputFd);
1440
+ if (process.stdin.isTTY) {
1441
+ ttyInput = process.stdin;
1442
+ } else {
1443
+ const inputFd = openSync2(TTY_PATH, "r");
1444
+ ttyInput = new tty2.ReadStream(inputFd);
1445
+ }
1385
1446
  if (process.stdout.isTTY) {
1386
1447
  ttyOutput = process.stdout;
1387
1448
  } else {
@@ -1420,6 +1481,7 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1420
1481
  resolve3(result);
1421
1482
  };
1422
1483
  const slots = [...initialSlots];
1484
+ const editedSelections = /* @__PURE__ */ new Map();
1423
1485
  const state = {
1424
1486
  slots,
1425
1487
  selectedIndex: 0,
@@ -1428,19 +1490,21 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1428
1490
  };
1429
1491
  let renderInterval = null;
1430
1492
  let cleanedUp = false;
1493
+ let isEditorOpen = false;
1431
1494
  const ttyInput = ttyIo.input;
1432
1495
  const ttyOutput = ttyIo.output;
1433
1496
  const renderer = createRenderer(ttyOutput);
1434
- const rl = readline3.createInterface({
1435
- input: ttyInput,
1436
- output: ttyOutput,
1437
- terminal: true
1438
- });
1439
- readline3.emitKeypressEvents(ttyInput, rl);
1497
+ readline3.emitKeypressEvents(ttyInput);
1440
1498
  ttyInput.setRawMode(true);
1499
+ const setRawModeSafe = (enabled) => {
1500
+ try {
1501
+ ttyInput.setRawMode(enabled);
1502
+ } catch {
1503
+ }
1504
+ };
1441
1505
  const doRender = () => {
1442
- if (!cleanedUp) {
1443
- renderSelector(state, Date.now(), renderer);
1506
+ if (!cleanedUp && !isEditorOpen) {
1507
+ renderSelector(state, Date.now(), renderer, editedSelections);
1444
1508
  }
1445
1509
  };
1446
1510
  const updateState = (update) => {
@@ -1470,22 +1534,36 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1470
1534
  if (clearOutput) {
1471
1535
  renderer.clearAll();
1472
1536
  }
1473
- ttyInput.setRawMode(false);
1474
- rl.close();
1475
- ttyInput.destroy();
1476
- ttyOutput.destroy();
1537
+ ttyInput.removeAllListeners("keypress");
1538
+ setRawModeSafe(false);
1539
+ ttyInput.pause();
1540
+ if (ttyInput !== process.stdin && !ttyInput.destroyed) {
1541
+ ttyInput.destroy();
1542
+ }
1543
+ if (ttyOutput !== process.stdout && ttyOutput !== process.stderr && !ttyOutput.destroyed) {
1544
+ ttyOutput.destroy();
1545
+ }
1477
1546
  };
1478
1547
  const nextCandidate = async (iterator) => {
1548
+ if (!asyncCtx?.abortController) {
1549
+ return iterator.next();
1550
+ }
1551
+ const signal = asyncCtx.abortController.signal;
1552
+ if (signal.aborted) {
1553
+ return { done: true, value: void 0 };
1554
+ }
1555
+ let cleanup2 = () => {
1556
+ };
1479
1557
  const abortPromise = new Promise(
1480
1558
  (resolve4) => {
1481
- asyncCtx?.abortController.signal.addEventListener(
1482
- "abort",
1483
- () => resolve4({ done: true, value: void 0 }),
1484
- { once: true }
1485
- );
1559
+ const onAbort = () => resolve4({ done: true, value: void 0 });
1560
+ signal.addEventListener("abort", onAbort);
1561
+ cleanup2 = () => signal.removeEventListener("abort", onAbort);
1486
1562
  }
1487
1563
  );
1488
- return Promise.race([iterator.next(), abortPromise]);
1564
+ return Promise.race([iterator.next(), abortPromise]).finally(() => {
1565
+ cleanup2();
1566
+ });
1489
1567
  };
1490
1568
  const finalizeGeneration = () => {
1491
1569
  collapseToReady(slots);
@@ -1544,21 +1622,22 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1544
1622
  }
1545
1623
  })();
1546
1624
  }
1547
- const confirmSelection = () => {
1625
+ const confirmSelection = (clearOutput = true) => {
1548
1626
  const candidate = getSelectedCandidate(slots, state.selectedIndex);
1549
1627
  if (!candidate) return;
1628
+ const selectedContent = editedSelections.get(candidate.slotId) ?? candidate.content;
1550
1629
  cancelGeneration();
1551
1630
  const totalCost = getTotalCost(slots);
1552
1631
  const quota = getLatestQuota(slots);
1553
1632
  resolveOnce({
1554
1633
  action: "confirm",
1555
- selected: candidate.content,
1634
+ selected: selectedContent,
1556
1635
  selectedIndex: state.selectedIndex,
1557
1636
  selectedCandidate: candidate,
1558
1637
  totalCost: totalCost > 0 ? totalCost : void 0,
1559
1638
  quota
1560
1639
  });
1561
- cleanup();
1640
+ cleanup(clearOutput);
1562
1641
  };
1563
1642
  const rerollSelection = () => {
1564
1643
  if (!hasReadySlot(slots)) return;
@@ -1574,23 +1653,68 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1574
1653
  const editSelection = async () => {
1575
1654
  const candidate = getSelectedCandidate(slots, state.selectedIndex);
1576
1655
  if (!candidate) return;
1577
- renderer.flush();
1578
- ttyInput.setRawMode(false);
1579
- let edited = null;
1656
+ stopRenderLoop();
1657
+ cancelGeneration();
1658
+ isEditorOpen = true;
1659
+ if (state.isGenerating) {
1660
+ finalizeGeneration();
1661
+ }
1662
+ ttyInput.removeListener("keypress", handleKeypress);
1663
+ setRawModeSafe(false);
1664
+ ttyInput.pause();
1665
+ let edited = candidate.content;
1580
1666
  try {
1581
1667
  const result = await openEditor(candidate.content);
1582
- edited = result ? result : null;
1668
+ edited = result || candidate.content;
1583
1669
  } catch {
1670
+ } finally {
1671
+ isEditorOpen = false;
1584
1672
  }
1585
- ttyInput.setRawMode(true);
1673
+ editedSelections.set(candidate.slotId, edited);
1586
1674
  renderer.reset();
1587
- updateState(() => {
1588
- if (!edited) return;
1589
- slots[state.selectedIndex] = {
1590
- status: "ready",
1591
- candidate: { ...candidate, content: edited }
1675
+ doRender();
1676
+ renderer.flush();
1677
+ setImmediate(() => {
1678
+ if (!cleanedUp) {
1679
+ confirmSelection(false);
1680
+ }
1681
+ });
1682
+ };
1683
+ const refineSelection = async () => {
1684
+ if (!hasReadySlot(slots)) return;
1685
+ ttyInput.off("keypress", handleKeypress);
1686
+ renderer.flush();
1687
+ ttyInput.setRawMode(false);
1688
+ const guide = await new Promise((resolve4) => {
1689
+ const prompt = `${ui.prompt(
1690
+ `Enter refine instructions (e.g., more formal / shorter / Enter to clear): `
1691
+ )}`;
1692
+ const promptReader = readline3.createInterface({
1693
+ input: ttyInput,
1694
+ output: ttyOutput,
1695
+ terminal: true
1696
+ });
1697
+ let resolved2 = false;
1698
+ const finish = (value) => {
1699
+ if (resolved2) return;
1700
+ resolved2 = true;
1701
+ promptReader.close();
1702
+ resolve4(value);
1592
1703
  };
1704
+ promptReader.on("SIGINT", () => {
1705
+ finish(null);
1706
+ });
1707
+ promptReader.question(prompt, (input) => {
1708
+ finish(input.trim());
1709
+ });
1593
1710
  });
1711
+ ttyInput.setRawMode(true);
1712
+ ttyInput.on("keypress", handleKeypress);
1713
+ renderer.reset();
1714
+ if (guide === null) return;
1715
+ cancelGeneration();
1716
+ resolveOnce({ action: "refine", guide });
1717
+ cleanup();
1594
1718
  };
1595
1719
  const handleKeypress = async (_str, key) => {
1596
1720
  if (!key) return;
@@ -1602,10 +1726,14 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1602
1726
  confirmSelection();
1603
1727
  return;
1604
1728
  }
1605
- if (key.name === "r") {
1729
+ if (key.name === "r" && !key.shift) {
1606
1730
  rerollSelection();
1607
1731
  return;
1608
1732
  }
1733
+ if (key.name === "r" && (key.shift || key.name === "R" || key.sequence === "R")) {
1734
+ await refineSelection();
1735
+ return;
1736
+ }
1609
1737
  if (key.name === "e") {
1610
1738
  await editSelection();
1611
1739
  return;
@@ -1807,6 +1935,16 @@ function normalizeGuide(value) {
1807
1935
  if (!trimmed) return void 0;
1808
1936
  return trimmed.length > 1024 ? trimmed.slice(0, 1024) : trimmed;
1809
1937
  }
1938
+ function composeGuidance(baseGuide, guideHint) {
1939
+ const normalizedBase = baseGuide?.trim() ?? "";
1940
+ const normalizedGuideHint = guideHint?.trim() ?? "";
1941
+ if (!normalizedBase && !normalizedGuideHint) return void 0;
1942
+ if (!normalizedBase) return normalizedGuideHint;
1943
+ if (!normalizedGuideHint) return normalizedBase;
1944
+ return `${normalizedBase}
1945
+
1946
+ Refinement: ${normalizedGuideHint}`;
1947
+ }
1810
1948
  function exitWithInvalidModelError(error) {
1811
1949
  console.error(`Error: Model '${error.model}' is not supported.`);
1812
1950
  if (error.allowedModels.length > 0) {
@@ -1922,6 +2060,7 @@ async function commit(args2) {
1922
2060
  const commandExecutionSignal = abortController.signal;
1923
2061
  const commandExecutionPromise = promise;
1924
2062
  const apiClient = api;
2063
+ let guideHint;
1925
2064
  commandExecutionPromise.catch(async (error) => {
1926
2065
  abortController.abort(abortReasonForError(error));
1927
2066
  await handleCommandExecutionError(error, {
@@ -1946,7 +2085,7 @@ async function commit(args2) {
1946
2085
  const createCandidates = (signal) => generateCommitMessages({
1947
2086
  diff,
1948
2087
  models,
1949
- guide: options.guide,
2088
+ guide: composeGuidance(options.guide, guideHint),
1950
2089
  signal: mergeAbortSignals(signal, commandExecutionSignal),
1951
2090
  cliSessionId,
1952
2091
  commandExecutionPromise,
@@ -1956,7 +2095,7 @@ async function commit(args2) {
1956
2095
  const gen = generateCommitMessages({
1957
2096
  diff,
1958
2097
  models: models.slice(0, 1),
1959
- guide: options.guide,
2098
+ guide: composeGuidance(options.guide, guideHint),
1960
2099
  signal: commandExecutionSignal,
1961
2100
  cliSessionId,
1962
2101
  commandExecutionPromise,
@@ -1995,6 +2134,10 @@ async function commit(args2) {
1995
2134
  if (result.action === "reroll") {
1996
2135
  continue;
1997
2136
  }
2137
+ if (result.action === "refine" && result.guide !== void 0) {
2138
+ guideHint = result.guide.trim() || void 0;
2139
+ continue;
2140
+ }
1998
2141
  if (result.action === "confirm" && result.selected) {
1999
2142
  await recordSelection(result.selectedCandidate?.generationId);
2000
2143
  const costLabel = result.totalCost != null ? ` (total: ${formatTotalCost(result.totalCost)})` : "";
package/dist/index.js CHANGED
@@ -547,9 +547,6 @@ var theme = {
547
547
  };
548
548
 
549
549
  // lib/ui.ts
550
- function formatTotalCost(cost) {
551
- return `$${cost.toFixed(6)}`;
552
- }
553
550
  var ui = {
554
551
  success: (msg) => `${theme.success}\u2714${theme.reset} ${theme.primary}${msg}${theme.reset}`,
555
552
  progress: (msg) => `${theme.progress}\u25B6${theme.reset} ${theme.primary}${msg}${theme.reset}`,
@@ -931,7 +928,19 @@ async function* generateCommitMessages(options) {
931
928
  try {
932
929
  if (signal?.aborted) return;
933
930
  let lastCommitMessage = "";
931
+ const generationStartedAtMs = Date.now();
932
+ let lastCommitMessageAtMs;
933
+ let providerMetadataAtMs;
934
934
  let providerMetadata;
935
+ const parseEventAtMs = (event) => {
936
+ if (typeof event.atMs !== "number" || !Number.isFinite(event.atMs)) {
937
+ return void 0;
938
+ }
939
+ if (event.atMs < 0) {
940
+ return 0;
941
+ }
942
+ return Math.round(event.atMs);
943
+ };
935
944
  for await (const event of generateWithRetry({
936
945
  cliSessionId: requiredCliSessionId,
937
946
  input: diff,
@@ -940,6 +949,7 @@ async function* generateCommitMessages(options) {
940
949
  })) {
941
950
  if (event.type === "commit-message") {
942
951
  lastCommitMessage = event.commitMessage;
952
+ lastCommitMessageAtMs = parseEventAtMs(event) ?? lastCommitMessageAtMs;
943
953
  if (useStream) {
944
954
  yield {
945
955
  content: lastCommitMessage,
@@ -950,6 +960,7 @@ async function* generateCommitMessages(options) {
950
960
  };
951
961
  }
952
962
  } else if (event.type === "provider-metadata") {
963
+ providerMetadataAtMs = parseEventAtMs(event) ?? providerMetadataAtMs;
953
964
  providerMetadata = event.providerMetadata;
954
965
  } else if (event.type === "error") {
955
966
  throw new Error(event.message);
@@ -957,11 +968,13 @@ async function* generateCommitMessages(options) {
957
968
  }
958
969
  if (lastCommitMessage) {
959
970
  const { generationId, cost } = extractGatewayMetadata(providerMetadata);
971
+ const generationMs = (providerMetadataAtMs ?? lastCommitMessageAtMs) != null ? providerMetadataAtMs ?? lastCommitMessageAtMs : Math.max(0, Date.now() - generationStartedAtMs);
960
972
  yield {
961
973
  content: lastCommitMessage,
962
974
  slotId: model,
963
975
  model,
964
976
  cost,
977
+ generationMs,
965
978
  generationId,
966
979
  ...useStream ? { isPartial: false } : {},
967
980
  slotIndex
@@ -1166,7 +1179,7 @@ import {
1166
1179
  mkdtempSync,
1167
1180
  openSync as openSync2,
1168
1181
  readFileSync as readFileSync2,
1169
- unlinkSync,
1182
+ rmSync,
1170
1183
  writeFileSync
1171
1184
  } from "fs";
1172
1185
  import { tmpdir } from "os";
@@ -1300,7 +1313,17 @@ function formatSlot(slot, selected) {
1300
1313
  }
1301
1314
  const candidate = slot.candidate;
1302
1315
  const title = candidate.content.split("\n")[0]?.trim() || "";
1303
- const modelInfo = candidate.model ? candidate.cost ? `${formatModelName(candidate.model)} ${formatCost(candidate.cost)}` : formatModelName(candidate.model) : "";
1316
+ const formatDuration = (ms) => {
1317
+ const safeMs = Math.max(0, Math.round(ms));
1318
+ if (safeMs < 1e3) {
1319
+ return `${safeMs}ms`;
1320
+ }
1321
+ const seconds = (safeMs / 1e3).toFixed(1).replace(/\.0$/, "");
1322
+ return `${seconds}s`;
1323
+ };
1324
+ const formattedModel = candidate.model ? formatModelName(candidate.model) : "";
1325
+ const formattedDuration = candidate.generationMs == null ? "" : ` ${formatDuration(candidate.generationMs)}`;
1326
+ const modelInfo = candidate.model ? candidate.cost ? `${formattedModel} ${formatCost(candidate.cost)}${formattedDuration}` : `${formattedModel}${formattedDuration}` : "";
1304
1327
  if (selected) {
1305
1328
  const radio2 = "\u25CF";
1306
1329
  const line2 = ` ${radio2} ${theme.bold}${title}${theme.reset}`;
@@ -1312,7 +1335,7 @@ function formatSlot(slot, selected) {
1312
1335
  const meta = modelInfo ? `${theme.dim} ${modelInfo}${theme.reset}` : "";
1313
1336
  return meta ? [line, meta] : [line];
1314
1337
  }
1315
- function renderSelector(state, nowMs, renderer) {
1338
+ function renderSelector(state, nowMs, renderer, editedSelections) {
1316
1339
  const { slots, selectedIndex, isGenerating, totalSlots } = state;
1317
1340
  const lines = [];
1318
1341
  const readyCount = getReadyCount(slots);
@@ -1329,10 +1352,18 @@ function renderSelector(state, nowMs, renderer) {
1329
1352
  const label = readyCount === 1 ? `1 commit message generated${costSuffix}` : `${readyCount} commit messages generated${costSuffix}`;
1330
1353
  lines.push(ui.success(label));
1331
1354
  }
1355
+ const selectedSlot = slots[selectedIndex];
1356
+ const isEditedSelection = selectedSlot?.status === "ready" && editedSelections?.has(selectedSlot.candidate.slotId) === true;
1332
1357
  const hasReady = readyCount > 0;
1333
1358
  if (hasReady) {
1334
- const hint = ui.hint("\u2191\u2193 navigate \u23CE confirm e edit r reroll q quit");
1335
- lines.push(ui.prompt(`Select a commit message ${hint}`));
1359
+ if (isEditedSelection) {
1360
+ lines.push(ui.success("Select a commit message"));
1361
+ } else {
1362
+ const hint = ui.hint(
1363
+ "\u2191\u2193 navigate \u23CE confirm e edit r reroll R refine q quit"
1364
+ );
1365
+ lines.push(ui.prompt(`Select a commit message ${hint}`));
1366
+ }
1336
1367
  } else {
1337
1368
  lines.push(ui.hint(" q quit"));
1338
1369
  }
@@ -1346,6 +1377,14 @@ function renderSelector(state, nowMs, renderer) {
1346
1377
  lines.push("");
1347
1378
  }
1348
1379
  }
1380
+ if (selectedSlot?.status === "ready") {
1381
+ const edited = editedSelections?.get(selectedSlot.candidate.slotId);
1382
+ if (edited) {
1383
+ const editedSummary = edited.split("\n")[0]?.slice(0, 120);
1384
+ lines.push(ui.success(`Edited: ${editedSummary}`));
1385
+ lines.push("");
1386
+ }
1387
+ }
1349
1388
  renderer.render(`${lines.join("\n")}
1350
1389
  `);
1351
1390
  }
@@ -1367,25 +1406,40 @@ function openEditor(content) {
1367
1406
  const editor = process.env.GIT_EDITOR || process.env.EDITOR || "vi";
1368
1407
  const tmpDir = mkdtempSync(join4(tmpdir(), "ultrahope-"));
1369
1408
  const tmpFile = join4(tmpDir, "EDIT_MESSAGE");
1370
- writeFileSync(tmpFile, content);
1371
- const child = spawn(editor, [tmpFile], { stdio: "inherit" });
1372
- child.on("close", (code) => {
1373
- if (code !== 0) {
1374
- unlinkSync(tmpFile);
1375
- reject(new Error(`Editor exited with code ${code}`));
1376
- return;
1377
- }
1378
- const result = readFileSync2(tmpFile, "utf-8").trim();
1379
- unlinkSync(tmpFile);
1380
- resolve3(result);
1381
- });
1382
- child.on("error", (err) => {
1409
+ let cleanupDone = false;
1410
+ const cleanupTempArtifacts = () => {
1411
+ if (cleanupDone) return;
1412
+ cleanupDone = true;
1383
1413
  try {
1384
- unlinkSync(tmpFile);
1414
+ rmSync(tmpDir, { recursive: true, force: true });
1385
1415
  } catch {
1386
1416
  }
1417
+ };
1418
+ try {
1419
+ writeFileSync(tmpFile, content);
1420
+ const child = spawn(editor, [tmpFile], { stdio: "inherit" });
1421
+ child.on("close", (code) => {
1422
+ try {
1423
+ if (code !== 0) {
1424
+ reject(new Error(`Editor exited with code ${code}`));
1425
+ return;
1426
+ }
1427
+ const result = readFileSync2(tmpFile, "utf-8").trim();
1428
+ resolve3(result);
1429
+ } catch (err) {
1430
+ reject(err);
1431
+ } finally {
1432
+ cleanupTempArtifacts();
1433
+ }
1434
+ });
1435
+ child.on("error", (err) => {
1436
+ cleanupTempArtifacts();
1437
+ reject(err);
1438
+ });
1439
+ } catch (err) {
1440
+ cleanupTempArtifacts();
1387
1441
  reject(err);
1388
- });
1442
+ }
1389
1443
  });
1390
1444
  }
1391
1445
  async function selectCandidate(options) {
@@ -1405,8 +1459,12 @@ async function selectCandidate(options) {
1405
1459
  let ttyOutput = null;
1406
1460
  try {
1407
1461
  accessSync2(TTY_PATH, constants2.R_OK | constants2.W_OK);
1408
- const inputFd = openSync2(TTY_PATH, "r");
1409
- ttyInput = new tty2.ReadStream(inputFd);
1462
+ if (process.stdin.isTTY) {
1463
+ ttyInput = process.stdin;
1464
+ } else {
1465
+ const inputFd = openSync2(TTY_PATH, "r");
1466
+ ttyInput = new tty2.ReadStream(inputFd);
1467
+ }
1410
1468
  if (process.stdout.isTTY) {
1411
1469
  ttyOutput = process.stdout;
1412
1470
  } else {
@@ -1445,6 +1503,7 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1445
1503
  resolve3(result);
1446
1504
  };
1447
1505
  const slots = [...initialSlots];
1506
+ const editedSelections = /* @__PURE__ */ new Map();
1448
1507
  const state = {
1449
1508
  slots,
1450
1509
  selectedIndex: 0,
@@ -1453,19 +1512,21 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1453
1512
  };
1454
1513
  let renderInterval = null;
1455
1514
  let cleanedUp = false;
1515
+ let isEditorOpen = false;
1456
1516
  const ttyInput = ttyIo.input;
1457
1517
  const ttyOutput = ttyIo.output;
1458
1518
  const renderer = createRenderer(ttyOutput);
1459
- const rl = readline3.createInterface({
1460
- input: ttyInput,
1461
- output: ttyOutput,
1462
- terminal: true
1463
- });
1464
- readline3.emitKeypressEvents(ttyInput, rl);
1519
+ readline3.emitKeypressEvents(ttyInput);
1465
1520
  ttyInput.setRawMode(true);
1521
+ const setRawModeSafe = (enabled) => {
1522
+ try {
1523
+ ttyInput.setRawMode(enabled);
1524
+ } catch {
1525
+ }
1526
+ };
1466
1527
  const doRender = () => {
1467
- if (!cleanedUp) {
1468
- renderSelector(state, Date.now(), renderer);
1528
+ if (!cleanedUp && !isEditorOpen) {
1529
+ renderSelector(state, Date.now(), renderer, editedSelections);
1469
1530
  }
1470
1531
  };
1471
1532
  const updateState = (update) => {
@@ -1495,22 +1556,36 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1495
1556
  if (clearOutput) {
1496
1557
  renderer.clearAll();
1497
1558
  }
1498
- ttyInput.setRawMode(false);
1499
- rl.close();
1500
- ttyInput.destroy();
1501
- ttyOutput.destroy();
1559
+ ttyInput.removeAllListeners("keypress");
1560
+ setRawModeSafe(false);
1561
+ ttyInput.pause();
1562
+ if (ttyInput !== process.stdin && !ttyInput.destroyed) {
1563
+ ttyInput.destroy();
1564
+ }
1565
+ if (ttyOutput !== process.stdout && ttyOutput !== process.stderr && !ttyOutput.destroyed) {
1566
+ ttyOutput.destroy();
1567
+ }
1502
1568
  };
1503
1569
  const nextCandidate = async (iterator) => {
1570
+ if (!asyncCtx?.abortController) {
1571
+ return iterator.next();
1572
+ }
1573
+ const signal = asyncCtx.abortController.signal;
1574
+ if (signal.aborted) {
1575
+ return { done: true, value: void 0 };
1576
+ }
1577
+ let cleanup2 = () => {
1578
+ };
1504
1579
  const abortPromise = new Promise(
1505
1580
  (resolve4) => {
1506
- asyncCtx?.abortController.signal.addEventListener(
1507
- "abort",
1508
- () => resolve4({ done: true, value: void 0 }),
1509
- { once: true }
1510
- );
1581
+ const onAbort = () => resolve4({ done: true, value: void 0 });
1582
+ signal.addEventListener("abort", onAbort);
1583
+ cleanup2 = () => signal.removeEventListener("abort", onAbort);
1511
1584
  }
1512
1585
  );
1513
- return Promise.race([iterator.next(), abortPromise]);
1586
+ return Promise.race([iterator.next(), abortPromise]).finally(() => {
1587
+ cleanup2();
1588
+ });
1514
1589
  };
1515
1590
  const finalizeGeneration = () => {
1516
1591
  collapseToReady(slots);
@@ -1569,21 +1644,22 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1569
1644
  }
1570
1645
  })();
1571
1646
  }
1572
- const confirmSelection = () => {
1647
+ const confirmSelection = (clearOutput = true) => {
1573
1648
  const candidate = getSelectedCandidate(slots, state.selectedIndex);
1574
1649
  if (!candidate) return;
1650
+ const selectedContent = editedSelections.get(candidate.slotId) ?? candidate.content;
1575
1651
  cancelGeneration();
1576
1652
  const totalCost = getTotalCost(slots);
1577
1653
  const quota = getLatestQuota(slots);
1578
1654
  resolveOnce({
1579
1655
  action: "confirm",
1580
- selected: candidate.content,
1656
+ selected: selectedContent,
1581
1657
  selectedIndex: state.selectedIndex,
1582
1658
  selectedCandidate: candidate,
1583
1659
  totalCost: totalCost > 0 ? totalCost : void 0,
1584
1660
  quota
1585
1661
  });
1586
- cleanup();
1662
+ cleanup(clearOutput);
1587
1663
  };
1588
1664
  const rerollSelection = () => {
1589
1665
  if (!hasReadySlot(slots)) return;
@@ -1599,23 +1675,68 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1599
1675
  const editSelection = async () => {
1600
1676
  const candidate = getSelectedCandidate(slots, state.selectedIndex);
1601
1677
  if (!candidate) return;
1602
- renderer.flush();
1603
- ttyInput.setRawMode(false);
1604
- let edited = null;
1678
+ stopRenderLoop();
1679
+ cancelGeneration();
1680
+ isEditorOpen = true;
1681
+ if (state.isGenerating) {
1682
+ finalizeGeneration();
1683
+ }
1684
+ ttyInput.removeListener("keypress", handleKeypress);
1685
+ setRawModeSafe(false);
1686
+ ttyInput.pause();
1687
+ let edited = candidate.content;
1605
1688
  try {
1606
1689
  const result = await openEditor(candidate.content);
1607
- edited = result ? result : null;
1690
+ edited = result || candidate.content;
1608
1691
  } catch {
1692
+ } finally {
1693
+ isEditorOpen = false;
1609
1694
  }
1610
- ttyInput.setRawMode(true);
1695
+ editedSelections.set(candidate.slotId, edited);
1611
1696
  renderer.reset();
1612
- updateState(() => {
1613
- if (!edited) return;
1614
- slots[state.selectedIndex] = {
1615
- status: "ready",
1616
- candidate: { ...candidate, content: edited }
1697
+ doRender();
1698
+ renderer.flush();
1699
+ setImmediate(() => {
1700
+ if (!cleanedUp) {
1701
+ confirmSelection(false);
1702
+ }
1703
+ });
1704
+ };
1705
+ const refineSelection = async () => {
1706
+ if (!hasReadySlot(slots)) return;
1707
+ ttyInput.off("keypress", handleKeypress);
1708
+ renderer.flush();
1709
+ ttyInput.setRawMode(false);
1710
+ const guide = await new Promise((resolve4) => {
1711
+ const prompt = `${ui.prompt(
1712
+ `Enter refine instructions (e.g., more formal / shorter / Enter to clear): `
1713
+ )}`;
1714
+ const promptReader = readline3.createInterface({
1715
+ input: ttyInput,
1716
+ output: ttyOutput,
1717
+ terminal: true
1718
+ });
1719
+ let resolved2 = false;
1720
+ const finish = (value) => {
1721
+ if (resolved2) return;
1722
+ resolved2 = true;
1723
+ promptReader.close();
1724
+ resolve4(value);
1617
1725
  };
1726
+ promptReader.on("SIGINT", () => {
1727
+ finish(null);
1728
+ });
1729
+ promptReader.question(prompt, (input) => {
1730
+ finish(input.trim());
1731
+ });
1618
1732
  });
1733
+ ttyInput.setRawMode(true);
1734
+ ttyInput.on("keypress", handleKeypress);
1735
+ renderer.reset();
1736
+ if (guide === null) return;
1737
+ cancelGeneration();
1738
+ resolveOnce({ action: "refine", guide });
1739
+ cleanup();
1619
1740
  };
1620
1741
  const handleKeypress = async (_str, key) => {
1621
1742
  if (!key) return;
@@ -1627,10 +1748,14 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1627
1748
  confirmSelection();
1628
1749
  return;
1629
1750
  }
1630
- if (key.name === "r") {
1751
+ if (key.name === "r" && !key.shift) {
1631
1752
  rerollSelection();
1632
1753
  return;
1633
1754
  }
1755
+ if (key.name === "r" && (key.shift || key.name === "R" || key.sequence === "R")) {
1756
+ await refineSelection();
1757
+ return;
1758
+ }
1634
1759
  if (key.name === "e") {
1635
1760
  await editSelection();
1636
1761
  return;
@@ -1848,6 +1973,16 @@ function normalizeGuide(value) {
1848
1973
  if (!trimmed) return void 0;
1849
1974
  return trimmed.length > 1024 ? trimmed.slice(0, 1024) : trimmed;
1850
1975
  }
1976
+ function composeGuidance(baseGuide, guideHint) {
1977
+ const normalizedBase = baseGuide?.trim() ?? "";
1978
+ const normalizedGuideHint = guideHint?.trim() ?? "";
1979
+ if (!normalizedBase && !normalizedGuideHint) return void 0;
1980
+ if (!normalizedBase) return normalizedGuideHint;
1981
+ if (!normalizedGuideHint) return normalizedBase;
1982
+ return `${normalizedBase}
1983
+
1984
+ Refinement: ${normalizedGuideHint}`;
1985
+ }
1851
1986
  function parseDescribeArgs(args2) {
1852
1987
  let revision = "@";
1853
1988
  let interactive = true;
@@ -1962,11 +2097,11 @@ async function recordSelection(apiClient, generationId) {
1962
2097
  console.error(`Warning: Failed to record selection. ${message}`);
1963
2098
  }
1964
2099
  }
1965
- function createCandidateFactory(diff, models, context, captureRecorder, guide) {
2100
+ function createCandidateFactory(diff, models, context, captureRecorder, getGuide) {
1966
2101
  return (signal) => generateCommitMessages({
1967
2102
  diff,
1968
2103
  models,
1969
- guide,
2104
+ guide: getGuide(),
1970
2105
  signal: mergeAbortSignals(signal, context.commandExecutionSignal),
1971
2106
  cliSessionId: context.cliSessionId,
1972
2107
  commandExecutionPromise: context.commandExecutionPromise,
@@ -1995,7 +2130,7 @@ async function runNonInteractiveDescribe(revision, models, diff, context, captur
1995
2130
  const message = first.value?.content ?? "";
1996
2131
  describeRevision(revision, message);
1997
2132
  }
1998
- async function runInteractiveDescribe(options, models, createCandidates, context) {
2133
+ async function runInteractiveDescribe(options, models, createCandidates, context, onGuideHintChange) {
1999
2134
  const stats = getJjDiffStats(options.revision);
2000
2135
  console.log(ui.success(`Found ${formatDiffStats(stats)}`));
2001
2136
  while (true) {
@@ -2018,13 +2153,16 @@ async function runInteractiveDescribe(options, models, createCandidates, context
2018
2153
  if (result.action === "reroll") {
2019
2154
  continue;
2020
2155
  }
2156
+ if (result.action === "refine" && result.guide !== void 0) {
2157
+ const nextGuideHint = result.guide.trim() || void 0;
2158
+ onGuideHintChange(nextGuideHint);
2159
+ continue;
2160
+ }
2021
2161
  if (result.action === "confirm" && result.selected) {
2022
2162
  await recordSelection(
2023
2163
  context.apiClient,
2024
2164
  result.selectedCandidate?.generationId
2025
2165
  );
2026
- const costLabel = result.totalCost != null ? ` (total: ${formatTotalCost(result.totalCost)})` : "";
2027
- console.log(ui.success(`Message selected${costLabel}`));
2028
2166
  console.log(
2029
2167
  `${ui.success(`Running jj describe -r ${options.revision}`)}
2030
2168
  `
@@ -2049,18 +2187,20 @@ async function describe(args2) {
2049
2187
  apiPath: "/v1/commit-message/stream"
2050
2188
  });
2051
2189
  try {
2190
+ let guideHint;
2052
2191
  const context = await initCommandExecutionContext(
2053
2192
  args2,
2054
2193
  models,
2055
2194
  diff,
2056
2195
  options.guide
2057
2196
  );
2197
+ const resolveGuide = () => composeGuidance(options.guide, guideHint);
2058
2198
  const createCandidates = createCandidateFactory(
2059
2199
  diff,
2060
2200
  models,
2061
2201
  context,
2062
2202
  captureRecorder,
2063
- options.guide
2203
+ resolveGuide
2064
2204
  );
2065
2205
  if (!options.interactive) {
2066
2206
  await runNonInteractiveDescribe(
@@ -2069,11 +2209,19 @@ async function describe(args2) {
2069
2209
  diff,
2070
2210
  context,
2071
2211
  captureRecorder,
2072
- options.guide
2212
+ resolveGuide()
2073
2213
  );
2074
2214
  return;
2075
2215
  }
2076
- await runInteractiveDescribe(options, models, createCandidates, context);
2216
+ await runInteractiveDescribe(
2217
+ options,
2218
+ models,
2219
+ createCandidates,
2220
+ context,
2221
+ (value) => {
2222
+ guideHint = value;
2223
+ }
2224
+ );
2077
2225
  } finally {
2078
2226
  const capturePath = captureRecorder.flush();
2079
2227
  if (capturePath) {
@@ -2220,6 +2368,10 @@ function exitWithInvalidModelError2(error) {
2220
2368
  }
2221
2369
  process.exit(1);
2222
2370
  }
2371
+ function composeGuidance2(guideHint) {
2372
+ const normalizedGuideHint = guideHint?.trim() ?? "";
2373
+ return normalizedGuideHint || void 0;
2374
+ }
2223
2375
  async function translate(args2) {
2224
2376
  const options = parseArgs(args2);
2225
2377
  const input = await stdin();
@@ -2271,6 +2423,7 @@ async function handleVcsCommitMessage(input, options, args2) {
2271
2423
  const commandExecutionSignal = abortController.signal;
2272
2424
  const commandExecutionPromise = promise;
2273
2425
  const apiClient = api;
2426
+ let guideHint;
2274
2427
  commandExecutionPromise.catch(async (error) => {
2275
2428
  abortController.abort(abortReasonForError(error));
2276
2429
  await handleCommandExecutionError(error, {
@@ -2295,6 +2448,7 @@ async function handleVcsCommitMessage(input, options, args2) {
2295
2448
  const createCandidates = (signal) => generateCommitMessages({
2296
2449
  diff: input,
2297
2450
  models,
2451
+ guide: composeGuidance2(guideHint),
2298
2452
  signal: mergeAbortSignals(signal, commandExecutionSignal),
2299
2453
  cliSessionId,
2300
2454
  commandExecutionPromise,
@@ -2304,6 +2458,7 @@ async function handleVcsCommitMessage(input, options, args2) {
2304
2458
  const gen = generateCommitMessages({
2305
2459
  diff: input,
2306
2460
  models: models.slice(0, 1),
2461
+ guide: composeGuidance2(guideHint),
2307
2462
  signal: commandExecutionSignal,
2308
2463
  cliSessionId,
2309
2464
  commandExecutionPromise,
@@ -2336,6 +2491,10 @@ async function handleVcsCommitMessage(input, options, args2) {
2336
2491
  console.error("Aborted.");
2337
2492
  process.exit(1);
2338
2493
  }
2494
+ if (result.action === "refine" && result.guide !== void 0) {
2495
+ guideHint = result.guide.trim() || void 0;
2496
+ continue;
2497
+ }
2339
2498
  if (result.action === "reroll") {
2340
2499
  continue;
2341
2500
  }
@@ -2571,7 +2730,7 @@ function parseArgs(args2) {
2571
2730
  // package.json
2572
2731
  var package_default = {
2573
2732
  name: "ultrahope",
2574
- version: "0.1.4",
2733
+ version: "0.1.5",
2575
2734
  description: "LLM-powered development workflow assistant",
2576
2735
  type: "module",
2577
2736
  license: "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultrahope",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "LLM-powered development workflow assistant",
5
5
  "type": "module",
6
6
  "license": "MIT",