@goodandready/dsh-goal 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,433 +1,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.currentGoal = null;
51
- this.listeners = new Set();
52
-
53
- const defaultStorageDir = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
54
- this.storagePath = options.storagePath ?? null;
55
-
56
- this.loadStateFromDisk();
57
- }
58
-
59
- loadStateFromDisk() {
60
- if (!this.storagePath) return;
61
- try {
62
- if (fs.existsSync(this.storagePath)) {
63
- const raw = fs.readFileSync(this.storagePath, 'utf8');
64
- const data = JSON.parse(raw);
65
- if (data && typeof data === 'object' && data.id && data.title) {
66
- this.currentGoal = data;
67
- }
68
- }
69
- } catch (err) {
70
- console.warn('[GoalEngine] Failed to load state from disk:', err);
71
- }
72
- }
73
-
74
- saveStateToDisk() {
75
- if (!this.storagePath) return;
76
- try {
77
- if (this.currentGoal) {
78
- fs.writeFileSync(this.storagePath, JSON.stringify(this.currentGoal, null, 2), 'utf8');
79
- } else {
80
- if (fs.existsSync(this.storagePath)) {
81
- fs.unlinkSync(this.storagePath);
82
- }
83
- }
84
- } catch (err) {
85
- console.warn('[GoalEngine] Failed to save state to disk:', err);
86
- }
87
- }
88
-
89
- /**
90
- * Подписка на изменение состояния
91
- * @param {Function} callback
92
- * @returns {Function} unsubscribe
93
- */
94
- subscribe(callback) {
95
- this.listeners.add(callback);
96
- return () => this.listeners.delete(callback);
97
- }
98
-
99
- emit() {
100
- this.saveStateToDisk();
101
- const snapshot = this.getSnapshot();
102
- for (const listener of this.listeners) {
103
- try {
104
- listener(snapshot);
105
- } catch (err) {
106
- console.error('[GoalEngine] Listener error:', err);
107
- }
108
- }
109
- }
110
-
111
- /**
112
- * Динамическое обновление настроек на лету
113
- * @param {Object} config
114
- */
115
- updateConfig(config = {}) {
116
- if (typeof config.defaultMaxIterations === 'number' && config.defaultMaxIterations >= 1) {
117
- const prev = this.defaultMaxIterations;
118
- this.defaultMaxIterations = config.defaultMaxIterations;
119
- if (this.currentGoal && this.currentGoal.maxIterations === prev) {
120
- this.currentGoal.maxIterations = config.defaultMaxIterations;
121
- }
122
- }
123
- if (typeof config.autoDrive === 'boolean') {
124
- this.autoDrive = config.autoDrive;
125
- }
126
- if (typeof config.enableSound === 'boolean') {
127
- this.enableSound = config.enableSound;
128
- }
129
- this.emit();
130
- }
131
-
132
- /**
133
- * Запуск новой цели
134
- */
135
- startGoal(title, options = {}) {
136
- if (!title || typeof title !== 'string' || !title.trim()) {
137
- throw new Error('Goal title cannot be empty');
138
- }
139
-
140
- const cleanTitle = title.trim();
141
- const now = Date.now();
142
-
143
- this.currentGoal = {
144
- id: `goal-${now}-${Math.random().toString(36).substring(2, 7)}`,
145
- title: cleanTitle,
146
- description: options.description?.trim() || '',
147
- state: GoalState.RUNNING,
148
- startedAt: now,
149
- pausedAt: null,
150
- totalPausedDurationMs: 0,
151
- completedAt: null,
152
- iterationsCount: 0,
153
- maxIterations: options.maxIterations ?? this.defaultMaxIterations,
154
- milestones: [],
155
- logs: [
156
- {
157
- timestamp: now,
158
- type: 'info',
159
- message: `Goal initiated: "${cleanTitle}"`,
160
- },
161
- ],
162
- resultSummary: '',
163
- };
164
-
165
- if (Array.isArray(options.milestones) && options.milestones.length > 0) {
166
- this.addMilestones(options.milestones, false);
167
- }
168
-
169
- this.emit();
170
- return this.getSnapshot();
171
- }
172
-
173
- /**
174
- * Приостановка автономного цикла цели
175
- */
176
- pause(reason = 'User requested pause') {
177
- if (!this.currentGoal || this.currentGoal.state !== GoalState.RUNNING) {
178
- return this.getSnapshot();
179
- }
180
-
181
- this.currentGoal.state = GoalState.PAUSED;
182
- this.currentGoal.pausedAt = Date.now();
183
- this.currentGoal.logs.push({
184
- timestamp: Date.now(),
185
- type: 'warning',
186
- message: `Paused: ${reason}`,
187
- });
188
-
189
- this.emit();
190
- return this.getSnapshot();
191
- }
192
-
193
- /**
194
- * Возобновление выполнения цели
195
- */
196
- resume() {
197
- if (!this.currentGoal || this.currentGoal.state !== GoalState.PAUSED) {
198
- return this.getSnapshot();
199
- }
200
-
201
- const now = Date.now();
202
- if (this.currentGoal.pausedAt) {
203
- this.currentGoal.totalPausedDurationMs += now - this.currentGoal.pausedAt;
204
- this.currentGoal.pausedAt = null;
205
- }
206
-
207
- this.currentGoal.state = GoalState.RUNNING;
208
- this.currentGoal.logs.push({
209
- timestamp: now,
210
- type: 'info',
211
- message: 'Goal resumed',
212
- });
213
-
214
- this.emit();
215
- return this.getSnapshot();
216
- }
217
-
218
- /**
219
- * Отмена цели
220
- */
221
- cancel(reason = 'Cancelled by user') {
222
- if (!this.currentGoal) return null;
223
-
224
- this.currentGoal.state = GoalState.CANCELLED;
225
- this.currentGoal.completedAt = Date.now();
226
- this.currentGoal.logs.push({
227
- timestamp: Date.now(),
228
- type: 'warning',
229
- message: `Cancelled: ${reason}`,
230
- });
231
-
232
- this.emit();
233
- return this.getSnapshot();
234
- }
235
-
236
- /**
237
- * Очистка / сброс цели в IDLE
238
- */
239
- clear() {
240
- this.currentGoal = null;
241
- this.emit();
242
- return this.getSnapshot();
243
- }
244
-
245
- /**
246
- * Успешное завершение цели
247
- */
248
- completeGoal(summary = '') {
249
- if (!this.currentGoal) return null;
250
-
251
- const now = Date.now();
252
- this.currentGoal.state = GoalState.COMPLETED;
253
- this.currentGoal.completedAt = now;
254
- this.currentGoal.resultSummary = summary;
255
- this.currentGoal.logs.push({
256
- timestamp: now,
257
- type: 'info',
258
- message: `Goal completed successfully: ${summary || 'All objectives met.'}`,
259
- });
260
-
261
- // Завершаем все активные milestones
262
- for (const m of this.currentGoal.milestones) {
263
- if (m.status === MilestoneStatus.IN_PROGRESS || m.status === MilestoneStatus.PENDING) {
264
- m.status = MilestoneStatus.COMPLETED;
265
- }
266
- }
267
-
268
- this.emit();
269
- return this.getSnapshot();
270
- }
271
-
272
- /**
273
- * Добавление вех (milestones)
274
- */
275
- addMilestones(milestonesList, shouldEmit = true) {
276
- if (!this.currentGoal || !Array.isArray(milestonesList)) return;
277
-
278
- for (const item of milestonesList) {
279
- const itemTitle = typeof item === 'string' ? item : item.title;
280
- if (!itemTitle || !itemTitle.trim()) continue;
281
-
282
- const mId = (typeof item === 'object' && item.id) ? item.id : `m-${this.currentGoal.milestones.length + 1}`;
283
- this.currentGoal.milestones.push({
284
- id: String(mId),
285
- title: itemTitle.trim(),
286
- status: (typeof item === 'object' && item.status) ? item.status : MilestoneStatus.PENDING,
287
- notes: (typeof item === 'object' && item.notes) ? item.notes : '',
288
- });
289
- }
290
-
291
- if (shouldEmit) this.emit();
292
- }
293
-
294
- /**
295
- * Обновление конкретной вехи
296
- */
297
- updateMilestone(id, status, notes = '') {
298
- if (!this.currentGoal) return false;
299
-
300
- const target = this.currentGoal.milestones.find((m) => m.id === String(id));
301
- if (!target) return false;
302
-
303
- if (status && Object.values(MilestoneStatus).includes(status)) {
304
- target.status = status;
305
- }
306
- if (notes) {
307
- target.notes = notes;
308
- }
309
-
310
- this.currentGoal.logs.push({
311
- timestamp: Date.now(),
312
- type: 'milestone',
313
- message: `Milestone [${target.title}] status -> ${target.status}`,
314
- });
315
-
316
- this.emit();
317
- return true;
318
- }
319
-
320
- /**
321
- * Увеличение счётчика итераций turn
322
- */
323
- incrementIteration() {
324
- if (!this.currentGoal || this.currentGoal.state !== GoalState.RUNNING) {
325
- return false;
326
- }
327
-
328
- this.currentGoal.iterationsCount += 1;
329
-
330
- if (this.currentGoal.iterationsCount >= this.currentGoal.maxIterations) {
331
- this.currentGoal.state = GoalState.FAILED;
332
- this.currentGoal.logs.push({
333
- timestamp: Date.now(),
334
- type: 'error',
335
- message: `Safety limit reached: maximum ${this.currentGoal.maxIterations} iterations exceeded.`,
336
- });
337
- this.emit();
338
- return false;
339
- }
340
-
341
- this.emit();
342
- return true;
343
- }
344
-
345
- /**
346
- * Подсчёт времени в секундах
347
- */
348
- getElapsedSeconds() {
349
- if (!this.currentGoal) return 0;
350
- const { startedAt, pausedAt, totalPausedDurationMs, completedAt } = this.currentGoal;
351
- const endTime = completedAt || (pausedAt || Date.now());
352
- const elapsedMs = Math.max(0, endTime - startedAt - totalPausedDurationMs);
353
- return Math.floor(elapsedMs / 1000);
354
- }
355
-
356
- /**
357
- * Снимок состояния для передачи клиенту / API
358
- */
359
- getSnapshot() {
360
- if (!this.currentGoal) {
361
- return {
362
- hasActiveGoal: false,
363
- state: GoalState.IDLE,
364
- title: '',
365
- startedAt: null,
366
- pausedAt: null,
367
- totalPausedDurationMs: 0,
368
- completedAt: null,
369
- elapsedSeconds: 0,
370
- formattedElapsed: '0s',
371
- milestones: [],
372
- progressPercent: 0,
373
- iterationsCount: 0,
374
- maxIterations: this.defaultMaxIterations,
375
- autoDrive: this.autoDrive,
376
- enableSound: this.enableSound,
377
- };
378
- }
379
-
380
- const elapsed = this.getElapsedSeconds();
381
- const milestones = this.currentGoal.milestones;
382
- const completedCount = milestones.filter((m) => m.status === MilestoneStatus.COMPLETED).length;
383
- const progressPercent = milestones.length > 0 ? Math.round((completedCount / milestones.length) * 100) : 0;
384
-
385
- return {
386
- hasActiveGoal: true,
387
- id: this.currentGoal.id,
388
- state: this.currentGoal.state,
389
- title: this.currentGoal.title,
390
- description: this.currentGoal.description,
391
- startedAt: this.currentGoal.startedAt,
392
- pausedAt: this.currentGoal.pausedAt,
393
- totalPausedDurationMs: this.currentGoal.totalPausedDurationMs,
394
- completedAt: this.currentGoal.completedAt,
395
- elapsedSeconds: elapsed,
396
- formattedElapsed: formatElapsed(elapsed),
397
- iterationsCount: this.currentGoal.iterationsCount,
398
- maxIterations: this.currentGoal.maxIterations,
399
- milestones,
400
- progressPercent,
401
- logs: this.currentGoal.logs,
402
- resultSummary: this.currentGoal.resultSummary,
403
- autoDrive: this.autoDrive,
404
- enableSound: this.enableSound,
405
- };
406
- }
407
-
408
- /**
409
- * Формирование системного контекста для инжекта модели
410
- */
411
- getStatePromptInjection() {
412
- if (!this.currentGoal || this.currentGoal.state !== GoalState.RUNNING) {
413
- return '';
414
- }
415
-
416
- const snapshot = this.getSnapshot();
417
- const hasMilestones = snapshot.milestones.length > 0;
418
- const milestonesText = hasMilestones
419
- ? snapshot.milestones.map((m, i) => ` ${i + 1}. [${m.status.toUpperCase()}] ${m.title}${m.notes ? ` (${m.notes})` : ''}`).join('\n')
420
- : ' (План работ ещё не сформирован — немедленно вызови goal_set_milestones со списком шагов!)';
421
-
422
- return `\n\n[DSH GOAL MODE ACTIVE]
423
- Цель: "${snapshot.title}"
424
- Время работы: ${snapshot.formattedElapsed} | Итерация: ${snapshot.iterationsCount}/${snapshot.maxIterations}
425
- План работ:
426
- ${milestonesText}
427
-
428
- Инструкции Goal Mode:
429
- 1. ${hasMilestones ? 'Выполняй текущий активный пункт плана работ.' : 'ТВОЙ ПЕРВЫЙ ШАГ: Немедленно вызови инструмент goal_set_milestones со списком пунктов плана работ.'}
430
- 2. По мере выполнения каждого шага отмечай его статус через инструмент goal_update_progress (status: "in_progress" перед началом, "completed" по завершении).
431
- 3. Когда все пункты плана будут выполнены, вызови инструмент goal_finish с итоговым резюме.`;
432
- }
433
- }
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
+ }