@goodandready/dsh-goal 0.1.7 → 0.1.8

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.
@@ -96,3 +96,23 @@
96
96
  - **Устойчивость SSE с Exponential Backoff**: На клиенте внедрена автоматическая схема повторного подключения `EventSource` с возрастающей задержкой (от 2s до 30s) и рандомизированным джиттером для защиты от шторма переподключений.
97
97
  - **Строгая REST валидация и Enum Guard**: Обработчик `POST /dsh-goal/action` валидирует непустой `title` при старте цели и проверяет допустимость статуса вехи по `MilestoneStatus` enum, возвращая внятный HTTP 400 Bad Request / 404 Not Found.
98
98
  - **Безопасность файловой системы**: `GoalEngine.writeStateToDiskSync()` гарантированно создаёт отсутствующие родительские директории через `fs.mkdirSync(dir, { recursive: true })` перед созданием временного файла и атомарным переименованием.
99
+
100
+ ### Решение 9: Интеллектуальная оценка времени (ETA), аналитика токенов, кнопка быстрого запуска цели и экспорт отчетов в Markdown
101
+
102
+ **Контекст:**
103
+ После стабилизации сессионной изоляции и SSE-потока пользователям требовалась наглядная оценка оставшегося времени работы над целью, понимание расхода токенов за цикл, удобный запуск цели без ручного ввода команды `/goal` и возможность мгновенно экспортировать структурированный итоговый отчёт в markdown-формате.
104
+
105
+ **Принятые решения:**
106
+ 1. **Расчет оставшегося времени (ETA projection):**
107
+ - Метод `getEstimatedRemainingSeconds(sid)` рассчитывает среднее время на выполнение завершенных вех `elapsed / completedCount` и умножает на количество оставшихся шагов.
108
+ - В верхнем баннере и модальном окне отображается живое время работы с прогнозом: `⏱ 45s (ETA ~2m)`.
109
+ 2. **Аналитика расхода токенов (Token Usage Analytics):**
110
+ - Накопление счетчиков `promptTokens`, `completionTokens`, `totalTokens` на каждом завершении хода `turn/end` через `engine.addTokenUsage(usage, sid)`.
111
+ - В модальном окне деталей в сетку характеристик добавлен блок «Токены» с подсказкой при наведении с детальным расщеплением.
112
+ 3. **Кнопка быстрого запуска цели (Quick Launch button):**
113
+ - Когда цель не активна, в `conversation.input.dock` отображается компактная кнопка с иконкой 🎯.
114
+ - По клику открывается модальное окно с полем ввода для немедленной постановки задачи агенту без ручного ввода слэш-команд.
115
+ - В карточке настроек добавлен переключатель `showQuickLaunchButton` с возможностью сброса к дефолту.
116
+ 4. **Экспорт отчета в Markdown (One-click Markdown Export):**
117
+ - Для выполненной цели модальное окно предоставляет кнопку «📋 Скопировать отчёт в Markdown».
118
+ - Генерирует отчет с заголовком, статусом, длительностью, итерациями, расходом токенов, резюме результатов и таблицей вех.
package/lib/client.js CHANGED
@@ -182,6 +182,32 @@ window.__ModuleLoader__.load({
182
182
  display: flex;
183
183
  justify-content: center;
184
184
  }
185
+ .dsh-goal-dock.dsh-goal-dock-quick {
186
+ justify-content: flex-start;
187
+ margin-bottom: 4px;
188
+ }
189
+ .dsh-goal-quicklaunch-btn {
190
+ appearance: none;
191
+ font: inherit;
192
+ background: var(--dsw-alias-bg-layer-2);
193
+ border: 1px solid var(--dsw-alias-border-l2);
194
+ border-radius: 999px;
195
+ padding: 4px 11px;
196
+ color: var(--dsw-alias-label-secondary);
197
+ font-size: 12px;
198
+ font-weight: 500;
199
+ cursor: pointer;
200
+ display: inline-flex;
201
+ align-items: center;
202
+ gap: 6px;
203
+ transition: all 0.15s ease;
204
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
205
+ }
206
+ .dsh-goal-quicklaunch-btn:hover {
207
+ background: var(--dsw-alias-bg-layer-3);
208
+ border-color: var(--dsw-alias-label-tertiary);
209
+ color: var(--dsw-alias-label-primary);
210
+ }
185
211
  .dsh-goal-banner {
186
212
  box-sizing: border-box;
187
213
  width: 100%;
@@ -382,7 +408,7 @@ window.__ModuleLoader__.load({
382
408
  }
383
409
  .dsh-goal-stat-grid {
384
410
  display: grid;
385
- grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
411
+ grid-template-columns: repeat(auto-fit, minmax(95px, 1fr));
386
412
  gap: 10px;
387
413
  }
388
414
  .dsh-goal-stat-box {
@@ -395,13 +421,17 @@ window.__ModuleLoader__.load({
395
421
  gap: 2px;
396
422
  }
397
423
  .dsh-goal-stat-val {
398
- font-size: 16px;
424
+ font-size: 15px;
399
425
  font-weight: 700;
400
426
  color: var(--dsw-alias-label-primary);
427
+ overflow: hidden;
428
+ text-overflow: ellipsis;
429
+ white-space: nowrap;
401
430
  }
402
431
  .dsh-goal-stat-lbl {
403
432
  font-size: 11px;
404
433
  color: var(--dsw-alias-label-secondary);
434
+ white-space: nowrap;
405
435
  }
406
436
  .dsh-goal-milestone-item {
407
437
  display: flex;
@@ -421,6 +451,17 @@ window.__ModuleLoader__.load({
421
451
  justify-content: flex-end;
422
452
  align-items: center;
423
453
  gap: 10px;
454
+ flex-wrap: wrap;
455
+ }
456
+ .dsh-goal-btn-copy {
457
+ background: var(--dsw-alias-bg-layer-2);
458
+ border: 1px solid var(--dsw-alias-border-l2);
459
+ color: var(--dsw-alias-label-primary);
460
+ }
461
+ .dsh-goal-btn-copy.copied {
462
+ background: rgba(16, 185, 129, 0.12);
463
+ border-color: var(--dsw-alias-state-success-primary);
464
+ color: var(--dsw-alias-state-success-primary);
424
465
  }
425
466
 
426
467
  /* Settings Card (clinebot pattern) */
@@ -658,12 +699,77 @@ window.__ModuleLoader__.load({
658
699
  } catch (_) {}
659
700
  }
660
701
 
702
+ // --- ГЕНЕРАТОР MARKDOWN-ОТЧЕТА ---
703
+ function generateMarkdownReport(state) {
704
+ if (!state) return '';
705
+ const title = state.title || 'Goal Report';
706
+ const status = state.state === 'COMPLETED' ? 'COMPLETED' : state.state;
707
+ const elapsed = state.formattedElapsed || '0s';
708
+ const iter = `${state.iterationsCount || 0}/${state.maxIterations || 25}`;
709
+ const totalTokens = state.tokensUsage?.totalTokens || 0;
710
+ const promptTokens = state.tokensUsage?.promptTokens || 0;
711
+ const compTokens = state.tokensUsage?.completionTokens || 0;
712
+
713
+ let md = `# 🎯 Goal Report: ${title}\n\n`;
714
+ md += `**Status:** \`${status}\` | **Duration:** \`${elapsed}\` | **Iterations:** \`${iter}\`\n`;
715
+ if (totalTokens > 0) {
716
+ md += `**Tokens:** \`${totalTokens.toLocaleString()}\` (Prompt: \`${promptTokens.toLocaleString()}\`, Completion: \`${compTokens.toLocaleString()}\`)\n`;
717
+ }
718
+ md += '\n';
719
+
720
+ if (state.description) {
721
+ md += `### Description\n${state.description}\n\n`;
722
+ }
723
+
724
+ if (state.resultSummary) {
725
+ md += `### Summary & Deliverables\n${state.resultSummary}\n\n`;
726
+ }
727
+
728
+ const milestones = state.milestones || [];
729
+ if (milestones.length > 0) {
730
+ md += `### Milestones\n| # | Status | Title | Notes |\n|---|---|---|---|\n`;
731
+ milestones.forEach((m, idx) => {
732
+ const mark = m.status === 'completed' ? '✅ Done' : m.status === 'in_progress' ? '🔄 In Progress' : '⏳ Pending';
733
+ const cleanTitle = (m.title || '').replace(/\|/g, '\\|');
734
+ const cleanNotes = (m.notes || '').replace(/\|/g, '\\|');
735
+ md += `| ${idx + 1} | ${mark} | ${cleanTitle} | ${cleanNotes || '—'} |\n`;
736
+ });
737
+ md += '\n';
738
+ }
739
+
740
+ md += `*Generated by DSH Goal Engine at ${new Date().toISOString()}*\n`;
741
+ return md;
742
+ }
743
+
661
744
  // --- МОДАЛЬНОЕ ОКНО ДЕТАЛЕЙ (MODAL DETAILS) ---
662
745
  function GoalDetailsModal({ state, onClose, onAction, t }) {
663
746
  if (!state) return null;
664
747
 
665
748
  const milestones = state.milestones || [];
666
749
  const isCompleted = state.state === 'COMPLETED';
750
+ const [copied, setCopied] = useState(false);
751
+
752
+ const totalTokens = state.tokensUsage?.totalTokens || 0;
753
+ const promptTokens = state.tokensUsage?.promptTokens || 0;
754
+ const compTokens = state.tokensUsage?.completionTokens || 0;
755
+ const tokensFormatted = totalTokens > 0
756
+ ? (totalTokens >= 1000 ? `${(totalTokens / 1000).toFixed(1)}k` : `${totalTokens}`)
757
+ : '—';
758
+ const tokensTooltip = totalTokens > 0
759
+ ? `Prompt: ${promptTokens.toLocaleString()} | Completion: ${compTokens.toLocaleString()} | Total: ${totalTokens.toLocaleString()}`
760
+ : 'Токены ещё не зафиксированы';
761
+
762
+ const handleCopyReport = () => {
763
+ const md = generateMarkdownReport(state);
764
+ if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
765
+ navigator.clipboard.writeText(md).then(() => {
766
+ setCopied(true);
767
+ setTimeout(() => setCopied(false), 2500);
768
+ }).catch((err) => {
769
+ console.warn('[dsh-goal] Clipboard copy failed:', err);
770
+ });
771
+ }
772
+ };
667
773
 
668
774
  return React.createElement(
669
775
  'div',
@@ -713,6 +819,18 @@ window.__ModuleLoader__.load({
713
819
  React.createElement('span', { className: 'dsh-goal-stat-val' }, state.formattedElapsed || '0s'),
714
820
  React.createElement('span', { className: 'dsh-goal-stat-lbl' }, t('time') || 'Время работы'),
715
821
  ),
822
+ React.createElement(
823
+ 'div',
824
+ { className: 'dsh-goal-stat-box' },
825
+ React.createElement('span', { className: 'dsh-goal-stat-val' }, state.formattedETA || (isCompleted ? 'Готово' : '—')),
826
+ React.createElement('span', { className: 'dsh-goal-stat-lbl' }, t('eta') || 'Оценка (ETA)'),
827
+ ),
828
+ React.createElement(
829
+ 'div',
830
+ { className: 'dsh-goal-stat-box', title: tokensTooltip },
831
+ React.createElement('span', { className: 'dsh-goal-stat-val' }, tokensFormatted),
832
+ React.createElement('span', { className: 'dsh-goal-stat-lbl' }, t('tokens') || 'Токены'),
833
+ ),
716
834
  React.createElement(
717
835
  'div',
718
836
  { className: 'dsh-goal-stat-box' },
@@ -760,6 +878,17 @@ window.__ModuleLoader__.load({
760
878
  React.createElement(
761
879
  'div',
762
880
  { className: 'dsh-goal-modal-foot' },
881
+ isCompleted
882
+ ? React.createElement(
883
+ 'button',
884
+ {
885
+ className: `dsh-goal-btn dsh-goal-btn-copy${copied ? ' copied' : ''}`,
886
+ onClick: handleCopyReport,
887
+ title: t('copyReport') || 'Скопировать отчёт в Markdown',
888
+ },
889
+ copied ? (t('reportCopied') || '✅ Скопировано!') : (t('copyReport') || '📋 Скопировать отчёт в Markdown'),
890
+ )
891
+ : null,
763
892
  isCompleted
764
893
  ? React.createElement(
765
894
  'button',
@@ -786,6 +915,98 @@ window.__ModuleLoader__.load({
786
915
  );
787
916
  }
788
917
 
918
+ // --- МОДАЛЬНОЕ ОКНО БЫСТРОГО ЗАПУСКА ЦЕЛИ (QUICK LAUNCH MODAL) ---
919
+ function QuickLaunchModal({ onClose, onStart, t }) {
920
+ const [title, setTitle] = useState('');
921
+ const inputRef = useRef(null);
922
+
923
+ useEffect(() => {
924
+ if (inputRef.current) inputRef.current.focus();
925
+ }, []);
926
+
927
+ const handleSubmit = (e) => {
928
+ if (e) e.preventDefault();
929
+ const trimmed = title.trim();
930
+ if (trimmed) {
931
+ onStart(trimmed);
932
+ }
933
+ };
934
+
935
+ return React.createElement(
936
+ 'div',
937
+ { className: 'dsh-goal-modal-overlay', onClick: onClose },
938
+ React.createElement(
939
+ 'div',
940
+ {
941
+ className: 'dsh-goal-modal',
942
+ style: { maxWidth: 480 },
943
+ onClick: (e) => e.stopPropagation(),
944
+ },
945
+ React.createElement(
946
+ 'div',
947
+ { className: 'dsh-goal-modal-head' },
948
+ React.createElement(
949
+ 'div',
950
+ { className: 'dsh-goal-modal-title' },
951
+ React.createElement(IconTarget, { size: 18 }),
952
+ t('quickLaunchTitle') || 'Быстрый запуск цели',
953
+ ),
954
+ React.createElement(
955
+ 'button',
956
+ { className: 'dsh-goal-btn icon-only close', onClick: onClose, title: t('close') || 'Закрыть' },
957
+ '✕',
958
+ ),
959
+ ),
960
+ React.createElement(
961
+ 'form',
962
+ { onSubmit: handleSubmit },
963
+ React.createElement(
964
+ 'div',
965
+ { className: 'dsh-goal-modal-body' },
966
+ React.createElement(
967
+ 'div',
968
+ { style: { fontSize: 13, color: 'var(--dsw-alias-label-secondary)', marginBottom: 8 } },
969
+ t('quickLaunchDesc') || 'Сформулируйте задачу для агента в автономном режиме:',
970
+ ),
971
+ React.createElement('input', {
972
+ ref: inputRef,
973
+ type: 'text',
974
+ className: 'dsh-goal-card-input',
975
+ style: { width: '100%', boxSizing: 'border-box', fontSize: 13, padding: '8px 10px' },
976
+ placeholder: t('quickLaunchPlaceholder') || 'Например: Реализовать экспорт отчетов и покрыть тестами...',
977
+ value: title,
978
+ onChange: (e) => setTitle(e.target.value),
979
+ onKeyDown: (e) => {
980
+ if (e.key === 'Enter' && !e.shiftKey) {
981
+ e.preventDefault();
982
+ handleSubmit();
983
+ }
984
+ },
985
+ }),
986
+ ),
987
+ React.createElement(
988
+ 'div',
989
+ { className: 'dsh-goal-modal-foot' },
990
+ React.createElement(
991
+ 'button',
992
+ { type: 'button', className: 'dsh-goal-btn', onClick: onClose },
993
+ t('close') || 'Отмена',
994
+ ),
995
+ React.createElement(
996
+ 'button',
997
+ {
998
+ type: 'submit',
999
+ className: 'dsh-goal-btn dsh-goal-btn-primary',
1000
+ disabled: !title.trim(),
1001
+ },
1002
+ t('startGoal') || 'Запустить цель',
1003
+ ),
1004
+ ),
1005
+ ),
1006
+ ),
1007
+ );
1008
+ }
1009
+
789
1010
  function sessionIdOf(ctx, props) {
790
1011
  try {
791
1012
  if (props?.session?.id) return String(props.session.id);
@@ -809,6 +1030,7 @@ window.__ModuleLoader__.load({
809
1030
  const sid = sessionIdOf(ctx, props);
810
1031
  const [state, setState] = useState(null);
811
1032
  const [isModalOpen, setIsModalOpen] = useState(false);
1033
+ const [isQuickLaunchOpen, setIsQuickLaunchOpen] = useState(false);
812
1034
  const [now, setNow] = useState(() => Date.now());
813
1035
  const stateRef = useRef(null);
814
1036
  stateRef.current = state;
@@ -1018,8 +1240,38 @@ window.__ModuleLoader__.load({
1018
1240
  } catch (_) {}
1019
1241
  };
1020
1242
 
1243
+ const showQuickLaunch = state ? (state.showQuickLaunchButton !== false) : true;
1244
+
1021
1245
  if (!state || !state.hasActiveGoal) {
1022
- return null;
1246
+ if (!showQuickLaunch) return null;
1247
+ return React.createElement(
1248
+ React.Fragment,
1249
+ null,
1250
+ React.createElement(
1251
+ 'div',
1252
+ { className: 'dsh-goal-dock dsh-goal-dock-quick', 'data-goal-dock': 'true' },
1253
+ React.createElement(
1254
+ 'button',
1255
+ {
1256
+ className: 'dsh-goal-quicklaunch-btn',
1257
+ title: t('quickLaunch') || 'Запустить цель (Goal Mode)',
1258
+ onClick: () => setIsQuickLaunchOpen(true),
1259
+ },
1260
+ React.createElement(IconTarget, { size: 14 }),
1261
+ React.createElement('span', null, t('quickLaunch') || 'Запустить цель'),
1262
+ ),
1263
+ ),
1264
+ isQuickLaunchOpen
1265
+ ? React.createElement(QuickLaunchModal, {
1266
+ onClose: () => setIsQuickLaunchOpen(false),
1267
+ onStart: async (title) => {
1268
+ await handleAction('start', { title });
1269
+ setIsQuickLaunchOpen(false);
1270
+ },
1271
+ t,
1272
+ })
1273
+ : null,
1274
+ );
1023
1275
  }
1024
1276
 
1025
1277
  const isPaused = state.state === 'PAUSED';
@@ -1034,6 +1286,7 @@ window.__ModuleLoader__.load({
1034
1286
  };
1035
1287
 
1036
1288
  const liveElapsedStr = formatLiveElapsed();
1289
+ const etaStr = state?.formattedETA ? ` (ETA ${state.formattedETA})` : '';
1037
1290
 
1038
1291
  return React.createElement(
1039
1292
  React.Fragment,
@@ -1067,7 +1320,7 @@ window.__ModuleLoader__.load({
1067
1320
  React.createElement(
1068
1321
  'span',
1069
1322
  { className: 'dsh-goal-time' },
1070
- `⏱ ${liveElapsedStr}`,
1323
+ `⏱ ${liveElapsedStr}${etaStr}`,
1071
1324
  ),
1072
1325
  !isCompleted
1073
1326
  ? React.createElement(
@@ -1119,6 +1372,7 @@ window.__ModuleLoader__.load({
1119
1372
  maxIterations: 25,
1120
1373
  autoDrive: true,
1121
1374
  enableSound: true,
1375
+ showQuickLaunchButton: true,
1122
1376
  };
1123
1377
 
1124
1378
  function parseNumberField(raw) {
@@ -1177,7 +1431,7 @@ window.__ModuleLoader__.load({
1177
1431
  function computeSavePlan(draft, snap) {
1178
1432
  if (!draft) return [];
1179
1433
 
1180
- const fields = ['maxIterations', 'autoDrive', 'enableSound'];
1434
+ const fields = ['maxIterations', 'autoDrive', 'enableSound', 'showQuickLaunchButton'];
1181
1435
  const writes = [];
1182
1436
 
1183
1437
  for (const f of fields) {
@@ -1234,8 +1488,9 @@ window.__ModuleLoader__.load({
1234
1488
  const maxIterStatus = getFieldStatus('maxIterations', draft?.maxIterations, snap);
1235
1489
  const autoDriveStatus = getFieldStatus('autoDrive', draft?.autoDrive, snap);
1236
1490
  const enableSoundStatus = getFieldStatus('enableSound', draft?.enableSound, snap);
1491
+ const quickLaunchStatus = getFieldStatus('showQuickLaunchButton', draft?.showQuickLaunchButton, snap);
1237
1492
 
1238
- const dirty = maxIterStatus.isDirty || autoDriveStatus.isDirty || enableSoundStatus.isDirty;
1493
+ const dirty = maxIterStatus.isDirty || autoDriveStatus.isDirty || enableSoundStatus.isDirty || quickLaunchStatus.isDirty;
1239
1494
  const invalid = maxIterStatus.invalid;
1240
1495
  const disabled = !scope || saving || snap?.writable === false;
1241
1496
 
@@ -1245,6 +1500,7 @@ window.__ModuleLoader__.load({
1245
1500
  maxIterations: prev?.maxIterations !== undefined ? prev.maxIterations : maxIterStatus.value,
1246
1501
  autoDrive: prev?.autoDrive !== undefined ? prev.autoDrive : autoDriveStatus.value,
1247
1502
  enableSound: prev?.enableSound !== undefined ? prev.enableSound : enableSoundStatus.value,
1503
+ showQuickLaunchButton: prev?.showQuickLaunchButton !== undefined ? prev.showQuickLaunchButton : quickLaunchStatus.value,
1248
1504
  [field]: val,
1249
1505
  }));
1250
1506
  };
@@ -1418,6 +1674,33 @@ window.__ModuleLoader__.load({
1418
1674
  )
1419
1675
  : null,
1420
1676
  ),
1677
+ React.createElement(
1678
+ 'div',
1679
+ { className: 'dsh-goal-check-row' },
1680
+ React.createElement(
1681
+ 'label',
1682
+ { className: 'dsh-goal-check' },
1683
+ React.createElement('input', {
1684
+ type: 'checkbox',
1685
+ checked: quickLaunchStatus.value !== false,
1686
+ disabled,
1687
+ onChange: (e) => edit('showQuickLaunchButton', e.target.checked),
1688
+ }),
1689
+ t('quickLaunchLabel') || 'Кнопка быстрого запуска цели над полем ввода',
1690
+ ),
1691
+ quickLaunchStatus.isOverridden
1692
+ ? React.createElement(
1693
+ 'button',
1694
+ {
1695
+ type: 'button',
1696
+ className: 'dsh-goal-btn-inline-reset',
1697
+ title: t('resetField') || 'Сбросить к значению по умолчанию',
1698
+ onClick: () => resetFieldToDefault('showQuickLaunchButton'),
1699
+ },
1700
+ React.createElement(IconRotateCcw, { size: 12 }),
1701
+ )
1702
+ : null,
1703
+ ),
1421
1704
  React.createElement(
1422
1705
  'div',
1423
1706
  { className: 'dsh-goal-foot' },
@@ -1461,6 +1744,8 @@ window.__ModuleLoader__.load({
1461
1744
  modalTitle: 'План и статус цели',
1462
1745
  modalTitleCompleted: 'Цель выполнена: план и результаты',
1463
1746
  time: 'Время работы',
1747
+ eta: 'Оценка (ETA)',
1748
+ tokens: 'Токены',
1464
1749
  milestones: 'План работ:',
1465
1750
  noMilestones: 'Агент формирует план работ...',
1466
1751
  close: 'Закрыть',
@@ -1469,6 +1754,14 @@ window.__ModuleLoader__.load({
1469
1754
  maxIterLabel: 'Максимум итераций (Safety Limit):',
1470
1755
  autoDriveLabel: 'Авто-драйв: продолжать цикл автоматически',
1471
1756
  soundLabel: 'Звук по завершении цели',
1757
+ quickLaunchLabel: 'Кнопка быстрого запуска цели над полем ввода',
1758
+ quickLaunch: 'Запустить цель',
1759
+ quickLaunchTitle: 'Быстрый запуск цели',
1760
+ quickLaunchDesc: 'Сформулируйте задачу для агента в автономном режиме:',
1761
+ quickLaunchPlaceholder: 'Например: Реализовать экспорт отчетов и покрыть тестами...',
1762
+ startGoal: 'Запустить цель',
1763
+ copyReport: '📋 Скопировать отчёт в Markdown',
1764
+ reportCopied: '✅ Скопировано!',
1472
1765
  save: 'Сохранить',
1473
1766
  saving: 'Сохранение…',
1474
1767
  discard: 'Отменить правки',
@@ -1489,6 +1782,8 @@ window.__ModuleLoader__.load({
1489
1782
  modalTitle: 'Goal Plan & Status',
1490
1783
  modalTitleCompleted: 'Goal Completed: Plan & Results',
1491
1784
  time: 'Running time',
1785
+ eta: 'ETA',
1786
+ tokens: 'Tokens',
1492
1787
  milestones: 'Plan of work:',
1493
1788
  noMilestones: 'Agent is preparing the plan of work...',
1494
1789
  close: 'Close',
@@ -1497,6 +1792,14 @@ window.__ModuleLoader__.load({
1497
1792
  maxIterLabel: 'Max iterations (Safety Limit):',
1498
1793
  autoDriveLabel: 'Auto-drive: keep the loop running automatically',
1499
1794
  soundLabel: 'Sound when a goal completes',
1795
+ quickLaunchLabel: 'Quick launch goal button above composer dock',
1796
+ quickLaunch: 'Start Goal',
1797
+ quickLaunchTitle: 'Quick Launch Goal',
1798
+ quickLaunchDesc: 'Define the objective for the agent in autonomous mode:',
1799
+ quickLaunchPlaceholder: 'e.g. Implement report export and cover with unit tests...',
1800
+ startGoal: 'Start Goal',
1801
+ copyReport: '📋 Copy Report in Markdown',
1802
+ reportCopied: '✅ Copied!',
1500
1803
  save: 'Save',
1501
1804
  saving: 'Saving…',
1502
1805
  discard: 'Discard changes',
@@ -76,12 +76,18 @@ export function executeGoalSlashCommand(engine, parsed, config = {}, agent = nul
76
76
  ? `\nПлан работ:\n` + snap.milestones.map((m, i) => ` ${i + 1}. [${m.status}] ${m.title}`).join('\n')
77
77
  : '';
78
78
 
79
+ const etaText = snap.formattedETA ? ` (ETA: ${snap.formattedETA})` : '';
80
+ const tokensInfo = (snap.tokensUsage && snap.tokensUsage.totalTokens > 0)
81
+ ? `\nТокены: ${snap.tokensUsage.totalTokens.toLocaleString()} (in: ${snap.tokensUsage.promptTokens.toLocaleString()}, out: ${snap.tokensUsage.completionTokens.toLocaleString()})`
82
+ : '';
83
+
79
84
  return {
80
85
  kind: 'success',
81
86
  text: `🎯 Цель: «${snap.title}»\n` +
82
87
  `Статус: ${snap.state}\n` +
83
- `Время: ${snap.formattedElapsed}\n` +
88
+ `Время: ${snap.formattedElapsed}${etaText}\n` +
84
89
  `Итераций: ${snap.iterationsCount}/${snap.maxIterations}` +
90
+ tokensInfo +
85
91
  milestonesInfo +
86
92
  `\n\nКоманды: /goal pause, /goal resume, /goal clear, /goal <новая цель>`,
87
93
  };
@@ -42,11 +42,28 @@ export function formatElapsed(totalSeconds) {
42
42
  return remainingMins > 0 ? `${hours}h ${remainingMins}m` : `${hours}h`;
43
43
  }
44
44
 
45
+ /**
46
+ * Форматирование расчетного оставшегося времени (ETA)
47
+ * @param {number|null} seconds
48
+ * @returns {string|null}
49
+ */
50
+ export function formatETA(seconds) {
51
+ if (seconds == null || isNaN(seconds)) return null;
52
+ const sec = Math.max(0, Math.floor(seconds));
53
+ if (sec < 60) return `~${sec}s`;
54
+ const mins = Math.round(sec / 60);
55
+ if (mins < 60) return `~${mins}m`;
56
+ const hours = Math.floor(mins / 60);
57
+ const remainingMins = mins % 60;
58
+ return remainingMins > 0 ? `~${hours}h ${remainingMins}m` : `~${hours}h`;
59
+ }
60
+
45
61
  export class GoalEngine {
46
62
  constructor(options = {}) {
47
63
  this.defaultMaxIterations = options.defaultMaxIterations ?? 25;
48
64
  this.autoDrive = options.autoDrive ?? true;
49
65
  this.enableSound = options.enableSound ?? true;
66
+ this.showQuickLaunchButton = options.showQuickLaunchButton ?? true;
50
67
  this.maxSessions = options.maxSessions ?? 100;
51
68
  this.goals = new Map();
52
69
  this.listeners = new Set();
@@ -96,6 +113,9 @@ export class GoalEngine {
96
113
  if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
97
114
  dirty = true;
98
115
  }
116
+ if (!goal.tokensUsage) {
117
+ goal.tokensUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
118
+ }
99
119
  this.goals.set(sid, goal);
100
120
  }
101
121
  }
@@ -112,6 +132,9 @@ export class GoalEngine {
112
132
  if (data.logs.length > 100) data.logs = data.logs.slice(-100);
113
133
  dirty = true;
114
134
  }
135
+ if (!data.tokensUsage) {
136
+ data.tokensUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
137
+ }
115
138
  this.goals.set('default', data);
116
139
  }
117
140
  if (dirty) {
@@ -251,6 +274,9 @@ export class GoalEngine {
251
274
  if (typeof config.enableSound === 'boolean') {
252
275
  this.enableSound = config.enableSound;
253
276
  }
277
+ if (typeof config.showQuickLaunchButton === 'boolean') {
278
+ this.showQuickLaunchButton = config.showQuickLaunchButton;
279
+ }
254
280
  this.emit();
255
281
  }
256
282
 
@@ -312,6 +338,7 @@ export class GoalEngine {
312
338
  iterationsCount: 0,
313
339
  maxIterations: options.maxIterations ?? this.defaultMaxIterations,
314
340
  milestones: [],
341
+ tokensUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
315
342
  logs: [
316
343
  {
317
344
  timestamp: now,
@@ -561,6 +588,57 @@ export class GoalEngine {
561
588
  return Math.floor(elapsedMs / 1000);
562
589
  }
563
590
 
591
+ /**
592
+ * Накопление статистики использования токенов сессии
593
+ * @param {Object} usage
594
+ * @param {string} [sessionId='default']
595
+ */
596
+ addTokenUsage(usage, sessionId = 'default') {
597
+ if (!usage || typeof usage !== 'object') return;
598
+ const sid = sessionId || 'default';
599
+ const goal = this.goals.get(sid);
600
+ if (!goal) return;
601
+
602
+ if (!goal.tokensUsage) {
603
+ goal.tokensUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
604
+ }
605
+
606
+ const prompt = Number(usage.promptTokens ?? usage.input_tokens ?? usage.prompt_tokens ?? 0) || 0;
607
+ const completion = Number(usage.completionTokens ?? usage.output_tokens ?? usage.completion_tokens ?? 0) || 0;
608
+ const total = Number(usage.totalTokens ?? usage.total_tokens ?? (prompt + completion)) || (prompt + completion);
609
+
610
+ goal.tokensUsage.promptTokens += prompt;
611
+ goal.tokensUsage.completionTokens += completion;
612
+ goal.tokensUsage.totalTokens += total;
613
+
614
+ this.emit(sid);
615
+ }
616
+
617
+ /**
618
+ * Интеллектуальный расчет прогноза оставшегося времени (ETA)
619
+ * на основе средней скорости выполнения завершенных вех
620
+ * @param {string} [sessionId='default']
621
+ * @returns {number|null}
622
+ */
623
+ getEstimatedRemainingSeconds(sessionId = 'default') {
624
+ const sid = sessionId || 'default';
625
+ const goal = this.goals.get(sid);
626
+ if (!goal || goal.state !== GoalState.RUNNING) return null;
627
+
628
+ const total = goal.milestones.length;
629
+ if (total === 0) return null;
630
+
631
+ const completedCount = goal.milestones.filter((m) => m.status === MilestoneStatus.COMPLETED).length;
632
+ if (completedCount === 0 || completedCount >= total) return null;
633
+
634
+ const elapsed = this.getElapsedSeconds(sid);
635
+ if (elapsed <= 0) return null;
636
+
637
+ const avgSecPerMilestone = elapsed / completedCount;
638
+ const remainingCount = total - completedCount;
639
+ return Math.max(1, Math.round(avgSecPerMilestone * remainingCount));
640
+ }
641
+
564
642
  /**
565
643
  * Снимок состояния для передачи клиенту / API
566
644
  */
@@ -579,12 +657,16 @@ export class GoalEngine {
579
657
  completedAt: null,
580
658
  elapsedSeconds: 0,
581
659
  formattedElapsed: '0s',
660
+ estimatedRemainingSeconds: null,
661
+ formattedETA: null,
662
+ tokensUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
582
663
  milestones: [],
583
664
  progressPercent: 0,
584
665
  iterationsCount: 0,
585
666
  maxIterations: this.defaultMaxIterations,
586
667
  autoDrive: this.autoDrive,
587
668
  enableSound: this.enableSound,
669
+ showQuickLaunchButton: this.showQuickLaunchButton,
588
670
  };
589
671
  }
590
672
 
@@ -592,6 +674,7 @@ export class GoalEngine {
592
674
  const milestones = goal.milestones;
593
675
  const completedCount = milestones.filter((m) => m.status === MilestoneStatus.COMPLETED).length;
594
676
  const progressPercent = milestones.length > 0 ? Math.round((completedCount / milestones.length) * 100) : 0;
677
+ const estSec = this.getEstimatedRemainingSeconds(sid);
595
678
 
596
679
  return {
597
680
  sessionId: sid,
@@ -606,6 +689,9 @@ export class GoalEngine {
606
689
  completedAt: goal.completedAt,
607
690
  elapsedSeconds: elapsed,
608
691
  formattedElapsed: formatElapsed(elapsed),
692
+ estimatedRemainingSeconds: estSec,
693
+ formattedETA: formatETA(estSec),
694
+ tokensUsage: goal.tokensUsage || { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
609
695
  iterationsCount: goal.iterationsCount,
610
696
  maxIterations: goal.maxIterations,
611
697
  milestones,
@@ -614,6 +700,7 @@ export class GoalEngine {
614
700
  resultSummary: goal.resultSummary,
615
701
  autoDrive: this.autoDrive,
616
702
  enableSound: this.enableSound,
703
+ showQuickLaunchButton: this.showQuickLaunchButton,
617
704
  };
618
705
  }
619
706
 
@@ -633,9 +720,11 @@ export class GoalEngine {
633
720
  ? snapshot.milestones.map((m, i) => ` ${i + 1}. [${m.status.toUpperCase()}] ${m.title}${m.notes ? ` (${m.notes})` : ''}`).join('\n')
634
721
  : ' (План работ ещё не сформирован — немедленно вызови goal_set_milestones со списком шагов!)';
635
722
 
723
+ const etaText = snapshot.formattedETA ? ` (ETA: ${snapshot.formattedETA})` : '';
724
+
636
725
  return `\n\n[DSH GOAL MODE ACTIVE]
637
726
  Цель: "${snapshot.title}"
638
- Время работы: ${snapshot.formattedElapsed} | Итерация: ${snapshot.iterationsCount}/${snapshot.maxIterations}
727
+ Время работы: ${snapshot.formattedElapsed}${etaText} | Итерация: ${snapshot.iterationsCount}/${snapshot.maxIterations}
639
728
  План работ:
640
729
  ${milestonesText}
641
730
 
package/lib/index.js CHANGED
@@ -42,6 +42,7 @@ export const Config = z.object({
42
42
  maxIterations: z.number().default(25).description('Safety limit: max autonomous iterations per goal'),
43
43
  autoDrive: z.boolean().default(true).description('Automatically continue the loop after each turn'),
44
44
  enableSound: z.boolean().default(true).description('Play synthesized audio chime on goal completion or failure'),
45
+ showQuickLaunchButton: z.boolean().default(true).description('Show quick launch goal button above composer dock'),
45
46
  storagePath: z.string().default('').description('Custom filesystem path for persistent state storage'),
46
47
  });
47
48
 
@@ -59,6 +60,7 @@ export function apply(ctx, config = {}) {
59
60
  maxIterations: config?.maxIterations ?? 25,
60
61
  autoDrive: config?.autoDrive ?? true,
61
62
  enableSound: config?.enableSound ?? true,
63
+ showQuickLaunchButton: config?.showQuickLaunchButton ?? true,
62
64
  storagePath: config?.storagePath,
63
65
  };
64
66
 
@@ -71,6 +73,7 @@ export function apply(ctx, config = {}) {
71
73
  defaultMaxIterations: currentSettings.maxIterations,
72
74
  autoDrive: currentSettings.autoDrive,
73
75
  enableSound: currentSettings.enableSound,
76
+ showQuickLaunchButton: currentSettings.showQuickLaunchButton,
74
77
  storagePath,
75
78
  });
76
79
 
@@ -215,6 +218,7 @@ export function apply(ctx, config = {}) {
215
218
  maxIterations: typeof live.maxIterations === 'number' ? live.maxIterations : currentSettings.maxIterations,
216
219
  autoDrive: typeof live.autoDrive === 'boolean' ? live.autoDrive : currentSettings.autoDrive,
217
220
  enableSound: typeof live.enableSound === 'boolean' ? live.enableSound : currentSettings.enableSound,
221
+ showQuickLaunchButton: typeof live.showQuickLaunchButton === 'boolean' ? live.showQuickLaunchButton : currentSettings.showQuickLaunchButton,
218
222
  storagePath: typeof live.storagePath === 'string' ? live.storagePath : currentSettings.storagePath,
219
223
  };
220
224
  }
@@ -226,6 +230,7 @@ export function apply(ctx, config = {}) {
226
230
  maxIterations: typeof live.maxIterations === 'number' ? live.maxIterations : currentSettings.maxIterations,
227
231
  autoDrive: typeof live.autoDrive === 'boolean' ? live.autoDrive : currentSettings.autoDrive,
228
232
  enableSound: typeof live.enableSound === 'boolean' ? live.enableSound : currentSettings.enableSound,
233
+ showQuickLaunchButton: typeof live.showQuickLaunchButton === 'boolean' ? live.showQuickLaunchButton : currentSettings.showQuickLaunchButton,
229
234
  storagePath: typeof live.storagePath === 'string' ? live.storagePath : currentSettings.storagePath,
230
235
  };
231
236
  }
@@ -240,6 +245,7 @@ export function apply(ctx, config = {}) {
240
245
  defaultMaxIterations: live.maxIterations,
241
246
  autoDrive: live.autoDrive,
242
247
  enableSound: live.enableSound,
248
+ showQuickLaunchButton: live.showQuickLaunchButton,
243
249
  });
244
250
  };
245
251
 
@@ -536,6 +542,7 @@ export function apply(ctx, config = {}) {
536
542
  description: typeof description === 'string' ? description.trim() : '',
537
543
  maxIterations: getConfig().maxIterations,
538
544
  }, sid);
545
+ resumeActiveAgent(`🎯 Цель установлена: "${cleanTitle}". Немедленно сформируй план работ (3-7 конкретных шагов) через инструмент goal_set_milestones и начни его выполнение.`, sid);
539
546
  break;
540
547
  }
541
548
  case 'pause':
@@ -615,6 +622,13 @@ export function apply(ctx, config = {}) {
615
622
  if (!liveConfig.autoDrive) return; // Учитываем настройку autoDrive в рантайме
616
623
 
617
624
  const sid = sessionIdOf(turn, 'default');
625
+
626
+ // Накопление токенов по завершении хода
627
+ const usage = turn?.usage || turn?.meta?.usage || turn?.response?.usage || turn?.turn?.usage;
628
+ if (usage) {
629
+ engine.addTokenUsage(usage, sid);
630
+ }
631
+
618
632
  const snap = engine.getSnapshot(sid);
619
633
  if (snap.hasActiveGoal && snap.state === GoalState.RUNNING) {
620
634
  // Проверяем причину завершения хода
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-goal",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Goal mode & autonomous execution plugin for DeepSeek Harness with sticky top banner",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",