@goodandready/dsh-goal 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,780 +1,988 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import os from 'node:os';
4
-
5
- /**
6
- * Изолированное ядро управления состоянием цели (Goal Engine).
7
- * Не имеет внешних зависимостей, 100% тестируемо через node --test.
8
- */
9
-
10
- export const GoalState = {
11
- IDLE: 'IDLE',
12
- PLANNING: 'PLANNING',
13
- RUNNING: 'RUNNING',
14
- PAUSED: 'PAUSED',
15
- COMPLETED: 'COMPLETED',
16
- FAILED: 'FAILED',
17
- CANCELLED: 'CANCELLED',
18
- };
19
-
20
- export const MilestoneStatus = {
21
- PENDING: 'pending',
22
- IN_PROGRESS: 'in_progress',
23
- COMPLETED: 'completed',
24
- FAILED: 'failed',
25
- };
26
-
27
- /**
28
- * Форматирование времени в лаконичную строку (например: "2s", "45s", "1m 15s", "2h 5m")
29
- * @param {number} totalSeconds
30
- * @returns {string}
31
- */
32
- export function formatElapsed(totalSeconds) {
33
- const sec = Math.max(0, Math.floor(totalSeconds));
34
- if (sec < 60) return `${sec}s`;
35
- const mins = Math.floor(sec / 60);
36
- const remainingSec = sec % 60;
37
- if (mins < 60) {
38
- return remainingSec > 0 ? `${mins}m ${remainingSec}s` : `${mins}m`;
39
- }
40
- const hours = Math.floor(mins / 60);
41
- const remainingMins = mins % 60;
42
- return remainingMins > 0 ? `${hours}h ${remainingMins}m` : `${hours}h`;
43
- }
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
-
61
- /**
62
- * Автоматическое определение языка текста (кириллица -> ru, иероглифы -> zh, иначе en)
63
- * @param {string} text
64
- * @param {string} [fallback='en']
65
- * @returns {'ru' | 'en' | 'zh'}
66
- */
67
- export function detectLanguage(text, fallback = 'en') {
68
- if (!text || typeof text !== 'string') return fallback;
69
- if (/[а-яёА-ЯЁ]/i.test(text)) {
70
- return 'ru';
71
- }
72
- if (/[\u4e00-\u9fa5]/.test(text)) {
73
- return 'zh';
74
- }
75
- return 'en';
76
- }
77
-
78
- export class GoalEngine {
79
- constructor(options = {}) {
80
- this.defaultMaxIterations = options.defaultMaxIterations ?? 25;
81
- this.autoDrive = options.autoDrive ?? true;
82
- this.enableSound = options.enableSound ?? true;
83
- this.showQuickLaunchButton = options.showQuickLaunchButton ?? true;
84
- this.maxSessions = options.maxSessions ?? 100;
85
- this.goals = new Map();
86
- this.listeners = new Set();
87
- this.saveTimer = null;
88
- this.stallCounters = new Map(); // sessionId -> number of consecutive turns without progress
89
-
90
- const defaultStorageDir = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
91
- this.storagePath = options.storagePath ?? null;
92
-
93
- this.loadStateFromDisk();
94
- }
95
-
96
- get currentGoal() {
97
- return this.goals.get('default') || null;
98
- }
99
-
100
- set currentGoal(val) {
101
- if (val) {
102
- this.goals.set('default', val);
103
- } else {
104
- this.goals.delete('default');
105
- }
106
- }
107
-
108
- loadStateFromDisk() {
109
- if (!this.storagePath) return;
110
- try {
111
- if (fs.existsSync(this.storagePath)) {
112
- const raw = fs.readFileSync(this.storagePath, 'utf8');
113
- const data = JSON.parse(raw);
114
- if (data && typeof data === 'object') {
115
- let dirty = false;
116
- if (data.sessions && typeof data.sessions === 'object') {
117
- for (const [sid, goal] of Object.entries(data.sessions)) {
118
- if (goal && goal.id && goal.title) {
119
- // Item 4: Crash Hydration — если цель осталась в RUNNING после перезапуска/падения DSH,
120
- // переводим в PAUSED с понятной причиной и фиксацией времени
121
- if (goal.state === GoalState.RUNNING) {
122
- goal.state = GoalState.PAUSED;
123
- goal.pausedAt = Date.now();
124
- if (!Array.isArray(goal.logs)) goal.logs = [];
125
- goal.logs.push({
126
- timestamp: Date.now(),
127
- type: 'warning',
128
- message: 'Harness was restarted — click ▶️ to resume',
129
- });
130
- if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
131
- dirty = true;
132
- }
133
- if (!goal.tokensUsage) {
134
- goal.tokensUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
135
- }
136
- if (!goal.lang) {
137
- goal.lang = detectLanguage(goal.title);
138
- }
139
- this.goals.set(sid, goal);
140
- }
141
- }
142
- } else if (data.id && data.title) {
143
- if (data.state === GoalState.RUNNING) {
144
- data.state = GoalState.PAUSED;
145
- data.pausedAt = Date.now();
146
- if (!Array.isArray(data.logs)) data.logs = [];
147
- data.logs.push({
148
- timestamp: Date.now(),
149
- type: 'warning',
150
- message: 'Harness was restarted click ▶️ to resume',
151
- });
152
- if (data.logs.length > 100) data.logs = data.logs.slice(-100);
153
- dirty = true;
154
- }
155
- if (!data.tokensUsage) {
156
- data.tokensUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
157
- }
158
- if (!data.lang) {
159
- data.lang = detectLanguage(data.title);
160
- }
161
- this.goals.set('default', data);
162
- }
163
- if (dirty) {
164
- this.scheduleSave(true);
165
- }
166
- }
167
- }
168
- } catch (err) {
169
- console.warn('[GoalEngine] Failed to load state from disk:', err);
170
- }
171
- }
172
-
173
- scheduleSave(immediate = false) {
174
- if (!this.storagePath) return;
175
- if (immediate) {
176
- if (this.saveTimer) {
177
- clearTimeout(this.saveTimer);
178
- this.saveTimer = null;
179
- }
180
- this.writeStateToDiskSync();
181
- return;
182
- }
183
- if (!this.saveTimer) {
184
- this.saveTimer = setTimeout(() => {
185
- this.saveTimer = null;
186
- this.writeStateToDiskSync();
187
- }, 250);
188
- if (typeof this.saveTimer.unref === 'function') {
189
- this.saveTimer.unref();
190
- }
191
- }
192
- }
193
-
194
- writeStateToDiskSync() {
195
- if (!this.storagePath) return;
196
- try {
197
- if (this.goals.size === 0) {
198
- if (fs.existsSync(this.storagePath)) {
199
- fs.unlinkSync(this.storagePath);
200
- }
201
- return;
202
- }
203
- const sessionsObj = {};
204
- for (const [sid, goal] of this.goals.entries()) {
205
- sessionsObj[sid] = goal;
206
- }
207
- const payload = {
208
- version: 2,
209
- sessions: sessionsObj,
210
- ...(this.goals.has('default') ? this.goals.get('default') : {}),
211
- };
212
- const dir = path.dirname(this.storagePath);
213
- if (!fs.existsSync(dir)) {
214
- fs.mkdirSync(dir, { recursive: true });
215
- }
216
- const tmp = `${this.storagePath}.tmp.${Date.now()}`;
217
- fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
218
- fs.renameSync(tmp, this.storagePath);
219
- } catch (err) {
220
- console.warn('[GoalEngine] Failed to write state to disk:', err);
221
- }
222
- }
223
-
224
- flushSync() {
225
- if (this.saveTimer) {
226
- clearTimeout(this.saveTimer);
227
- this.saveTimer = null;
228
- }
229
- this.writeStateToDiskSync();
230
- }
231
-
232
- saveStateToDisk() {
233
- this.flushSync();
234
- }
235
-
236
- /**
237
- * Подписка на изменение состояния
238
- * @param {Function} callback
239
- * @returns {Function} unsubscribe
240
- */
241
- subscribe(callback) {
242
- this.listeners.add(callback);
243
- return () => this.listeners.delete(callback);
244
- }
245
-
246
- emit(sessionId = 'default', immediate = false) {
247
- this.scheduleSave(immediate);
248
- const sid = sessionId || 'default';
249
- const snapshot = this.getSnapshot(sid);
250
- for (const listener of this.listeners) {
251
- try {
252
- listener(snapshot, sid);
253
- } catch (err) {
254
- console.error('[GoalEngine] Listener error:', err);
255
- }
256
- }
257
- }
258
-
259
- /**
260
- * Отслеживание прогресса для Smart Progress Guard
261
- */
262
- recordProgress(sessionId = 'default') {
263
- const sid = sessionId || 'default';
264
- this.stallCounters.set(sid, 0);
265
- }
266
-
267
- incrementStallCount(sessionId = 'default') {
268
- const sid = sessionId || 'default';
269
- const current = this.stallCounters.get(sid) || 0;
270
- const next = current + 1;
271
- this.stallCounters.set(sid, next);
272
- return next;
273
- }
274
-
275
- getStallCount(sessionId = 'default') {
276
- const sid = sessionId || 'default';
277
- return this.stallCounters.get(sid) || 0;
278
- }
279
-
280
- /**
281
- * Динамическое обновление настроек на лету
282
- * @param {Object} config
283
- */
284
- updateConfig(config = {}) {
285
- if (typeof config.defaultMaxIterations === 'number' && config.defaultMaxIterations >= 1) {
286
- const prev = this.defaultMaxIterations;
287
- this.defaultMaxIterations = config.defaultMaxIterations;
288
- for (const [_, goal] of this.goals) {
289
- if (goal && goal.maxIterations === prev) {
290
- goal.maxIterations = config.defaultMaxIterations;
291
- }
292
- }
293
- }
294
- if (typeof config.autoDrive === 'boolean') {
295
- this.autoDrive = config.autoDrive;
296
- }
297
- if (typeof config.enableSound === 'boolean') {
298
- this.enableSound = config.enableSound;
299
- }
300
- if (typeof config.showQuickLaunchButton === 'boolean') {
301
- this.showQuickLaunchButton = config.showQuickLaunchButton;
302
- }
303
- this.emit();
304
- }
305
-
306
- /**
307
- * Получение цели сессии
308
- */
309
- getGoal(sessionId = 'default') {
310
- return this.goals.get(sessionId || 'default') || null;
311
- }
312
-
313
- /**
314
- * Очистка старых неактивных сессий во избежание утечек памяти
315
- */
316
- pruneInactiveSessions() {
317
- if (this.goals.size < this.maxSessions) return;
318
- const inactive = [];
319
- for (const [sid, goal] of this.goals.entries()) {
320
- if (sid === 'default') continue;
321
- if (goal.state === GoalState.COMPLETED || goal.state === GoalState.CANCELLED || goal.state === GoalState.FAILED) {
322
- inactive.push({ sid, completedAt: goal.completedAt || goal.startedAt || 0 });
323
- }
324
- }
325
- inactive.sort((a, b) => a.completedAt - b.completedAt);
326
- while (this.goals.size >= this.maxSessions && inactive.length > 0) {
327
- const oldest = inactive.shift();
328
- this.goals.delete(oldest.sid);
329
- this.stallCounters.delete(oldest.sid);
330
- }
331
- }
332
-
333
- /**
334
- * Запуск новой цели
335
- * @param {string} title
336
- * @param {Object} options
337
- * @param {string} [sessionId='default']
338
- */
339
- startGoal(title, options = {}, sessionId = 'default') {
340
- if (!title || typeof title !== 'string' || !title.trim()) {
341
- throw new Error('Goal title cannot be empty');
342
- }
343
-
344
- this.pruneInactiveSessions();
345
-
346
- const cleanTitle = title.trim();
347
- const now = Date.now();
348
- const sid = sessionId || 'default';
349
- this.stallCounters.set(sid, 0);
350
-
351
- const goal = {
352
- id: `goal-${now}-${Math.random().toString(36).substring(2, 7)}`,
353
- sessionId: sid,
354
- title: cleanTitle,
355
- description: options.description?.trim() || '',
356
- lang: options.lang || detectLanguage(cleanTitle),
357
- state: GoalState.RUNNING,
358
- startedAt: now,
359
- pausedAt: null,
360
- totalPausedDurationMs: 0,
361
- completedAt: null,
362
- iterationsCount: 0,
363
- maxIterations: options.maxIterations ?? this.defaultMaxIterations,
364
- milestones: [],
365
- tokensUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
366
- logs: [
367
- {
368
- timestamp: now,
369
- type: 'info',
370
- message: `Goal initiated: "${cleanTitle}"`,
371
- },
372
- ],
373
- resultSummary: '',
374
- };
375
-
376
- this.goals.set(sid, goal);
377
-
378
- if (Array.isArray(options.milestones) && options.milestones.length > 0) {
379
- this.addMilestones(options.milestones, false, sid);
380
- }
381
-
382
- this.emit(sid, true);
383
- return this.getSnapshot(sid);
384
- }
385
-
386
- /**
387
- * Приостановка автономного цикла цели
388
- */
389
- pause(reason = 'User requested pause', sessionId = 'default') {
390
- const sid = sessionId || 'default';
391
- const goal = this.goals.get(sid);
392
- if (!goal || goal.state !== GoalState.RUNNING) {
393
- return this.getSnapshot(sid);
394
- }
395
-
396
- goal.state = GoalState.PAUSED;
397
- goal.pausedAt = Date.now();
398
- goal.logs.push({
399
- timestamp: Date.now(),
400
- type: 'warning',
401
- message: `Paused: ${reason}`,
402
- });
403
-
404
- if (goal.logs.length > 100) {
405
- goal.logs = goal.logs.slice(-100);
406
- }
407
-
408
- this.emit(sid, true);
409
- return this.getSnapshot(sid);
410
- }
411
-
412
- /**
413
- * Возобновление выполнения цели
414
- */
415
- resume(sessionId = 'default') {
416
- const sid = sessionId || 'default';
417
- const goal = this.goals.get(sid);
418
- if (!goal || goal.state !== GoalState.PAUSED) {
419
- return this.getSnapshot(sid);
420
- }
421
-
422
- const now = Date.now();
423
- if (goal.pausedAt) {
424
- goal.totalPausedDurationMs += now - goal.pausedAt;
425
- goal.pausedAt = null;
426
- }
427
-
428
- goal.state = GoalState.RUNNING;
429
- this.stallCounters.set(sid, 0); // сбрасываем счетчик простоя при возобновлении
430
-
431
- goal.logs.push({
432
- timestamp: now,
433
- type: 'info',
434
- message: 'Goal resumed',
435
- });
436
-
437
- if (goal.logs.length > 100) {
438
- goal.logs = goal.logs.slice(-100);
439
- }
440
-
441
- this.emit(sid, true);
442
- return this.getSnapshot(sid);
443
- }
444
-
445
- /**
446
- * Отмена цели
447
- */
448
- cancel(reason = 'Cancelled by user', sessionId = 'default') {
449
- const sid = sessionId || 'default';
450
- const goal = this.goals.get(sid);
451
- if (!goal) return null;
452
-
453
- goal.state = GoalState.CANCELLED;
454
- goal.completedAt = Date.now();
455
- goal.logs.push({
456
- timestamp: Date.now(),
457
- type: 'warning',
458
- message: `Cancelled: ${reason}`,
459
- });
460
-
461
- if (goal.logs.length > 100) {
462
- goal.logs = goal.logs.slice(-100);
463
- }
464
-
465
- this.emit(sid, true);
466
- return this.getSnapshot(sid);
467
- }
468
-
469
- /**
470
- * Очистка / сброс цели в IDLE
471
- */
472
- clear(sessionId = 'default') {
473
- const sid = sessionId || 'default';
474
- this.goals.delete(sid);
475
- this.stallCounters.delete(sid);
476
- this.emit(sid, true);
477
- return this.getSnapshot(sid);
478
- }
479
-
480
- /**
481
- * Успешное завершение цели
482
- */
483
- completeGoal(summary = '', sessionId = 'default') {
484
- const sid = sessionId || 'default';
485
- const goal = this.goals.get(sid);
486
- if (!goal) return null;
487
-
488
- const now = Date.now();
489
- goal.state = GoalState.COMPLETED;
490
- goal.completedAt = now;
491
- goal.resultSummary = summary;
492
- goal.logs.push({
493
- timestamp: now,
494
- type: 'info',
495
- message: `Goal completed successfully: ${summary || 'All objectives met.'}`,
496
- });
497
-
498
- if (goal.logs.length > 100) {
499
- goal.logs = goal.logs.slice(-100);
500
- }
501
-
502
- // Завершаем все активные milestones
503
- for (const m of goal.milestones) {
504
- if (m.status === MilestoneStatus.IN_PROGRESS || m.status === MilestoneStatus.PENDING) {
505
- m.status = MilestoneStatus.COMPLETED;
506
- }
507
- }
508
-
509
- this.stallCounters.delete(sid);
510
- this.emit(sid, true);
511
- return this.getSnapshot(sid);
512
- }
513
-
514
- /**
515
- * Добавление вех (milestones)
516
- */
517
- addMilestones(milestonesList, shouldEmit = true, sessionId = 'default') {
518
- const sid = sessionId || 'default';
519
- const goal = this.goals.get(sid);
520
- if (!goal || !Array.isArray(milestonesList)) return;
521
-
522
- for (const item of milestonesList) {
523
- const itemTitle = typeof item === 'string' ? item : item.title;
524
- if (!itemTitle || !itemTitle.trim()) continue;
525
-
526
- const mId = (typeof item === 'object' && item.id) ? item.id : `m-${goal.milestones.length + 1}`;
527
- goal.milestones.push({
528
- id: String(mId),
529
- title: itemTitle.trim(),
530
- status: (typeof item === 'object' && item.status) ? item.status : MilestoneStatus.PENDING,
531
- notes: (typeof item === 'object' && item.notes) ? item.notes : '',
532
- });
533
- }
534
-
535
- this.recordProgress(sid);
536
- if (shouldEmit) this.emit(sid);
537
- }
538
-
539
- /**
540
- * Обновление конкретной вехи
541
- */
542
- updateMilestone(id, status, notes = '', sessionId = 'default') {
543
- const sid = sessionId || 'default';
544
- const goal = this.goals.get(sid);
545
- if (!goal) return false;
546
-
547
- const target = goal.milestones.find((m) => m.id === String(id));
548
- if (!target) return false;
549
-
550
- if (status && Object.values(MilestoneStatus).includes(status)) {
551
- target.status = status;
552
- }
553
- if (notes) {
554
- target.notes = String(notes);
555
- }
556
-
557
- goal.logs.push({
558
- timestamp: Date.now(),
559
- type: 'milestone',
560
- message: `Milestone [${target.title}] status -> ${target.status}`,
561
- });
562
-
563
- if (goal.logs.length > 100) {
564
- goal.logs = goal.logs.slice(-100);
565
- }
566
-
567
- this.recordProgress(sid);
568
- this.emit(sid);
569
- return true;
570
- }
571
-
572
- /**
573
- * Увеличение счётчика итераций turn
574
- */
575
- incrementIteration(sessionId = 'default') {
576
- const sid = sessionId || 'default';
577
- const goal = this.goals.get(sid);
578
- if (!goal || goal.state !== GoalState.RUNNING) {
579
- return false;
580
- }
581
-
582
- goal.iterationsCount += 1;
583
-
584
- if (goal.iterationsCount >= goal.maxIterations) {
585
- goal.state = GoalState.FAILED;
586
- goal.logs.push({
587
- timestamp: Date.now(),
588
- type: 'error',
589
- message: `Safety limit reached: maximum ${goal.maxIterations} iterations exceeded.`,
590
- });
591
- if (goal.logs.length > 100) {
592
- goal.logs = goal.logs.slice(-100);
593
- }
594
- this.emit(sid, true);
595
- return false;
596
- }
597
-
598
- this.emit(sid);
599
- return true;
600
- }
601
-
602
- /**
603
- * Подсчёт времени в секундах
604
- */
605
- getElapsedSeconds(sessionId = 'default') {
606
- const sid = sessionId || 'default';
607
- const goal = this.goals.get(sid);
608
- if (!goal) return 0;
609
- const { startedAt, pausedAt, totalPausedDurationMs, completedAt } = goal;
610
- const endTime = completedAt || (pausedAt || Date.now());
611
- const elapsedMs = Math.max(0, endTime - startedAt - totalPausedDurationMs);
612
- return Math.floor(elapsedMs / 1000);
613
- }
614
-
615
- /**
616
- * Накопление статистики использования токенов сессии
617
- * @param {Object} usage
618
- * @param {string} [sessionId='default']
619
- */
620
- addTokenUsage(usage, sessionId = 'default') {
621
- if (!usage || typeof usage !== 'object') return;
622
- const sid = sessionId || 'default';
623
- const goal = this.goals.get(sid);
624
- if (!goal) return;
625
-
626
- if (!goal.tokensUsage) {
627
- goal.tokensUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
628
- }
629
-
630
- const prompt = Number(usage.promptTokens ?? usage.input_tokens ?? usage.prompt_tokens ?? 0) || 0;
631
- const completion = Number(usage.completionTokens ?? usage.output_tokens ?? usage.completion_tokens ?? 0) || 0;
632
- const total = Number(usage.totalTokens ?? usage.total_tokens ?? (prompt + completion)) || (prompt + completion);
633
-
634
- goal.tokensUsage.promptTokens += prompt;
635
- goal.tokensUsage.completionTokens += completion;
636
- goal.tokensUsage.totalTokens += total;
637
-
638
- this.emit(sid);
639
- }
640
-
641
- /**
642
- * Интеллектуальный расчет прогноза оставшегося времени (ETA)
643
- * на основе средней скорости выполнения завершенных вех
644
- * @param {string} [sessionId='default']
645
- * @returns {number|null}
646
- */
647
- getEstimatedRemainingSeconds(sessionId = 'default') {
648
- const sid = sessionId || 'default';
649
- const goal = this.goals.get(sid);
650
- if (!goal || goal.state !== GoalState.RUNNING) return null;
651
-
652
- const total = goal.milestones.length;
653
- if (total === 0) return null;
654
-
655
- const completedCount = goal.milestones.filter((m) => m.status === MilestoneStatus.COMPLETED).length;
656
- if (completedCount === 0 || completedCount >= total) return null;
657
-
658
- const elapsed = this.getElapsedSeconds(sid);
659
- if (elapsed <= 0) return null;
660
-
661
- const avgSecPerMilestone = elapsed / completedCount;
662
- const remainingCount = total - completedCount;
663
- return Math.max(1, Math.round(avgSecPerMilestone * remainingCount));
664
- }
665
-
666
- /**
667
- * Снимок состояния для передачи клиенту / API
668
- */
669
- getSnapshot(sessionId = 'default') {
670
- const sid = sessionId || 'default';
671
- const goal = this.goals.get(sid);
672
- if (!goal) {
673
- return {
674
- sessionId: sid,
675
- hasActiveGoal: false,
676
- state: GoalState.IDLE,
677
- title: '',
678
- startedAt: null,
679
- pausedAt: null,
680
- totalPausedDurationMs: 0,
681
- completedAt: null,
682
- elapsedSeconds: 0,
683
- formattedElapsed: '0s',
684
- estimatedRemainingSeconds: null,
685
- formattedETA: null,
686
- lang: 'en',
687
- tokensUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
688
- milestones: [],
689
- progressPercent: 0,
690
- iterationsCount: 0,
691
- maxIterations: this.defaultMaxIterations,
692
- autoDrive: this.autoDrive,
693
- enableSound: this.enableSound,
694
- showQuickLaunchButton: this.showQuickLaunchButton,
695
- };
696
- }
697
-
698
- const elapsed = this.getElapsedSeconds(sid);
699
- const milestones = goal.milestones;
700
- const completedCount = milestones.filter((m) => m.status === MilestoneStatus.COMPLETED).length;
701
- const progressPercent = milestones.length > 0 ? Math.round((completedCount / milestones.length) * 100) : 0;
702
- const estSec = this.getEstimatedRemainingSeconds(sid);
703
-
704
- return {
705
- sessionId: sid,
706
- hasActiveGoal: true,
707
- id: goal.id,
708
- state: goal.state,
709
- title: goal.title,
710
- description: goal.description,
711
- lang: goal.lang || detectLanguage(goal.title),
712
- startedAt: goal.startedAt,
713
- pausedAt: goal.pausedAt,
714
- totalPausedDurationMs: goal.totalPausedDurationMs,
715
- completedAt: goal.completedAt,
716
- elapsedSeconds: elapsed,
717
- formattedElapsed: formatElapsed(elapsed),
718
- estimatedRemainingSeconds: estSec,
719
- formattedETA: formatETA(estSec),
720
- tokensUsage: goal.tokensUsage || { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
721
- iterationsCount: goal.iterationsCount,
722
- maxIterations: goal.maxIterations,
723
- milestones,
724
- progressPercent,
725
- logs: goal.logs,
726
- resultSummary: goal.resultSummary,
727
- autoDrive: this.autoDrive,
728
- enableSound: this.enableSound,
729
- showQuickLaunchButton: this.showQuickLaunchButton,
730
- };
731
- }
732
-
733
- /**
734
- * Формирование системного контекста для инжекта модели
735
- */
736
- getStatePromptInjection(sessionId = 'default') {
737
- const sid = sessionId || 'default';
738
- const goal = this.goals.get(sid);
739
- if (!goal || goal.state !== GoalState.RUNNING) {
740
- return '';
741
- }
742
-
743
- const snapshot = this.getSnapshot(sid);
744
- const lang = goal.lang || detectLanguage(snapshot.title);
745
- const hasMilestones = snapshot.milestones.length > 0;
746
- const etaText = snapshot.formattedETA ? ` (ETA: ${snapshot.formattedETA})` : '';
747
-
748
- if (lang === 'ru') {
749
- const milestonesText = hasMilestones
750
- ? snapshot.milestones.map((m, i) => ` ${i + 1}. [${m.status.toUpperCase()}] ${m.title}${m.notes ? ` (${m.notes})` : ''}`).join('\n')
751
- : ' (План работ ещё не сформирован — немедленно вызови goal_set_milestones со списком шагов!)';
752
-
753
- return `\n\n[DSH GOAL MODE ACTIVE]
754
- Цель: "${snapshot.title}"
755
- Время работы: ${snapshot.formattedElapsed}${etaText} | Итерация: ${snapshot.iterationsCount}/${snapshot.maxIterations}
756
- План работ:
757
- ${milestonesText}
758
-
759
- Инструкции Goal Mode (СТРОГО ОБЯЗАТЕЛЬНЫ К ВЫПОЛНЕНИЮ):
760
- 1. ${hasMilestones ? 'Выполняй текущий активный пункт плана работ.' : 'ТВОЙ ПЕРВЫЙ ШАГ: Немедленно вызови инструмент goal_set_milestones со списком пунктов плана работ (3-7 конкретных шагов). Запрещено выполнять работу или завершать turn без вызова goal_set_milestones!'}
761
- 2. По мере выполнения каждого шага обязательно отмечай его статус через инструмент goal_update_progress (status: "in_progress" перед началом шага, status: "completed" по его завершении с кратким notes).
762
- 3. Когда все пункты плана будут выполнены, вызови инструмент goal_finish с подробным итоговым резюме достигнутых результатов.`;
763
- }
764
-
765
- const milestonesText = hasMilestones
766
- ? snapshot.milestones.map((m, i) => ` ${i + 1}. [${m.status.toUpperCase()}] ${m.title}${m.notes ? ` (${m.notes})` : ''}`).join('\n')
767
- : ' (Work plan is not yet established — call goal_set_milestones immediately with initial steps!)';
768
-
769
- return `\n\n[DSH GOAL MODE ACTIVE]
770
- Goal: "${snapshot.title}"
771
- Elapsed Time: ${snapshot.formattedElapsed}${etaText} | Iteration: ${snapshot.iterationsCount}/${snapshot.maxIterations}
772
- Work Plan:
773
- ${milestonesText}
774
-
775
- Goal Mode Instructions (MANDATORY TO FOLLOW):
776
- 1. ${hasMilestones ? 'Execute the current active milestone from the work plan.' : 'YOUR FIRST STEP: Immediately call tool goal_set_milestones with the list of milestones (3-7 concrete steps). You must not execute work or finish turn without calling goal_set_milestones!'}
777
- 2. As each milestone progresses, update its status via tool goal_update_progress (status: "in_progress" before starting, status: "completed" upon completion with brief notes).
778
- 3. When all milestones are completed, call tool goal_finish with a detailed summary of achieved results.`;
779
- }
780
- }
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { execSync } from 'node:child_process';
5
+
6
+ /**
7
+ * Изолированное ядро управления состоянием цели (Goal Engine).
8
+ * Не имеет внешних зависимостей, 100% тестируемо через node --test.
9
+ */
10
+
11
+ export const GoalState = {
12
+ IDLE: 'IDLE',
13
+ PLANNING: 'PLANNING',
14
+ RUNNING: 'RUNNING',
15
+ PAUSED: 'PAUSED',
16
+ COMPLETED: 'COMPLETED',
17
+ FAILED: 'FAILED',
18
+ CANCELLED: 'CANCELLED',
19
+ };
20
+
21
+ export const MilestoneStatus = {
22
+ PENDING: 'pending',
23
+ IN_PROGRESS: 'in_progress',
24
+ COMPLETED: 'completed',
25
+ FAILED: 'failed',
26
+ };
27
+
28
+ /**
29
+ * Форматирование времени в лаконичную строку (например: "2s", "45s", "1m 15s", "2h 5m")
30
+ * @param {number} totalSeconds
31
+ * @returns {string}
32
+ */
33
+ export function formatElapsed(totalSeconds) {
34
+ const sec = Math.max(0, Math.floor(totalSeconds));
35
+ if (sec < 60) return `${sec}s`;
36
+ const mins = Math.floor(sec / 60);
37
+ const remainingSec = sec % 60;
38
+ if (mins < 60) {
39
+ return remainingSec > 0 ? `${mins}m ${remainingSec}s` : `${mins}m`;
40
+ }
41
+ const hours = Math.floor(mins / 60);
42
+ const remainingMins = mins % 60;
43
+ return remainingMins > 0 ? `${hours}h ${remainingMins}m` : `${hours}h`;
44
+ }
45
+
46
+ /**
47
+ * Форматирование расчетного оставшегося времени (ETA)
48
+ * @param {number|null} seconds
49
+ * @returns {string|null}
50
+ */
51
+ export function formatETA(seconds) {
52
+ if (seconds == null || isNaN(seconds)) return null;
53
+ const sec = Math.max(0, Math.floor(seconds));
54
+ if (sec < 60) return `~${sec}s`;
55
+ const mins = Math.round(sec / 60);
56
+ if (mins < 60) return `~${mins}m`;
57
+ const hours = Math.floor(mins / 60);
58
+ const remainingMins = mins % 60;
59
+ return remainingMins > 0 ? `~${hours}h ${remainingMins}m` : `~${hours}h`;
60
+ }
61
+
62
+ /**
63
+ * Автоматическое определение языка текста (кириллица -> ru, иероглифы -> zh, иначе en)
64
+ * @param {string} text
65
+ * @param {string} [fallback='en']
66
+ * @returns {'ru' | 'en' | 'zh'}
67
+ */
68
+ export function detectLanguage(text, fallback = 'en') {
69
+ if (!text || typeof text !== 'string') return fallback;
70
+ if (/[а-яёА-ЯЁ]/i.test(text)) {
71
+ return 'ru';
72
+ }
73
+ if (/[\u4e00-\u9fa5]/.test(text)) {
74
+ return 'zh';
75
+ }
76
+ return 'en';
77
+ }
78
+
79
+
80
+ /**
81
+ * Безопасное получение текущего короткого Git commit hash
82
+ * @returns {string|null}
83
+ */
84
+ export function getGitCurrentCommit() {
85
+ try {
86
+ return execSync('git rev-parse --short HEAD', {
87
+ encoding: 'utf8',
88
+ stdio: ['ignore', 'pipe', 'ignore'],
89
+ timeout: 1000,
90
+ }).trim();
91
+ } catch (_) {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Генератор Markdown-отчета о результатах цели
98
+ * @param {Object} state
99
+ * @returns {string}
100
+ */
101
+ export function exportReportMarkdown(state) {
102
+ if (!state) return '';
103
+ const title = state.title || 'Goal Report';
104
+ const status = state.state === 'COMPLETED' ? 'COMPLETED' : state.state;
105
+ const elapsed = state.formattedElapsed || '0s';
106
+ const iter = `${state.iterationsCount || 0}/${state.maxIterations || 25}`;
107
+ const totalTokens = state.tokensUsage?.totalTokens || 0;
108
+ const promptTokens = state.tokensUsage?.promptTokens || 0;
109
+ const compTokens = state.tokensUsage?.completionTokens || 0;
110
+ const gitCommit = state.gitStartCommit ? ` | **Git Start:** \`${state.gitStartCommit}\`` : '';
111
+
112
+ let md = `# 🎯 Goal Report: ${title}\n\n`;
113
+ md += `**Status:** \`${status}\` | **Duration:** \`${elapsed}\` | **Iterations:** \`${iter}\`${gitCommit}\n`;
114
+ if (totalTokens > 0) {
115
+ md += `**Tokens:** \`${totalTokens.toLocaleString('en-US')}\` (Prompt: \`${promptTokens.toLocaleString('en-US')}\`, Completion: \`${compTokens.toLocaleString('en-US')}\`)\n`;
116
+ }
117
+ md += '\n';
118
+
119
+ if (state.description) {
120
+ md += `### Description\n${state.description}\n\n`;
121
+ }
122
+
123
+ if (state.resultSummary) {
124
+ md += `### Summary & Deliverables\n${state.resultSummary}\n\n`;
125
+ }
126
+
127
+ const milestones = state.milestones || [];
128
+ if (milestones.length > 0) {
129
+ md += `### Milestones\n| # | Status | Title | Notes |\n|---|---|---|---|\n`;
130
+ milestones.forEach((m, idx) => {
131
+ const mark = m.status === 'completed' ? '✅ Done' : m.status === 'in_progress' ? '🔄 In Progress' : '⏳ Pending';
132
+ const cleanTitle = (m.title || '').replace(/\|/g, '\\|');
133
+ const cleanNotes = (m.notes || '').replace(/\|/g, '\\|');
134
+ md += `| ${idx + 1} | ${mark} | ${cleanTitle} | ${cleanNotes || '—'} |\n`;
135
+ });
136
+ md += '\n';
137
+ }
138
+
139
+ md += `*Generated by DSH Goal Engine at ${new Date().toISOString()}*\n`;
140
+ return md;
141
+ }
142
+
143
+ /**
144
+ * Генератор отчета в формате GitHub / Gitea PR Comment со спойлерами
145
+ * @param {Object} state
146
+ * @returns {string}
147
+ */
148
+ export function exportReportGitHubPR(state) {
149
+ if (!state) return '';
150
+ const title = state.title || 'Goal Report';
151
+ const status = state.state === 'COMPLETED' ? 'COMPLETED' : state.state;
152
+ const elapsed = state.formattedElapsed || '0s';
153
+ const iter = `${state.iterationsCount || 0}/${state.maxIterations || 25}`;
154
+ const totalTokens = state.tokensUsage?.totalTokens || 0;
155
+ const promptTokens = state.tokensUsage?.promptTokens || 0;
156
+ const compTokens = state.tokensUsage?.completionTokens || 0;
157
+ const gitCommit = state.gitStartCommit ? ` &nbsp;|&nbsp; **Git Start:** \`${state.gitStartCommit}\` 📌` : '';
158
+
159
+ let md = `## 🎯 Autonomous Goal Resolution: ${title}\n\n`;
160
+ md += `> **Status:** \`${status}\` 🚀 &nbsp;|&nbsp; **Duration:** \`${elapsed}\` ⏱️ &nbsp;|&nbsp; **Iterations:** \`${iter}\` 🔄${gitCommit}\n\n`;
161
+
162
+ if (totalTokens > 0) {
163
+ md += `### 📊 Telemetry & Token Usage\n`;
164
+ md += `- **Total Tokens:** \`${totalTokens.toLocaleString('en-US')}\` (Prompt: \`${promptTokens.toLocaleString('en-US')}\`, Completion: \`${compTokens.toLocaleString('en-US')}\`)\n\n`;
165
+ }
166
+
167
+ if (state.resultSummary) {
168
+ md += `### 📦 Deliverables & Achievements\n${state.resultSummary}\n\n`;
169
+ } else if (state.description) {
170
+ md += `### 📝 Objective\n${state.description}\n\n`;
171
+ }
172
+
173
+ const milestones = state.milestones || [];
174
+ if (milestones.length > 0) {
175
+ const completedCount = milestones.filter(m => m.status === 'completed').length;
176
+ md += `<details>\n<summary><b>📋 Milestones Breakdown (${completedCount}/${milestones.length} Completed)</b></summary>\n\n`;
177
+ md += `| # | Status | Milestone | Notes |\n|---|---|---|---|\n`;
178
+ milestones.forEach((m, idx) => {
179
+ const mark = m.status === 'completed' ? '✅ Done' : m.status === 'in_progress' ? '🔄 In Progress' : '⏳ Pending';
180
+ const cleanTitle = (m.title || '').replace(/\|/g, '\\|');
181
+ const cleanNotes = (m.notes || '').replace(/\|/g, '\\|');
182
+ md += `| ${idx + 1} | ${mark} | ${cleanTitle} | ${cleanNotes || '—'} |\n`;
183
+ });
184
+ md += `\n</details>\n\n`;
185
+ }
186
+
187
+ md += `*Automated by [@goodandready/dsh-goal](https://github.com/GooDAnDReaDY/dsh-goal)*\n`;
188
+ return md;
189
+ }
190
+
191
+ export class GoalEngine {
192
+ constructor(options = {}) {
193
+ this.defaultMaxIterations = options.defaultMaxIterations ?? 25;
194
+ this.autoDrive = options.autoDrive ?? true;
195
+ this.enableSound = options.enableSound ?? true;
196
+ this.showQuickLaunchButton = options.showQuickLaunchButton ?? true;
197
+ this.consecutiveToolFailureLimit = options.consecutiveToolFailureLimit ?? 3;
198
+ this.maxSessions = options.maxSessions ?? 100;
199
+ this.goals = new Map();
200
+ this.listeners = new Set();
201
+ this.saveTimer = null;
202
+ this.stallCounters = new Map(); // sessionId -> number of consecutive turns without progress
203
+ this.toolFailureCounters = new Map(); // sessionId -> number of consecutive turns with tool errors
204
+
205
+ const defaultStorageDir = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
206
+ this.storagePath = options.storagePath ?? null;
207
+
208
+ this.loadStateFromDisk();
209
+ }
210
+
211
+ get currentGoal() {
212
+ return this.goals.get('default') || null;
213
+ }
214
+
215
+ set currentGoal(val) {
216
+ if (val) {
217
+ this.goals.set('default', val);
218
+ } else {
219
+ this.goals.delete('default');
220
+ }
221
+ }
222
+
223
+ loadStateFromDisk() {
224
+ if (!this.storagePath) return;
225
+ try {
226
+ if (fs.existsSync(this.storagePath)) {
227
+ const raw = fs.readFileSync(this.storagePath, 'utf8');
228
+ const data = JSON.parse(raw);
229
+ if (data && typeof data === 'object') {
230
+ let dirty = false;
231
+ if (data.sessions && typeof data.sessions === 'object') {
232
+ for (const [sid, goal] of Object.entries(data.sessions)) {
233
+ if (goal && goal.id && goal.title) {
234
+ // Item 4: Crash Hydration — если цель осталась в RUNNING после перезапуска/падения DSH,
235
+ // переводим в PAUSED с понятной причиной и фиксацией времени
236
+ if (goal.state === GoalState.RUNNING) {
237
+ goal.state = GoalState.PAUSED;
238
+ goal.pausedAt = Date.now();
239
+ if (!Array.isArray(goal.logs)) goal.logs = [];
240
+ goal.logs.push({
241
+ timestamp: Date.now(),
242
+ type: 'warning',
243
+ message: 'Harness was restarted — click ▶️ to resume',
244
+ });
245
+ if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
246
+ dirty = true;
247
+ }
248
+ if (!goal.tokensUsage) {
249
+ goal.tokensUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
250
+ }
251
+ if (!goal.lang) {
252
+ goal.lang = detectLanguage(goal.title);
253
+ }
254
+ this.goals.set(sid, goal);
255
+ }
256
+ }
257
+ } else if (data.id && data.title) {
258
+ if (data.state === GoalState.RUNNING) {
259
+ data.state = GoalState.PAUSED;
260
+ data.pausedAt = Date.now();
261
+ if (!Array.isArray(data.logs)) data.logs = [];
262
+ data.logs.push({
263
+ timestamp: Date.now(),
264
+ type: 'warning',
265
+ message: 'Harness was restarted — click ▶️ to resume',
266
+ });
267
+ if (data.logs.length > 100) data.logs = data.logs.slice(-100);
268
+ dirty = true;
269
+ }
270
+ if (!data.tokensUsage) {
271
+ data.tokensUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
272
+ }
273
+ if (!data.lang) {
274
+ data.lang = detectLanguage(data.title);
275
+ }
276
+ this.goals.set('default', data);
277
+ }
278
+ if (dirty) {
279
+ this.scheduleSave(true);
280
+ }
281
+ }
282
+ }
283
+ } catch (err) {
284
+ console.warn('[GoalEngine] Failed to load state from disk:', err);
285
+ }
286
+ }
287
+
288
+ scheduleSave(immediate = false) {
289
+ if (!this.storagePath) return;
290
+ if (immediate) {
291
+ if (this.saveTimer) {
292
+ clearTimeout(this.saveTimer);
293
+ this.saveTimer = null;
294
+ }
295
+ this.writeStateToDiskSync();
296
+ return;
297
+ }
298
+ if (!this.saveTimer) {
299
+ this.saveTimer = setTimeout(() => {
300
+ this.saveTimer = null;
301
+ this.writeStateToDiskSync();
302
+ }, 250);
303
+ if (typeof this.saveTimer.unref === 'function') {
304
+ this.saveTimer.unref();
305
+ }
306
+ }
307
+ }
308
+
309
+ writeStateToDiskSync() {
310
+ if (!this.storagePath) return;
311
+ try {
312
+ if (this.goals.size === 0) {
313
+ if (fs.existsSync(this.storagePath)) {
314
+ fs.unlinkSync(this.storagePath);
315
+ }
316
+ return;
317
+ }
318
+ const sessionsObj = {};
319
+ for (const [sid, goal] of this.goals.entries()) {
320
+ sessionsObj[sid] = goal;
321
+ }
322
+ const payload = {
323
+ version: 2,
324
+ sessions: sessionsObj,
325
+ ...(this.goals.has('default') ? this.goals.get('default') : {}),
326
+ };
327
+ const dir = path.dirname(this.storagePath);
328
+ if (!fs.existsSync(dir)) {
329
+ fs.mkdirSync(dir, { recursive: true });
330
+ }
331
+ const tmp = `${this.storagePath}.tmp.${Date.now()}`;
332
+ fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
333
+ fs.renameSync(tmp, this.storagePath);
334
+ } catch (err) {
335
+ console.warn('[GoalEngine] Failed to write state to disk:', err);
336
+ }
337
+ }
338
+
339
+ flushSync() {
340
+ if (this.saveTimer) {
341
+ clearTimeout(this.saveTimer);
342
+ this.saveTimer = null;
343
+ }
344
+ this.writeStateToDiskSync();
345
+ }
346
+
347
+ saveStateToDisk() {
348
+ this.flushSync();
349
+ }
350
+
351
+ /**
352
+ * Подписка на изменение состояния
353
+ * @param {Function} callback
354
+ * @returns {Function} unsubscribe
355
+ */
356
+ subscribe(callback) {
357
+ this.listeners.add(callback);
358
+ return () => this.listeners.delete(callback);
359
+ }
360
+
361
+ emit(sessionId = 'default', immediate = false) {
362
+ this.scheduleSave(immediate);
363
+ const sid = sessionId || 'default';
364
+ const snapshot = this.getSnapshot(sid);
365
+ for (const listener of this.listeners) {
366
+ try {
367
+ listener(snapshot, sid);
368
+ } catch (err) {
369
+ console.error('[GoalEngine] Listener error:', err);
370
+ }
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Отслеживание прогресса для Smart Progress Guard
376
+ */
377
+ recordProgress(sessionId = 'default') {
378
+ const sid = sessionId || 'default';
379
+ this.stallCounters.set(sid, 0);
380
+ }
381
+
382
+ incrementStallCount(sessionId = 'default') {
383
+ const sid = sessionId || 'default';
384
+ const current = this.stallCounters.get(sid) || 0;
385
+ const next = current + 1;
386
+ this.stallCounters.set(sid, next);
387
+ return next;
388
+ }
389
+
390
+ getStallCount(sessionId = 'default') {
391
+ const sid = sessionId || 'default';
392
+ return this.stallCounters.get(sid) || 0;
393
+ }
394
+
395
+ /**
396
+ * Динамическое обновление настроек на лету
397
+ * @param {Object} config
398
+ */
399
+ updateConfig(config = {}) {
400
+ if (typeof config.defaultMaxIterations === 'number' && config.defaultMaxIterations >= 1) {
401
+ const prev = this.defaultMaxIterations;
402
+ this.defaultMaxIterations = config.defaultMaxIterations;
403
+ for (const [_, goal] of this.goals) {
404
+ if (goal && goal.maxIterations === prev) {
405
+ goal.maxIterations = config.defaultMaxIterations;
406
+ }
407
+ }
408
+ }
409
+ if (typeof config.autoDrive === 'boolean') {
410
+ this.autoDrive = config.autoDrive;
411
+ }
412
+ if (typeof config.enableSound === 'boolean') {
413
+ this.enableSound = config.enableSound;
414
+ }
415
+ if (typeof config.showQuickLaunchButton === 'boolean') {
416
+ this.showQuickLaunchButton = config.showQuickLaunchButton;
417
+ }
418
+ if (typeof config.consecutiveToolFailureLimit === 'number') {
419
+ this.consecutiveToolFailureLimit = Math.max(0, config.consecutiveToolFailureLimit);
420
+ }
421
+ this.emit();
422
+ }
423
+
424
+ /**
425
+ * Получение цели сессии
426
+ */
427
+ getGoal(sessionId = 'default') {
428
+ return this.goals.get(sessionId || 'default') || null;
429
+ }
430
+
431
+ /**
432
+ * Очистка старых неактивных сессий во избежание утечек памяти
433
+ */
434
+
435
+ /**
436
+ * Управление счетчиком повторяющихся ошибок инструментов
437
+ */
438
+ incrementToolFailureCount(sessionId = 'default') {
439
+ const sid = sessionId || 'default';
440
+ const current = this.toolFailureCounters.get(sid) || 0;
441
+ const next = current + 1;
442
+ this.toolFailureCounters.set(sid, next);
443
+ return next;
444
+ }
445
+
446
+ resetToolFailureCount(sessionId = 'default') {
447
+ const sid = sessionId || 'default';
448
+ this.toolFailureCounters.set(sid, 0);
449
+ }
450
+
451
+ getToolFailureCount(sessionId = 'default') {
452
+ const sid = sessionId || 'default';
453
+ return this.toolFailureCounters.get(sid) || 0;
454
+ }
455
+
456
+ /**
457
+ * Живая корректировка цели / добавление уточнений (Live Steering / Nudge)
458
+ * @param {string} text
459
+ * @param {string} [sessionId='default']
460
+ */
461
+ nudge(text, sessionId = 'default') {
462
+ const sid = sessionId || 'default';
463
+ const goal = this.goals.get(sid);
464
+ if (!goal) return this.getSnapshot(sid);
465
+
466
+ const clean = String(text || '').trim();
467
+ if (!clean) return this.getSnapshot(sid);
468
+
469
+ if (!Array.isArray(goal.nudges)) {
470
+ goal.nudges = [];
471
+ }
472
+ const now = Date.now();
473
+ goal.nudges.push({ text: clean, timestamp: now });
474
+ goal.pendingNudge = clean;
475
+ goal.logs.push({
476
+ timestamp: now,
477
+ type: 'info',
478
+ message: `User steering / clarification: "${clean}"`,
479
+ });
480
+ if (goal.logs.length > 100) {
481
+ goal.logs = goal.logs.slice(-100);
482
+ }
483
+
484
+ this.emit(sid, true);
485
+ return this.getSnapshot(sid);
486
+ }
487
+
488
+ consumePendingNudge(sessionId = 'default') {
489
+ const sid = sessionId || 'default';
490
+ const goal = this.goals.get(sid);
491
+ if (!goal || !goal.pendingNudge) return null;
492
+ const nudge = goal.pendingNudge;
493
+ goal.pendingNudge = null;
494
+ this.emit(sid);
495
+ return nudge;
496
+ }
497
+
498
+ pruneInactiveSessions() {
499
+ if (this.goals.size < this.maxSessions) return;
500
+ const inactive = [];
501
+ for (const [sid, goal] of this.goals.entries()) {
502
+ if (sid === 'default') continue;
503
+ if (goal.state === GoalState.COMPLETED || goal.state === GoalState.CANCELLED || goal.state === GoalState.FAILED) {
504
+ inactive.push({ sid, completedAt: goal.completedAt || goal.startedAt || 0 });
505
+ }
506
+ }
507
+ inactive.sort((a, b) => a.completedAt - b.completedAt);
508
+ while (this.goals.size >= this.maxSessions && inactive.length > 0) {
509
+ const oldest = inactive.shift();
510
+ this.goals.delete(oldest.sid);
511
+ this.stallCounters.delete(oldest.sid);
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Запуск новой цели
517
+ * @param {string} title
518
+ * @param {Object} options
519
+ * @param {string} [sessionId='default']
520
+ */
521
+ startGoal(title, options = {}, sessionId = 'default') {
522
+ if (!title || typeof title !== 'string' || !title.trim()) {
523
+ throw new Error('Goal title cannot be empty');
524
+ }
525
+
526
+ this.pruneInactiveSessions();
527
+
528
+ const cleanTitle = title.trim();
529
+ const now = Date.now();
530
+ const sid = sessionId || 'default';
531
+ this.stallCounters.set(sid, 0);
532
+
533
+ const gitCommit = options.gitStartCommit !== undefined
534
+ ? options.gitStartCommit
535
+ : getGitCurrentCommit();
536
+
537
+ this.toolFailureCounters.set(sid, 0);
538
+
539
+ const goal = {
540
+ id: `goal-${now}-${Math.random().toString(36).substring(2, 7)}`,
541
+ sessionId: sid,
542
+ title: cleanTitle,
543
+ description: options.description?.trim() || '',
544
+ lang: options.lang || detectLanguage(cleanTitle),
545
+ state: GoalState.RUNNING,
546
+ startedAt: now,
547
+ pausedAt: null,
548
+ totalPausedDurationMs: 0,
549
+ completedAt: null,
550
+ iterationsCount: 0,
551
+ maxIterations: options.maxIterations ?? this.defaultMaxIterations,
552
+ gitStartCommit: gitCommit || null,
553
+ pendingNudge: null,
554
+ nudges: [],
555
+ milestones: [],
556
+ tokensUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
557
+ logs: [
558
+ {
559
+ timestamp: now,
560
+ type: 'info',
561
+ message: `Goal initiated: "${cleanTitle}"` + (gitCommit ? ` (Git: ${gitCommit})` : ''),
562
+ },
563
+ ],
564
+ resultSummary: '',
565
+ };
566
+
567
+ this.goals.set(sid, goal);
568
+
569
+ if (Array.isArray(options.milestones) && options.milestones.length > 0) {
570
+ this.addMilestones(options.milestones, false, sid);
571
+ }
572
+
573
+ this.emit(sid, true);
574
+ return this.getSnapshot(sid);
575
+ }
576
+
577
+ /**
578
+ * Приостановка автономного цикла цели
579
+ */
580
+ pause(reason = 'User requested pause', sessionId = 'default') {
581
+ const sid = sessionId || 'default';
582
+ const goal = this.goals.get(sid);
583
+ if (!goal || goal.state !== GoalState.RUNNING) {
584
+ return this.getSnapshot(sid);
585
+ }
586
+
587
+ goal.state = GoalState.PAUSED;
588
+ goal.pausedAt = Date.now();
589
+ goal.logs.push({
590
+ timestamp: Date.now(),
591
+ type: 'warning',
592
+ message: `Paused: ${reason}`,
593
+ });
594
+
595
+ if (goal.logs.length > 100) {
596
+ goal.logs = goal.logs.slice(-100);
597
+ }
598
+
599
+ this.emit(sid, true);
600
+ return this.getSnapshot(sid);
601
+ }
602
+
603
+ /**
604
+ * Возобновление выполнения цели
605
+ */
606
+ resume(sessionId = 'default') {
607
+ const sid = sessionId || 'default';
608
+ const goal = this.goals.get(sid);
609
+ if (!goal || goal.state !== GoalState.PAUSED) {
610
+ return this.getSnapshot(sid);
611
+ }
612
+
613
+ const now = Date.now();
614
+ if (goal.pausedAt) {
615
+ goal.totalPausedDurationMs += now - goal.pausedAt;
616
+ goal.pausedAt = null;
617
+ }
618
+
619
+ goal.state = GoalState.RUNNING;
620
+ this.stallCounters.set(sid, 0); // сбрасываем счетчик простоя при возобновлении
621
+
622
+ goal.logs.push({
623
+ timestamp: now,
624
+ type: 'info',
625
+ message: 'Goal resumed',
626
+ });
627
+
628
+ if (goal.logs.length > 100) {
629
+ goal.logs = goal.logs.slice(-100);
630
+ }
631
+
632
+ this.emit(sid, true);
633
+ return this.getSnapshot(sid);
634
+ }
635
+
636
+ /**
637
+ * Отмена цели
638
+ */
639
+ cancel(reason = 'Cancelled by user', sessionId = 'default') {
640
+ const sid = sessionId || 'default';
641
+ const goal = this.goals.get(sid);
642
+ if (!goal) return null;
643
+
644
+ goal.state = GoalState.CANCELLED;
645
+ goal.completedAt = Date.now();
646
+ goal.logs.push({
647
+ timestamp: Date.now(),
648
+ type: 'warning',
649
+ message: `Cancelled: ${reason}`,
650
+ });
651
+
652
+ if (goal.logs.length > 100) {
653
+ goal.logs = goal.logs.slice(-100);
654
+ }
655
+
656
+ this.emit(sid, true);
657
+ return this.getSnapshot(sid);
658
+ }
659
+
660
+ /**
661
+ * Очистка / сброс цели в IDLE
662
+ */
663
+ clear(sessionId = 'default') {
664
+ const sid = sessionId || 'default';
665
+ this.goals.delete(sid);
666
+ this.stallCounters.delete(sid);
667
+ this.emit(sid, true);
668
+ return this.getSnapshot(sid);
669
+ }
670
+
671
+ /**
672
+ * Успешное завершение цели
673
+ */
674
+ completeGoal(summary = '', sessionId = 'default') {
675
+ const sid = sessionId || 'default';
676
+ const goal = this.goals.get(sid);
677
+ if (!goal) return null;
678
+
679
+ const now = Date.now();
680
+ goal.state = GoalState.COMPLETED;
681
+ goal.completedAt = now;
682
+ goal.resultSummary = summary;
683
+ goal.logs.push({
684
+ timestamp: now,
685
+ type: 'info',
686
+ message: `Goal completed successfully: ${summary || 'All objectives met.'}`,
687
+ });
688
+
689
+ if (goal.logs.length > 100) {
690
+ goal.logs = goal.logs.slice(-100);
691
+ }
692
+
693
+ // Завершаем все активные milestones
694
+ for (const m of goal.milestones) {
695
+ if (m.status === MilestoneStatus.IN_PROGRESS || m.status === MilestoneStatus.PENDING) {
696
+ m.status = MilestoneStatus.COMPLETED;
697
+ }
698
+ }
699
+
700
+ this.stallCounters.delete(sid);
701
+ this.emit(sid, true);
702
+ return this.getSnapshot(sid);
703
+ }
704
+
705
+ /**
706
+ * Добавление вех (milestones)
707
+ */
708
+ addMilestones(milestonesList, shouldEmit = true, sessionId = 'default') {
709
+ const sid = sessionId || 'default';
710
+ const goal = this.goals.get(sid);
711
+ if (!goal || !Array.isArray(milestonesList)) return;
712
+
713
+ for (const item of milestonesList) {
714
+ const itemTitle = typeof item === 'string' ? item : item.title;
715
+ if (!itemTitle || !itemTitle.trim()) continue;
716
+
717
+ const mId = (typeof item === 'object' && item.id) ? item.id : `m-${goal.milestones.length + 1}`;
718
+ goal.milestones.push({
719
+ id: String(mId),
720
+ title: itemTitle.trim(),
721
+ status: (typeof item === 'object' && item.status) ? item.status : MilestoneStatus.PENDING,
722
+ notes: (typeof item === 'object' && item.notes) ? item.notes : '',
723
+ });
724
+ }
725
+
726
+ this.recordProgress(sid);
727
+ if (shouldEmit) this.emit(sid);
728
+ }
729
+
730
+ /**
731
+ * Обновление конкретной вехи
732
+ */
733
+ updateMilestone(id, status, notes = '', sessionId = 'default') {
734
+ const sid = sessionId || 'default';
735
+ const goal = this.goals.get(sid);
736
+ if (!goal) return false;
737
+
738
+ const target = goal.milestones.find((m) => m.id === String(id));
739
+ if (!target) return false;
740
+
741
+ if (status && Object.values(MilestoneStatus).includes(status)) {
742
+ target.status = status;
743
+ }
744
+ if (notes) {
745
+ target.notes = String(notes);
746
+ }
747
+
748
+ goal.logs.push({
749
+ timestamp: Date.now(),
750
+ type: 'milestone',
751
+ message: `Milestone [${target.title}] status -> ${target.status}`,
752
+ });
753
+
754
+ if (goal.logs.length > 100) {
755
+ goal.logs = goal.logs.slice(-100);
756
+ }
757
+
758
+ this.recordProgress(sid);
759
+ this.emit(sid);
760
+ return true;
761
+ }
762
+
763
+ /**
764
+ * Увеличение счётчика итераций turn
765
+ */
766
+ incrementIteration(sessionId = 'default') {
767
+ const sid = sessionId || 'default';
768
+ const goal = this.goals.get(sid);
769
+ if (!goal || goal.state !== GoalState.RUNNING) {
770
+ return false;
771
+ }
772
+
773
+ goal.iterationsCount += 1;
774
+
775
+ if (goal.iterationsCount >= goal.maxIterations) {
776
+ goal.state = GoalState.FAILED;
777
+ goal.logs.push({
778
+ timestamp: Date.now(),
779
+ type: 'error',
780
+ message: `Safety limit reached: maximum ${goal.maxIterations} iterations exceeded.`,
781
+ });
782
+ if (goal.logs.length > 100) {
783
+ goal.logs = goal.logs.slice(-100);
784
+ }
785
+ this.emit(sid, true);
786
+ return false;
787
+ }
788
+
789
+ this.emit(sid);
790
+ return true;
791
+ }
792
+
793
+ /**
794
+ * Подсчёт времени в секундах
795
+ */
796
+ getElapsedSeconds(sessionId = 'default') {
797
+ const sid = sessionId || 'default';
798
+ const goal = this.goals.get(sid);
799
+ if (!goal) return 0;
800
+ const { startedAt, pausedAt, totalPausedDurationMs, completedAt } = goal;
801
+ const endTime = completedAt || (pausedAt || Date.now());
802
+ const elapsedMs = Math.max(0, endTime - startedAt - totalPausedDurationMs);
803
+ return Math.floor(elapsedMs / 1000);
804
+ }
805
+
806
+ /**
807
+ * Накопление статистики использования токенов сессии
808
+ * @param {Object} usage
809
+ * @param {string} [sessionId='default']
810
+ */
811
+ addTokenUsage(usage, sessionId = 'default') {
812
+ if (!usage || typeof usage !== 'object') return;
813
+ const sid = sessionId || 'default';
814
+ const goal = this.goals.get(sid);
815
+ if (!goal) return;
816
+
817
+ if (!goal.tokensUsage) {
818
+ goal.tokensUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
819
+ }
820
+
821
+ const prompt = Number(usage.promptTokens ?? usage.input_tokens ?? usage.prompt_tokens ?? 0) || 0;
822
+ const completion = Number(usage.completionTokens ?? usage.output_tokens ?? usage.completion_tokens ?? 0) || 0;
823
+ const total = Number(usage.totalTokens ?? usage.total_tokens ?? (prompt + completion)) || (prompt + completion);
824
+
825
+ goal.tokensUsage.promptTokens += prompt;
826
+ goal.tokensUsage.completionTokens += completion;
827
+ goal.tokensUsage.totalTokens += total;
828
+
829
+ this.emit(sid);
830
+ }
831
+
832
+ /**
833
+ * Интеллектуальный расчет прогноза оставшегося времени (ETA)
834
+ * на основе средней скорости выполнения завершенных вех
835
+ * @param {string} [sessionId='default']
836
+ * @returns {number|null}
837
+ */
838
+ getEstimatedRemainingSeconds(sessionId = 'default') {
839
+ const sid = sessionId || 'default';
840
+ const goal = this.goals.get(sid);
841
+ if (!goal || goal.state !== GoalState.RUNNING) return null;
842
+
843
+ const total = goal.milestones.length;
844
+ if (total === 0) return null;
845
+
846
+ const completedCount = goal.milestones.filter((m) => m.status === MilestoneStatus.COMPLETED).length;
847
+ if (completedCount === 0 || completedCount >= total) return null;
848
+
849
+ const elapsed = this.getElapsedSeconds(sid);
850
+ if (elapsed <= 0) return null;
851
+
852
+ const avgSecPerMilestone = elapsed / completedCount;
853
+ const remainingCount = total - completedCount;
854
+ return Math.max(1, Math.round(avgSecPerMilestone * remainingCount));
855
+ }
856
+
857
+ /**
858
+ * Снимок состояния для передачи клиенту / API
859
+ */
860
+ getSnapshot(sessionId = 'default') {
861
+ const sid = sessionId || 'default';
862
+ const goal = this.goals.get(sid);
863
+ if (!goal) {
864
+ return {
865
+ sessionId: sid,
866
+ hasActiveGoal: false,
867
+ state: GoalState.IDLE,
868
+ title: '',
869
+ startedAt: null,
870
+ pausedAt: null,
871
+ totalPausedDurationMs: 0,
872
+ completedAt: null,
873
+ elapsedSeconds: 0,
874
+ formattedElapsed: '0s',
875
+ estimatedRemainingSeconds: null,
876
+ formattedETA: null,
877
+ lang: 'en',
878
+ tokensUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
879
+ milestones: [],
880
+ progressPercent: 0,
881
+ iterationsCount: 0,
882
+ maxIterations: this.defaultMaxIterations,
883
+ autoDrive: this.autoDrive,
884
+ enableSound: this.enableSound,
885
+ showQuickLaunchButton: this.showQuickLaunchButton,
886
+ gitStartCommit: null,
887
+ pendingNudge: null,
888
+ toolFailureCount: 0,
889
+ consecutiveToolFailureLimit: this.consecutiveToolFailureLimit,
890
+ };
891
+ }
892
+
893
+ const elapsed = this.getElapsedSeconds(sid);
894
+ const milestones = goal.milestones;
895
+ const completedCount = milestones.filter((m) => m.status === MilestoneStatus.COMPLETED).length;
896
+ const progressPercent = milestones.length > 0 ? Math.round((completedCount / milestones.length) * 100) : 0;
897
+ const estSec = this.getEstimatedRemainingSeconds(sid);
898
+
899
+ return {
900
+ sessionId: sid,
901
+ hasActiveGoal: true,
902
+ id: goal.id,
903
+ state: goal.state,
904
+ title: goal.title,
905
+ description: goal.description,
906
+ lang: goal.lang || detectLanguage(goal.title),
907
+ startedAt: goal.startedAt,
908
+ pausedAt: goal.pausedAt,
909
+ totalPausedDurationMs: goal.totalPausedDurationMs,
910
+ completedAt: goal.completedAt,
911
+ elapsedSeconds: elapsed,
912
+ formattedElapsed: formatElapsed(elapsed),
913
+ estimatedRemainingSeconds: estSec,
914
+ formattedETA: formatETA(estSec),
915
+ tokensUsage: goal.tokensUsage || { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
916
+ iterationsCount: goal.iterationsCount,
917
+ maxIterations: goal.maxIterations,
918
+ milestones,
919
+ progressPercent,
920
+ logs: goal.logs,
921
+ resultSummary: goal.resultSummary,
922
+ autoDrive: this.autoDrive,
923
+ enableSound: this.enableSound,
924
+ showQuickLaunchButton: this.showQuickLaunchButton,
925
+ gitStartCommit: goal.gitStartCommit || null,
926
+ pendingNudge: goal.pendingNudge || null,
927
+ toolFailureCount: this.getToolFailureCount(sid),
928
+ consecutiveToolFailureLimit: this.consecutiveToolFailureLimit,
929
+ };
930
+ }
931
+
932
+ /**
933
+ * Формирование системного контекста для инжекта модели
934
+ */
935
+ getStatePromptInjection(sessionId = 'default') {
936
+ const sid = sessionId || 'default';
937
+ const goal = this.goals.get(sid);
938
+ if (!goal || goal.state !== GoalState.RUNNING) {
939
+ return '';
940
+ }
941
+
942
+ const snapshot = this.getSnapshot(sid);
943
+ const lang = goal.lang || detectLanguage(snapshot.title);
944
+ const hasMilestones = snapshot.milestones.length > 0;
945
+ const etaText = snapshot.formattedETA ? ` (ETA: ${snapshot.formattedETA})` : '';
946
+
947
+ let nudgeText = '';
948
+ if (goal.pendingNudge) {
949
+ const userFeedback = goal.pendingNudge;
950
+ goal.pendingNudge = null;
951
+ nudgeText = lang === 'ru'
952
+ ? `\n\n🚨 СРОЧНОЕ УТОЧНЕНИЕ / НАПРАВЛЕНИЕ ОТ ПОЛЬЗОВАТЕЛЯ:\n"${userFeedback}"\nОбязательно скорректируй свои ближайшие действия с учётом этого замечания!\n`
953
+ : `\n\n🚨 URGENT USER CLARIFICATION / STEERING:\n"${userFeedback}"\nYou must adjust your immediate actions according to this guidance!\n`;
954
+ }
955
+
956
+ if (lang === 'ru') {
957
+ const milestonesText = hasMilestones
958
+ ? snapshot.milestones.map((m, i) => ` ${i + 1}. [${m.status.toUpperCase()}] ${m.title}${m.notes ? ` (${m.notes})` : ''}`).join('\n')
959
+ : ' (План работ ещё не сформирован — немедленно вызови goal_set_milestones со списком шагов!)';
960
+
961
+ return nudgeText + `\n\n[DSH GOAL MODE ACTIVE]
962
+ Цель: "${snapshot.title}"
963
+ Время работы: ${snapshot.formattedElapsed}${etaText} | Итерация: ${snapshot.iterationsCount}/${snapshot.maxIterations}
964
+ План работ:
965
+ ${milestonesText}
966
+
967
+ Инструкции Goal Mode (СТРОГО ОБЯЗАТЕЛЬНЫ К ВЫПОЛНЕНИЮ):
968
+ 1. ${hasMilestones ? 'Выполняй текущий активный пункт плана работ.' : 'ТВОЙ ПЕРВЫЙ ШАГ: Немедленно вызови инструмент goal_set_milestones со списком пунктов плана работ (3-7 конкретных шагов). Запрещено выполнять работу или завершать turn без вызова goal_set_milestones!'}
969
+ 2. По мере выполнения каждого шага обязательно отмечай его статус через инструмент goal_update_progress (status: "in_progress" перед началом шага, status: "completed" по его завершении с кратким notes).
970
+ 3. Когда все пункты плана будут выполнены, вызови инструмент goal_finish с подробным итоговым резюме достигнутых результатов.`;
971
+ }
972
+
973
+ const milestonesText = hasMilestones
974
+ ? snapshot.milestones.map((m, i) => ` ${i + 1}. [${m.status.toUpperCase()}] ${m.title}${m.notes ? ` (${m.notes})` : ''}`).join('\n')
975
+ : ' (Work plan is not yet established — call goal_set_milestones immediately with initial steps!)';
976
+
977
+ return nudgeText + `\n\n[DSH GOAL MODE ACTIVE]
978
+ Goal: "${snapshot.title}"
979
+ Elapsed Time: ${snapshot.formattedElapsed}${etaText} | Iteration: ${snapshot.iterationsCount}/${snapshot.maxIterations}
980
+ Work Plan:
981
+ ${milestonesText}
982
+
983
+ Goal Mode Instructions (MANDATORY TO FOLLOW):
984
+ 1. ${hasMilestones ? 'Execute the current active milestone from the work plan.' : 'YOUR FIRST STEP: Immediately call tool goal_set_milestones with the list of milestones (3-7 concrete steps). You must not execute work or finish turn without calling goal_set_milestones!'}
985
+ 2. As each milestone progresses, update its status via tool goal_update_progress (status: "in_progress" before starting, status: "completed" upon completion with brief notes).
986
+ 3. When all milestones are completed, call tool goal_finish with a detailed summary of achieved results.`;
987
+ }
988
+ }