@goodandready/dsh-goal 0.1.4 → 0.1.6

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,536 +1,643 @@
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
- export class GoalEngine {
46
- constructor(options = {}) {
47
- this.defaultMaxIterations = options.defaultMaxIterations ?? 25;
48
- this.autoDrive = options.autoDrive ?? true;
49
- this.enableSound = options.enableSound ?? true;
50
- this.goals = new Map();
51
- this.listeners = new Set();
52
- this.saveTimer = null;
53
-
54
- const defaultStorageDir = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
55
- this.storagePath = options.storagePath ?? null;
56
-
57
- this.loadStateFromDisk();
58
- }
59
-
60
- get currentGoal() {
61
- return this.goals.get('default') || null;
62
- }
63
-
64
- set currentGoal(val) {
65
- if (val) {
66
- this.goals.set('default', val);
67
- } else {
68
- this.goals.delete('default');
69
- }
70
- }
71
-
72
- loadStateFromDisk() {
73
- if (!this.storagePath) return;
74
- try {
75
- if (fs.existsSync(this.storagePath)) {
76
- const raw = fs.readFileSync(this.storagePath, 'utf8');
77
- const data = JSON.parse(raw);
78
- if (data && typeof data === 'object') {
79
- if (data.sessions && typeof data.sessions === 'object') {
80
- for (const [sid, goal] of Object.entries(data.sessions)) {
81
- if (goal && goal.id && goal.title) {
82
- this.goals.set(sid, goal);
83
- }
84
- }
85
- } else if (data.id && data.title) {
86
- this.goals.set('default', data);
87
- }
88
- }
89
- }
90
- } catch (err) {
91
- console.warn('[GoalEngine] Failed to load state from disk:', err);
92
- }
93
- }
94
-
95
- scheduleSave(immediate = false) {
96
- if (!this.storagePath) return;
97
- if (immediate) {
98
- if (this.saveTimer) {
99
- clearTimeout(this.saveTimer);
100
- this.saveTimer = null;
101
- }
102
- this.writeStateToDiskSync();
103
- return;
104
- }
105
- if (!this.saveTimer) {
106
- this.saveTimer = setTimeout(() => {
107
- this.saveTimer = null;
108
- this.writeStateToDiskSync();
109
- }, 250);
110
- if (typeof this.saveTimer.unref === 'function') {
111
- this.saveTimer.unref();
112
- }
113
- }
114
- }
115
-
116
- writeStateToDiskSync() {
117
- if (!this.storagePath) return;
118
- try {
119
- if (this.goals.size === 0) {
120
- if (fs.existsSync(this.storagePath)) {
121
- fs.unlinkSync(this.storagePath);
122
- }
123
- return;
124
- }
125
- const sessionsObj = {};
126
- for (const [sid, goal] of this.goals.entries()) {
127
- sessionsObj[sid] = goal;
128
- }
129
- const payload = {
130
- version: 2,
131
- sessions: sessionsObj,
132
- ...(this.goals.has('default') ? this.goals.get('default') : {}),
133
- };
134
- const tmp = `${this.storagePath}.tmp.${Date.now()}`;
135
- fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
136
- fs.renameSync(tmp, this.storagePath);
137
- } catch (err) {
138
- console.warn('[GoalEngine] Failed to write state to disk:', err);
139
- }
140
- }
141
-
142
- flushSync() {
143
- if (this.saveTimer) {
144
- clearTimeout(this.saveTimer);
145
- this.saveTimer = null;
146
- }
147
- this.writeStateToDiskSync();
148
- }
149
-
150
- saveStateToDisk() {
151
- this.flushSync();
152
- }
153
-
154
- /**
155
- * Подписка на изменение состояния
156
- * @param {Function} callback
157
- * @returns {Function} unsubscribe
158
- */
159
- subscribe(callback) {
160
- this.listeners.add(callback);
161
- return () => this.listeners.delete(callback);
162
- }
163
-
164
- emit(sessionId = 'default', immediate = false) {
165
- this.scheduleSave(immediate);
166
- const sid = sessionId || 'default';
167
- const snapshot = this.getSnapshot(sid);
168
- for (const listener of this.listeners) {
169
- try {
170
- listener(snapshot, sid);
171
- } catch (err) {
172
- console.error('[GoalEngine] Listener error:', err);
173
- }
174
- }
175
- }
176
-
177
- /**
178
- * Динамическое обновление настроек на лету
179
- * @param {Object} config
180
- */
181
- updateConfig(config = {}) {
182
- if (typeof config.defaultMaxIterations === 'number' && config.defaultMaxIterations >= 1) {
183
- const prev = this.defaultMaxIterations;
184
- this.defaultMaxIterations = config.defaultMaxIterations;
185
- if (this.currentGoal && this.currentGoal.maxIterations === prev) {
186
- this.currentGoal.maxIterations = config.defaultMaxIterations;
187
- }
188
- }
189
- if (typeof config.autoDrive === 'boolean') {
190
- this.autoDrive = config.autoDrive;
191
- }
192
- if (typeof config.enableSound === 'boolean') {
193
- this.enableSound = config.enableSound;
194
- }
195
- this.emit();
196
- }
197
-
198
- /**
199
- * Запуск новой цели
200
- */
201
- getGoal(sessionId = 'default') {
202
- return this.goals.get(sessionId || 'default') || null;
203
- }
204
-
205
- /**
206
- * Запуск новой цели
207
- * @param {string} title
208
- * @param {Object} options
209
- * @param {string} [sessionId='default']
210
- */
211
- startGoal(title, options = {}, sessionId = 'default') {
212
- if (!title || typeof title !== 'string' || !title.trim()) {
213
- throw new Error('Goal title cannot be empty');
214
- }
215
-
216
- const cleanTitle = title.trim();
217
- const now = Date.now();
218
- const sid = sessionId || 'default';
219
-
220
- const goal = {
221
- id: `goal-${now}-${Math.random().toString(36).substring(2, 7)}`,
222
- sessionId: sid,
223
- title: cleanTitle,
224
- description: options.description?.trim() || '',
225
- state: GoalState.RUNNING,
226
- startedAt: now,
227
- pausedAt: null,
228
- totalPausedDurationMs: 0,
229
- completedAt: null,
230
- iterationsCount: 0,
231
- maxIterations: options.maxIterations ?? this.defaultMaxIterations,
232
- milestones: [],
233
- logs: [
234
- {
235
- timestamp: now,
236
- type: 'info',
237
- message: `Goal initiated: "${cleanTitle}"`,
238
- },
239
- ],
240
- resultSummary: '',
241
- };
242
-
243
- this.goals.set(sid, goal);
244
-
245
- if (Array.isArray(options.milestones) && options.milestones.length > 0) {
246
- this.addMilestones(options.milestones, false, sid);
247
- }
248
-
249
- this.emit(sid, true);
250
- return this.getSnapshot(sid);
251
- }
252
-
253
- /**
254
- * Приостановка автономного цикла цели
255
- */
256
- pause(reason = 'User requested pause', sessionId = 'default') {
257
- const sid = sessionId || 'default';
258
- const goal = this.goals.get(sid);
259
- if (!goal || goal.state !== GoalState.RUNNING) {
260
- return this.getSnapshot(sid);
261
- }
262
-
263
- goal.state = GoalState.PAUSED;
264
- goal.pausedAt = Date.now();
265
- goal.logs.push({
266
- timestamp: Date.now(),
267
- type: 'warning',
268
- message: `Paused: ${reason}`,
269
- });
270
-
271
- this.emit(sid, true);
272
- return this.getSnapshot(sid);
273
- }
274
-
275
- /**
276
- * Возобновление выполнения цели
277
- */
278
- resume(sessionId = 'default') {
279
- const sid = sessionId || 'default';
280
- const goal = this.goals.get(sid);
281
- if (!goal || goal.state !== GoalState.PAUSED) {
282
- return this.getSnapshot(sid);
283
- }
284
-
285
- const now = Date.now();
286
- if (goal.pausedAt) {
287
- goal.totalPausedDurationMs += now - goal.pausedAt;
288
- goal.pausedAt = null;
289
- }
290
-
291
- goal.state = GoalState.RUNNING;
292
- goal.logs.push({
293
- timestamp: now,
294
- type: 'info',
295
- message: 'Goal resumed',
296
- });
297
-
298
- this.emit(sid, true);
299
- return this.getSnapshot(sid);
300
- }
301
-
302
- /**
303
- * Отмена цели
304
- */
305
- cancel(reason = 'Cancelled by user', sessionId = 'default') {
306
- const sid = sessionId || 'default';
307
- const goal = this.goals.get(sid);
308
- if (!goal) return null;
309
-
310
- goal.state = GoalState.CANCELLED;
311
- goal.completedAt = Date.now();
312
- goal.logs.push({
313
- timestamp: Date.now(),
314
- type: 'warning',
315
- message: `Cancelled: ${reason}`,
316
- });
317
-
318
- this.emit(sid, true);
319
- return this.getSnapshot(sid);
320
- }
321
-
322
- /**
323
- * Очистка / сброс цели в IDLE
324
- */
325
- clear(sessionId = 'default') {
326
- const sid = sessionId || 'default';
327
- this.goals.delete(sid);
328
- this.emit(sid, true);
329
- return this.getSnapshot(sid);
330
- }
331
-
332
- /**
333
- * Успешное завершение цели
334
- */
335
- completeGoal(summary = '', sessionId = 'default') {
336
- const sid = sessionId || 'default';
337
- const goal = this.goals.get(sid);
338
- if (!goal) return null;
339
-
340
- const now = Date.now();
341
- goal.state = GoalState.COMPLETED;
342
- goal.completedAt = now;
343
- goal.resultSummary = summary;
344
- goal.logs.push({
345
- timestamp: now,
346
- type: 'info',
347
- message: `Goal completed successfully: ${summary || 'All objectives met.'}`,
348
- });
349
-
350
- // Завершаем все активные milestones
351
- for (const m of goal.milestones) {
352
- if (m.status === MilestoneStatus.IN_PROGRESS || m.status === MilestoneStatus.PENDING) {
353
- m.status = MilestoneStatus.COMPLETED;
354
- }
355
- }
356
-
357
- this.emit(sid, true);
358
- return this.getSnapshot(sid);
359
- }
360
-
361
- /**
362
- * Добавление вех (milestones)
363
- */
364
- addMilestones(milestonesList, shouldEmit = true, sessionId = 'default') {
365
- const sid = sessionId || 'default';
366
- const goal = this.goals.get(sid);
367
- if (!goal || !Array.isArray(milestonesList)) return;
368
-
369
- for (const item of milestonesList) {
370
- const itemTitle = typeof item === 'string' ? item : item.title;
371
- if (!itemTitle || !itemTitle.trim()) continue;
372
-
373
- const mId = (typeof item === 'object' && item.id) ? item.id : `m-${goal.milestones.length + 1}`;
374
- goal.milestones.push({
375
- id: String(mId),
376
- title: itemTitle.trim(),
377
- status: (typeof item === 'object' && item.status) ? item.status : MilestoneStatus.PENDING,
378
- notes: (typeof item === 'object' && item.notes) ? item.notes : '',
379
- });
380
- }
381
-
382
- if (shouldEmit) this.emit(sid);
383
- }
384
-
385
- /**
386
- * Обновление конкретной вехи
387
- */
388
- updateMilestone(id, status, notes = '', sessionId = 'default') {
389
- const sid = sessionId || 'default';
390
- const goal = this.goals.get(sid);
391
- if (!goal) return false;
392
-
393
- const target = goal.milestones.find((m) => m.id === String(id));
394
- if (!target) return false;
395
-
396
- if (status && Object.values(MilestoneStatus).includes(status)) {
397
- target.status = status;
398
- }
399
- if (notes) {
400
- target.notes = notes;
401
- }
402
-
403
- goal.logs.push({
404
- timestamp: Date.now(),
405
- type: 'milestone',
406
- message: `Milestone [${target.title}] status -> ${target.status}`,
407
- });
408
-
409
- this.emit(sid);
410
- return true;
411
- }
412
-
413
- /**
414
- * Увеличение счётчика итераций turn
415
- */
416
- incrementIteration(sessionId = 'default') {
417
- const sid = sessionId || 'default';
418
- const goal = this.goals.get(sid);
419
- if (!goal || goal.state !== GoalState.RUNNING) {
420
- return false;
421
- }
422
-
423
- goal.iterationsCount += 1;
424
-
425
- if (goal.iterationsCount >= goal.maxIterations) {
426
- goal.state = GoalState.FAILED;
427
- goal.logs.push({
428
- timestamp: Date.now(),
429
- type: 'error',
430
- message: `Safety limit reached: maximum ${goal.maxIterations} iterations exceeded.`,
431
- });
432
- this.emit(sid, true);
433
- return false;
434
- }
435
-
436
- this.emit(sid);
437
- return true;
438
- }
439
-
440
- /**
441
- * Подсчёт времени в секундах
442
- */
443
- getElapsedSeconds(sessionId = 'default') {
444
- const sid = sessionId || 'default';
445
- const goal = this.goals.get(sid);
446
- if (!goal) return 0;
447
- const { startedAt, pausedAt, totalPausedDurationMs, completedAt } = goal;
448
- const endTime = completedAt || (pausedAt || Date.now());
449
- const elapsedMs = Math.max(0, endTime - startedAt - totalPausedDurationMs);
450
- return Math.floor(elapsedMs / 1000);
451
- }
452
-
453
- /**
454
- * Снимок состояния для передачи клиенту / API
455
- */
456
- getSnapshot(sessionId = 'default') {
457
- const sid = sessionId || 'default';
458
- const goal = this.goals.get(sid);
459
- if (!goal) {
460
- return {
461
- sessionId: sid,
462
- hasActiveGoal: false,
463
- state: GoalState.IDLE,
464
- title: '',
465
- startedAt: null,
466
- pausedAt: null,
467
- totalPausedDurationMs: 0,
468
- completedAt: null,
469
- elapsedSeconds: 0,
470
- formattedElapsed: '0s',
471
- milestones: [],
472
- progressPercent: 0,
473
- iterationsCount: 0,
474
- maxIterations: this.defaultMaxIterations,
475
- autoDrive: this.autoDrive,
476
- enableSound: this.enableSound,
477
- };
478
- }
479
-
480
- const elapsed = this.getElapsedSeconds(sid);
481
- const milestones = goal.milestones;
482
- const completedCount = milestones.filter((m) => m.status === MilestoneStatus.COMPLETED).length;
483
- const progressPercent = milestones.length > 0 ? Math.round((completedCount / milestones.length) * 100) : 0;
484
-
485
- return {
486
- sessionId: sid,
487
- hasActiveGoal: true,
488
- id: goal.id,
489
- state: goal.state,
490
- title: goal.title,
491
- description: goal.description,
492
- startedAt: goal.startedAt,
493
- pausedAt: goal.pausedAt,
494
- totalPausedDurationMs: goal.totalPausedDurationMs,
495
- completedAt: goal.completedAt,
496
- elapsedSeconds: elapsed,
497
- formattedElapsed: formatElapsed(elapsed),
498
- iterationsCount: goal.iterationsCount,
499
- maxIterations: goal.maxIterations,
500
- milestones,
501
- progressPercent,
502
- logs: goal.logs,
503
- resultSummary: goal.resultSummary,
504
- autoDrive: this.autoDrive,
505
- enableSound: this.enableSound,
506
- };
507
- }
508
-
509
- /**
510
- * Формирование системного контекста для инжекта модели
511
- */
512
- getStatePromptInjection(sessionId = 'default') {
513
- const sid = sessionId || 'default';
514
- const goal = this.goals.get(sid);
515
- if (!goal || goal.state !== GoalState.RUNNING) {
516
- return '';
517
- }
518
-
519
- const snapshot = this.getSnapshot(sid);
520
- const hasMilestones = snapshot.milestones.length > 0;
521
- const milestonesText = hasMilestones
522
- ? snapshot.milestones.map((m, i) => ` ${i + 1}. [${m.status.toUpperCase()}] ${m.title}${m.notes ? ` (${m.notes})` : ''}`).join('\n')
523
- : ' (План работ ещё не сформирован — немедленно вызови goal_set_milestones со списком шагов!)';
524
-
525
- return `\n\n[DSH GOAL MODE ACTIVE]
526
- Цель: "${snapshot.title}"
527
- Время работы: ${snapshot.formattedElapsed} | Итерация: ${snapshot.iterationsCount}/${snapshot.maxIterations}
528
- План работ:
529
- ${milestonesText}
530
-
531
- Инструкции Goal Mode (СТРОГО ОБЯЗАТЕЛЬНЫ К ВЫПОЛНЕНИЮ):
532
- 1. ${hasMilestones ? 'Выполняй текущий активный пункт плана работ.' : 'ТВОЙ ПЕРВЫЙ ШАГ: Немедленно вызови инструмент goal_set_milestones со списком пунктов плана работ (3-7 конкретных шагов). Запрещено выполнять работу или завершать turn без вызова goal_set_milestones!'}
533
- 2. По мере выполнения каждого шага обязательно отмечай его статус через инструмент goal_update_progress (status: "in_progress" перед началом шага, status: "completed" по его завершении с кратким notes).
534
- 3. Когда все пункты плана будут выполнены, вызови инструмент goal_finish с подробным итоговым резюме достигнутых результатов.`;
535
- }
536
- }
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
+ export class GoalEngine {
46
+ constructor(options = {}) {
47
+ this.defaultMaxIterations = options.defaultMaxIterations ?? 25;
48
+ this.autoDrive = options.autoDrive ?? true;
49
+ this.enableSound = options.enableSound ?? true;
50
+ this.maxSessions = options.maxSessions ?? 100;
51
+ this.goals = new Map();
52
+ this.listeners = new Set();
53
+ this.saveTimer = null;
54
+ this.stallCounters = new Map(); // sessionId -> number of consecutive turns without progress
55
+
56
+ const defaultStorageDir = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
57
+ this.storagePath = options.storagePath ?? null;
58
+
59
+ this.loadStateFromDisk();
60
+ }
61
+
62
+ get currentGoal() {
63
+ return this.goals.get('default') || null;
64
+ }
65
+
66
+ set currentGoal(val) {
67
+ if (val) {
68
+ this.goals.set('default', val);
69
+ } else {
70
+ this.goals.delete('default');
71
+ }
72
+ }
73
+
74
+ loadStateFromDisk() {
75
+ if (!this.storagePath) return;
76
+ try {
77
+ if (fs.existsSync(this.storagePath)) {
78
+ const raw = fs.readFileSync(this.storagePath, 'utf8');
79
+ const data = JSON.parse(raw);
80
+ if (data && typeof data === 'object') {
81
+ let dirty = false;
82
+ if (data.sessions && typeof data.sessions === 'object') {
83
+ for (const [sid, goal] of Object.entries(data.sessions)) {
84
+ if (goal && goal.id && goal.title) {
85
+ // Item 4: Crash Hydration если цель осталась в RUNNING после перезапуска/падения DSH,
86
+ // переводим в PAUSED с понятной причиной и фиксацией времени
87
+ if (goal.state === GoalState.RUNNING) {
88
+ goal.state = GoalState.PAUSED;
89
+ goal.pausedAt = Date.now();
90
+ if (!Array.isArray(goal.logs)) goal.logs = [];
91
+ goal.logs.push({
92
+ timestamp: Date.now(),
93
+ type: 'warning',
94
+ message: 'Harness was restarted — click ▶️ to resume',
95
+ });
96
+ if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
97
+ dirty = true;
98
+ }
99
+ this.goals.set(sid, goal);
100
+ }
101
+ }
102
+ } else if (data.id && data.title) {
103
+ if (data.state === GoalState.RUNNING) {
104
+ data.state = GoalState.PAUSED;
105
+ data.pausedAt = Date.now();
106
+ if (!Array.isArray(data.logs)) data.logs = [];
107
+ data.logs.push({
108
+ timestamp: Date.now(),
109
+ type: 'warning',
110
+ message: 'Harness was restarted — click ▶️ to resume',
111
+ });
112
+ if (data.logs.length > 100) data.logs = data.logs.slice(-100);
113
+ dirty = true;
114
+ }
115
+ this.goals.set('default', data);
116
+ }
117
+ if (dirty) {
118
+ this.scheduleSave(true);
119
+ }
120
+ }
121
+ }
122
+ } catch (err) {
123
+ console.warn('[GoalEngine] Failed to load state from disk:', err);
124
+ }
125
+ }
126
+
127
+ scheduleSave(immediate = false) {
128
+ if (!this.storagePath) return;
129
+ if (immediate) {
130
+ if (this.saveTimer) {
131
+ clearTimeout(this.saveTimer);
132
+ this.saveTimer = null;
133
+ }
134
+ this.writeStateToDiskSync();
135
+ return;
136
+ }
137
+ if (!this.saveTimer) {
138
+ this.saveTimer = setTimeout(() => {
139
+ this.saveTimer = null;
140
+ this.writeStateToDiskSync();
141
+ }, 250);
142
+ if (typeof this.saveTimer.unref === 'function') {
143
+ this.saveTimer.unref();
144
+ }
145
+ }
146
+ }
147
+
148
+ writeStateToDiskSync() {
149
+ if (!this.storagePath) return;
150
+ try {
151
+ if (this.goals.size === 0) {
152
+ if (fs.existsSync(this.storagePath)) {
153
+ fs.unlinkSync(this.storagePath);
154
+ }
155
+ return;
156
+ }
157
+ const sessionsObj = {};
158
+ for (const [sid, goal] of this.goals.entries()) {
159
+ sessionsObj[sid] = goal;
160
+ }
161
+ const payload = {
162
+ version: 2,
163
+ sessions: sessionsObj,
164
+ ...(this.goals.has('default') ? this.goals.get('default') : {}),
165
+ };
166
+ const tmp = `${this.storagePath}.tmp.${Date.now()}`;
167
+ fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
168
+ fs.renameSync(tmp, this.storagePath);
169
+ } catch (err) {
170
+ console.warn('[GoalEngine] Failed to write state to disk:', err);
171
+ }
172
+ }
173
+
174
+ flushSync() {
175
+ if (this.saveTimer) {
176
+ clearTimeout(this.saveTimer);
177
+ this.saveTimer = null;
178
+ }
179
+ this.writeStateToDiskSync();
180
+ }
181
+
182
+ saveStateToDisk() {
183
+ this.flushSync();
184
+ }
185
+
186
+ /**
187
+ * Подписка на изменение состояния
188
+ * @param {Function} callback
189
+ * @returns {Function} unsubscribe
190
+ */
191
+ subscribe(callback) {
192
+ this.listeners.add(callback);
193
+ return () => this.listeners.delete(callback);
194
+ }
195
+
196
+ emit(sessionId = 'default', immediate = false) {
197
+ this.scheduleSave(immediate);
198
+ const sid = sessionId || 'default';
199
+ const snapshot = this.getSnapshot(sid);
200
+ for (const listener of this.listeners) {
201
+ try {
202
+ listener(snapshot, sid);
203
+ } catch (err) {
204
+ console.error('[GoalEngine] Listener error:', err);
205
+ }
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Отслеживание прогресса для Smart Progress Guard
211
+ */
212
+ recordProgress(sessionId = 'default') {
213
+ const sid = sessionId || 'default';
214
+ this.stallCounters.set(sid, 0);
215
+ }
216
+
217
+ incrementStallCount(sessionId = 'default') {
218
+ const sid = sessionId || 'default';
219
+ const current = this.stallCounters.get(sid) || 0;
220
+ const next = current + 1;
221
+ this.stallCounters.set(sid, next);
222
+ return next;
223
+ }
224
+
225
+ getStallCount(sessionId = 'default') {
226
+ const sid = sessionId || 'default';
227
+ return this.stallCounters.get(sid) || 0;
228
+ }
229
+
230
+ /**
231
+ * Динамическое обновление настроек на лету
232
+ * @param {Object} config
233
+ */
234
+ updateConfig(config = {}) {
235
+ if (typeof config.defaultMaxIterations === 'number' && config.defaultMaxIterations >= 1) {
236
+ const prev = this.defaultMaxIterations;
237
+ this.defaultMaxIterations = config.defaultMaxIterations;
238
+ for (const [_, goal] of this.goals) {
239
+ if (goal && goal.maxIterations === prev) {
240
+ goal.maxIterations = config.defaultMaxIterations;
241
+ }
242
+ }
243
+ }
244
+ if (typeof config.autoDrive === 'boolean') {
245
+ this.autoDrive = config.autoDrive;
246
+ }
247
+ if (typeof config.enableSound === 'boolean') {
248
+ this.enableSound = config.enableSound;
249
+ }
250
+ this.emit();
251
+ }
252
+
253
+ /**
254
+ * Получение цели сессии
255
+ */
256
+ getGoal(sessionId = 'default') {
257
+ return this.goals.get(sessionId || 'default') || null;
258
+ }
259
+
260
+ /**
261
+ * Очистка старых неактивных сессий во избежание утечек памяти
262
+ */
263
+ pruneInactiveSessions() {
264
+ if (this.goals.size < this.maxSessions) return;
265
+ const inactive = [];
266
+ for (const [sid, goal] of this.goals.entries()) {
267
+ if (sid === 'default') continue;
268
+ if (goal.state === GoalState.COMPLETED || goal.state === GoalState.CANCELLED || goal.state === GoalState.FAILED) {
269
+ inactive.push({ sid, completedAt: goal.completedAt || goal.startedAt || 0 });
270
+ }
271
+ }
272
+ inactive.sort((a, b) => a.completedAt - b.completedAt);
273
+ while (this.goals.size >= this.maxSessions && inactive.length > 0) {
274
+ const oldest = inactive.shift();
275
+ this.goals.delete(oldest.sid);
276
+ this.stallCounters.delete(oldest.sid);
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Запуск новой цели
282
+ * @param {string} title
283
+ * @param {Object} options
284
+ * @param {string} [sessionId='default']
285
+ */
286
+ startGoal(title, options = {}, sessionId = 'default') {
287
+ if (!title || typeof title !== 'string' || !title.trim()) {
288
+ throw new Error('Goal title cannot be empty');
289
+ }
290
+
291
+ this.pruneInactiveSessions();
292
+
293
+ const cleanTitle = title.trim();
294
+ const now = Date.now();
295
+ const sid = sessionId || 'default';
296
+ this.stallCounters.set(sid, 0);
297
+
298
+ const goal = {
299
+ id: `goal-${now}-${Math.random().toString(36).substring(2, 7)}`,
300
+ sessionId: sid,
301
+ title: cleanTitle,
302
+ description: options.description?.trim() || '',
303
+ state: GoalState.RUNNING,
304
+ startedAt: now,
305
+ pausedAt: null,
306
+ totalPausedDurationMs: 0,
307
+ completedAt: null,
308
+ iterationsCount: 0,
309
+ maxIterations: options.maxIterations ?? this.defaultMaxIterations,
310
+ milestones: [],
311
+ logs: [
312
+ {
313
+ timestamp: now,
314
+ type: 'info',
315
+ message: `Goal initiated: "${cleanTitle}"`,
316
+ },
317
+ ],
318
+ resultSummary: '',
319
+ };
320
+
321
+ this.goals.set(sid, goal);
322
+
323
+ if (Array.isArray(options.milestones) && options.milestones.length > 0) {
324
+ this.addMilestones(options.milestones, false, sid);
325
+ }
326
+
327
+ this.emit(sid, true);
328
+ return this.getSnapshot(sid);
329
+ }
330
+
331
+ /**
332
+ * Приостановка автономного цикла цели
333
+ */
334
+ pause(reason = 'User requested pause', sessionId = 'default') {
335
+ const sid = sessionId || 'default';
336
+ const goal = this.goals.get(sid);
337
+ if (!goal || goal.state !== GoalState.RUNNING) {
338
+ return this.getSnapshot(sid);
339
+ }
340
+
341
+ goal.state = GoalState.PAUSED;
342
+ goal.pausedAt = Date.now();
343
+ goal.logs.push({
344
+ timestamp: Date.now(),
345
+ type: 'warning',
346
+ message: `Paused: ${reason}`,
347
+ });
348
+
349
+ if (goal.logs.length > 100) {
350
+ goal.logs = goal.logs.slice(-100);
351
+ }
352
+
353
+ this.emit(sid, true);
354
+ return this.getSnapshot(sid);
355
+ }
356
+
357
+ /**
358
+ * Возобновление выполнения цели
359
+ */
360
+ resume(sessionId = 'default') {
361
+ const sid = sessionId || 'default';
362
+ const goal = this.goals.get(sid);
363
+ if (!goal || goal.state !== GoalState.PAUSED) {
364
+ return this.getSnapshot(sid);
365
+ }
366
+
367
+ const now = Date.now();
368
+ if (goal.pausedAt) {
369
+ goal.totalPausedDurationMs += now - goal.pausedAt;
370
+ goal.pausedAt = null;
371
+ }
372
+
373
+ goal.state = GoalState.RUNNING;
374
+ this.stallCounters.set(sid, 0); // сбрасываем счетчик простоя при возобновлении
375
+
376
+ goal.logs.push({
377
+ timestamp: now,
378
+ type: 'info',
379
+ message: 'Goal resumed',
380
+ });
381
+
382
+ if (goal.logs.length > 100) {
383
+ goal.logs = goal.logs.slice(-100);
384
+ }
385
+
386
+ this.emit(sid, true);
387
+ return this.getSnapshot(sid);
388
+ }
389
+
390
+ /**
391
+ * Отмена цели
392
+ */
393
+ cancel(reason = 'Cancelled by user', sessionId = 'default') {
394
+ const sid = sessionId || 'default';
395
+ const goal = this.goals.get(sid);
396
+ if (!goal) return null;
397
+
398
+ goal.state = GoalState.CANCELLED;
399
+ goal.completedAt = Date.now();
400
+ goal.logs.push({
401
+ timestamp: Date.now(),
402
+ type: 'warning',
403
+ message: `Cancelled: ${reason}`,
404
+ });
405
+
406
+ if (goal.logs.length > 100) {
407
+ goal.logs = goal.logs.slice(-100);
408
+ }
409
+
410
+ this.emit(sid, true);
411
+ return this.getSnapshot(sid);
412
+ }
413
+
414
+ /**
415
+ * Очистка / сброс цели в IDLE
416
+ */
417
+ clear(sessionId = 'default') {
418
+ const sid = sessionId || 'default';
419
+ this.goals.delete(sid);
420
+ this.stallCounters.delete(sid);
421
+ this.emit(sid, true);
422
+ return this.getSnapshot(sid);
423
+ }
424
+
425
+ /**
426
+ * Успешное завершение цели
427
+ */
428
+ completeGoal(summary = '', sessionId = 'default') {
429
+ const sid = sessionId || 'default';
430
+ const goal = this.goals.get(sid);
431
+ if (!goal) return null;
432
+
433
+ const now = Date.now();
434
+ goal.state = GoalState.COMPLETED;
435
+ goal.completedAt = now;
436
+ goal.resultSummary = summary;
437
+ goal.logs.push({
438
+ timestamp: now,
439
+ type: 'info',
440
+ message: `Goal completed successfully: ${summary || 'All objectives met.'}`,
441
+ });
442
+
443
+ if (goal.logs.length > 100) {
444
+ goal.logs = goal.logs.slice(-100);
445
+ }
446
+
447
+ // Завершаем все активные milestones
448
+ for (const m of goal.milestones) {
449
+ if (m.status === MilestoneStatus.IN_PROGRESS || m.status === MilestoneStatus.PENDING) {
450
+ m.status = MilestoneStatus.COMPLETED;
451
+ }
452
+ }
453
+
454
+ this.stallCounters.delete(sid);
455
+ this.emit(sid, true);
456
+ return this.getSnapshot(sid);
457
+ }
458
+
459
+ /**
460
+ * Добавление вех (milestones)
461
+ */
462
+ addMilestones(milestonesList, shouldEmit = true, sessionId = 'default') {
463
+ const sid = sessionId || 'default';
464
+ const goal = this.goals.get(sid);
465
+ if (!goal || !Array.isArray(milestonesList)) return;
466
+
467
+ for (const item of milestonesList) {
468
+ const itemTitle = typeof item === 'string' ? item : item.title;
469
+ if (!itemTitle || !itemTitle.trim()) continue;
470
+
471
+ const mId = (typeof item === 'object' && item.id) ? item.id : `m-${goal.milestones.length + 1}`;
472
+ goal.milestones.push({
473
+ id: String(mId),
474
+ title: itemTitle.trim(),
475
+ status: (typeof item === 'object' && item.status) ? item.status : MilestoneStatus.PENDING,
476
+ notes: (typeof item === 'object' && item.notes) ? item.notes : '',
477
+ });
478
+ }
479
+
480
+ this.recordProgress(sid);
481
+ if (shouldEmit) this.emit(sid);
482
+ }
483
+
484
+ /**
485
+ * Обновление конкретной вехи
486
+ */
487
+ updateMilestone(id, status, notes = '', sessionId = 'default') {
488
+ const sid = sessionId || 'default';
489
+ const goal = this.goals.get(sid);
490
+ if (!goal) return false;
491
+
492
+ const target = goal.milestones.find((m) => m.id === String(id));
493
+ if (!target) return false;
494
+
495
+ if (status && Object.values(MilestoneStatus).includes(status)) {
496
+ target.status = status;
497
+ }
498
+ if (notes) {
499
+ target.notes = String(notes);
500
+ }
501
+
502
+ goal.logs.push({
503
+ timestamp: Date.now(),
504
+ type: 'milestone',
505
+ message: `Milestone [${target.title}] status -> ${target.status}`,
506
+ });
507
+
508
+ if (goal.logs.length > 100) {
509
+ goal.logs = goal.logs.slice(-100);
510
+ }
511
+
512
+ this.recordProgress(sid);
513
+ this.emit(sid);
514
+ return true;
515
+ }
516
+
517
+ /**
518
+ * Увеличение счётчика итераций turn
519
+ */
520
+ incrementIteration(sessionId = 'default') {
521
+ const sid = sessionId || 'default';
522
+ const goal = this.goals.get(sid);
523
+ if (!goal || goal.state !== GoalState.RUNNING) {
524
+ return false;
525
+ }
526
+
527
+ goal.iterationsCount += 1;
528
+
529
+ if (goal.iterationsCount >= goal.maxIterations) {
530
+ goal.state = GoalState.FAILED;
531
+ goal.logs.push({
532
+ timestamp: Date.now(),
533
+ type: 'error',
534
+ message: `Safety limit reached: maximum ${goal.maxIterations} iterations exceeded.`,
535
+ });
536
+ if (goal.logs.length > 100) {
537
+ goal.logs = goal.logs.slice(-100);
538
+ }
539
+ this.emit(sid, true);
540
+ return false;
541
+ }
542
+
543
+ this.emit(sid);
544
+ return true;
545
+ }
546
+
547
+ /**
548
+ * Подсчёт времени в секундах
549
+ */
550
+ getElapsedSeconds(sessionId = 'default') {
551
+ const sid = sessionId || 'default';
552
+ const goal = this.goals.get(sid);
553
+ if (!goal) return 0;
554
+ const { startedAt, pausedAt, totalPausedDurationMs, completedAt } = goal;
555
+ const endTime = completedAt || (pausedAt || Date.now());
556
+ const elapsedMs = Math.max(0, endTime - startedAt - totalPausedDurationMs);
557
+ return Math.floor(elapsedMs / 1000);
558
+ }
559
+
560
+ /**
561
+ * Снимок состояния для передачи клиенту / API
562
+ */
563
+ getSnapshot(sessionId = 'default') {
564
+ const sid = sessionId || 'default';
565
+ const goal = this.goals.get(sid);
566
+ if (!goal) {
567
+ return {
568
+ sessionId: sid,
569
+ hasActiveGoal: false,
570
+ state: GoalState.IDLE,
571
+ title: '',
572
+ startedAt: null,
573
+ pausedAt: null,
574
+ totalPausedDurationMs: 0,
575
+ completedAt: null,
576
+ elapsedSeconds: 0,
577
+ formattedElapsed: '0s',
578
+ milestones: [],
579
+ progressPercent: 0,
580
+ iterationsCount: 0,
581
+ maxIterations: this.defaultMaxIterations,
582
+ autoDrive: this.autoDrive,
583
+ enableSound: this.enableSound,
584
+ };
585
+ }
586
+
587
+ const elapsed = this.getElapsedSeconds(sid);
588
+ const milestones = goal.milestones;
589
+ const completedCount = milestones.filter((m) => m.status === MilestoneStatus.COMPLETED).length;
590
+ const progressPercent = milestones.length > 0 ? Math.round((completedCount / milestones.length) * 100) : 0;
591
+
592
+ return {
593
+ sessionId: sid,
594
+ hasActiveGoal: true,
595
+ id: goal.id,
596
+ state: goal.state,
597
+ title: goal.title,
598
+ description: goal.description,
599
+ startedAt: goal.startedAt,
600
+ pausedAt: goal.pausedAt,
601
+ totalPausedDurationMs: goal.totalPausedDurationMs,
602
+ completedAt: goal.completedAt,
603
+ elapsedSeconds: elapsed,
604
+ formattedElapsed: formatElapsed(elapsed),
605
+ iterationsCount: goal.iterationsCount,
606
+ maxIterations: goal.maxIterations,
607
+ milestones,
608
+ progressPercent,
609
+ logs: goal.logs,
610
+ resultSummary: goal.resultSummary,
611
+ autoDrive: this.autoDrive,
612
+ enableSound: this.enableSound,
613
+ };
614
+ }
615
+
616
+ /**
617
+ * Формирование системного контекста для инжекта модели
618
+ */
619
+ getStatePromptInjection(sessionId = 'default') {
620
+ const sid = sessionId || 'default';
621
+ const goal = this.goals.get(sid);
622
+ if (!goal || goal.state !== GoalState.RUNNING) {
623
+ return '';
624
+ }
625
+
626
+ const snapshot = this.getSnapshot(sid);
627
+ const hasMilestones = snapshot.milestones.length > 0;
628
+ const milestonesText = hasMilestones
629
+ ? snapshot.milestones.map((m, i) => ` ${i + 1}. [${m.status.toUpperCase()}] ${m.title}${m.notes ? ` (${m.notes})` : ''}`).join('\n')
630
+ : ' (План работ ещё не сформирован — немедленно вызови goal_set_milestones со списком шагов!)';
631
+
632
+ return `\n\n[DSH GOAL MODE ACTIVE]
633
+ Цель: "${snapshot.title}"
634
+ Время работы: ${snapshot.formattedElapsed} | Итерация: ${snapshot.iterationsCount}/${snapshot.maxIterations}
635
+ План работ:
636
+ ${milestonesText}
637
+
638
+ Инструкции Goal Mode (СТРОГО ОБЯЗАТЕЛЬНЫ К ВЫПОЛНЕНИЮ):
639
+ 1. ${hasMilestones ? 'Выполняй текущий активный пункт плана работ.' : 'ТВОЙ ПЕРВЫЙ ШАГ: Немедленно вызови инструмент goal_set_milestones со списком пунктов плана работ (3-7 конкретных шагов). Запрещено выполнять работу или завершать turn без вызова goal_set_milestones!'}
640
+ 2. По мере выполнения каждого шага обязательно отмечай его статус через инструмент goal_update_progress (status: "in_progress" перед началом шага, status: "completed" по его завершении с кратким notes).
641
+ 3. Когда все пункты плана будут выполнены, вызови инструмент goal_finish с подробным итоговым резюме достигнутых результатов.`;
642
+ }
643
+ }