@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.
- package/docs/design/DESIGN.md +16 -0
- package/lib/client.js +439 -216
- package/lib/goal-engine.js +643 -536
- package/lib/index.js +122 -6
- package/package.json +1 -1
package/lib/goal-engine.js
CHANGED
|
@@ -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.
|
|
51
|
-
this.
|
|
52
|
-
this.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
this.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
this.goals.
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const sid = sessionId || 'default';
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
*
|
|
255
|
-
*/
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
this.
|
|
328
|
-
this.
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
goal.state = GoalState.
|
|
342
|
-
goal.
|
|
343
|
-
goal.
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
if (
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
if (
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
};
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
};
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
+
}
|