@goodandready/dsh-goal 0.1.5 → 0.1.7

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,192 +1,193 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { GoalState } from './goal-engine.js';
3
-
4
- export const USAGE = 'Использование: /goal [<цель>|clear|pause|resume]';
5
-
6
- /**
7
- * Парсинг строки ввода для команды /goal
8
- * @param {string} rawInput
9
- * @returns {{ action: string, text?: string }}
10
- */
11
- export function parseGoalInput(rawInput = '') {
12
- const input = String(rawInput || '').trim();
13
- if (!input) {
14
- return { action: 'show' };
15
- }
16
-
17
- const lower = input.toLowerCase();
18
- if (lower === 'clear') return { action: 'clear' };
19
- if (lower === 'pause') return { action: 'pause' };
20
- if (lower === 'resume') return { action: 'resume' };
21
-
22
- return { action: 'start', text: input };
23
- }
24
-
25
- /**
26
- * Создание валидного identified сообщения пользователя для отправки агенту через followup
27
- * @param {string} text
28
- * @returns {Object}
29
- */
30
- export function createGoalUserMessage(text) {
31
- return {
32
- id: randomUUID(),
33
- role: 'user',
34
- content: [{ type: 'text', text }],
35
- source: { kind: 'user' },
36
- };
37
- }
38
-
39
- /**
40
- * Формирование стартового промпта с жестким требованием составить план работ
41
- * @param {string} goalText
42
- * @returns {string}
43
- */
44
- export function formatGoalStartPrompt(goalText) {
45
- return `🎯 Активирован режим цели (Goal Mode): "${goalText}"\n\n` +
46
- `СТРОГИЙ КОНТРАКТ АВТОНОМНОГО РЕЖИМА:\n` +
47
- `1. ОБЯЗАТЕЛЬНЫЙ ШАГ №1: Твоим ПЕРВЫМ действием ДО выполнения любой другой работы или ответа должен быть вызов инструмента goal_set_milestones с массивом из 3-7 конкретных, последовательных пунктов плана работ. Пользователь видит этот план в интерфейсе в реальном времени.\n` +
48
- `2. ШАГ №2: После установки плана переходи к выполнению шагов. Перед началом работы над каждым шагом отметь его статус как in_progress через goal_update_progress(milestone_id, "in_progress").\n` +
49
- `3. ШАГ №3: После успешного завершения шага отметь его статус как completed через goal_update_progress(milestone_id, "completed", "краткие итоги шага").\n` +
50
- `4. ШАГ №4: Когда все пункты плана выполнены, вызови инструмент goal_finish(summary) с подробным резюме достигнутых результатов.`;
51
- }
52
-
53
- /**
54
- * Форматирование текстового ответа для UI и постановка/управление задачей агента
55
- * @param {import('./goal-engine.js').GoalEngine} engine
56
- * @param {{ action: string, text?: string }} parsed
57
- * @param {Object} [config]
58
- * @param {Object} [agent]
59
- * @returns {{ kind: 'success' | 'error', text: string }}
60
- */
61
- export function executeGoalSlashCommand(engine, parsed, config = {}, agent = null, sessionId = 'default') {
62
- const sid = sessionId || 'default';
63
- const snap = engine.getSnapshot(sid);
64
-
65
- switch (parsed.action) {
66
- case 'show': {
67
- if (!snap.hasActiveGoal) {
68
- return {
69
- kind: 'success',
70
- text: `🎯 Режим цели (Goal Mode): цель не установлена.\n${USAGE}`,
71
- };
72
- }
73
-
74
- const milestonesInfo = snap.milestones.length > 0
75
- ? `\nПлан работ:\n` + snap.milestones.map((m, i) => ` ${i + 1}. [${m.status}] ${m.title}`).join('\n')
76
- : '';
77
-
78
- return {
79
- kind: 'success',
80
- text: `🎯 Цель: «${snap.title}»\n` +
81
- `Статус: ${snap.state}\n` +
82
- `Время: ${snap.formattedElapsed}\n` +
83
- `Итераций: ${snap.iterationsCount}/${snap.maxIterations}` +
84
- milestonesInfo +
85
- `\n\nКоманды: /goal pause, /goal resume, /goal clear, /goal <новая цель>`,
86
- };
87
- }
88
-
89
- case 'clear': {
90
- if (!snap.hasActiveGoal) {
91
- return {
92
- kind: 'success',
93
- text: 'Цель не была установлена.',
94
- };
95
- }
96
- engine.clear(sid);
97
- if (agent && typeof agent.cancel === 'function') {
98
- try {
99
- agent.cancel({ kind: 'user', reason: 'Goal cleared by user' });
100
- } catch (err) {
101
- console.warn('[dsh-goal] Failed to cancel agent on clear:', err);
102
- }
103
- }
104
- return {
105
- kind: 'success',
106
- text: '🎯 Цель сброшена.',
107
- };
108
- }
109
-
110
- case 'pause': {
111
- if (!snap.hasActiveGoal) {
112
- return {
113
- kind: 'error',
114
- text: `Нельзя приостановить: активная цель отсутствует.\n${USAGE}`,
115
- };
116
- }
117
- if (snap.state === GoalState.PAUSED) {
118
- return {
119
- kind: 'success',
120
- text: '⏸ Цель уже на паузе.',
121
- };
122
- }
123
- engine.pause('Пауза по команде пользователя (/goal pause)', sid);
124
- if (agent && typeof agent.cancel === 'function') {
125
- try {
126
- agent.cancel({ kind: 'user', reason: 'Goal paused by user' });
127
- } catch (err) {
128
- console.warn('[dsh-goal] Failed to cancel agent on pause:', err);
129
- }
130
- }
131
- return {
132
- kind: 'success',
133
- text: `⏸ Цель приостановлена: «${snap.title}». Работа агента остановлена. Используйте /goal resume или кнопку ▶️ для продолжения.`,
134
- };
135
- }
136
-
137
- case 'resume': {
138
- if (!snap.hasActiveGoal) {
139
- return {
140
- kind: 'error',
141
- text: `Нельзя возобновить: активная цель отсутствует.\n${USAGE}`,
142
- };
143
- }
144
- if (snap.state === GoalState.RUNNING) {
145
- return {
146
- kind: 'success',
147
- text: '▶️ Цель уже выполняется.',
148
- };
149
- }
150
- engine.resume(sid);
151
- if (agent && typeof agent.followup === 'function') {
152
- try {
153
- const resumePrompt = `▶️ Цель возобновлена пользователем. Продолжай выполнение плана работ с того места, где остановился. Отмечай шаги через goal_update_progress.`;
154
- agent.followup(createGoalUserMessage(resumePrompt));
155
- } catch (err) {
156
- console.warn('[dsh-goal] Failed to followup agent on resume:', err);
157
- }
158
- }
159
- return {
160
- kind: 'success',
161
- text: `▶️ Цель возобновлена: «${snap.title}». Агент продолжает работу.`,
162
- };
163
- }
164
-
165
- case 'start': {
166
- const maxIterations = config.maxIterations ?? snap.maxIterations ?? 25;
167
- const newSnap = engine.startGoal(parsed.text, { maxIterations }, sid);
168
-
169
- // Если команда вызвана в контексте агента, отправляем цель в очередь модели
170
- if (agent && typeof agent.followup === 'function') {
171
- try {
172
- const userMsg = createGoalUserMessage(formatGoalStartPrompt(parsed.text));
173
- agent.followup(userMsg);
174
- } catch (err) {
175
- console.warn('[dsh-goal] Failed to dispatch goal to agent followup:', err);
176
- }
177
- }
178
-
179
- return {
180
- kind: 'success',
181
- text: `🎯 Активирована цель: «${newSnap.title}» (макс. ${newSnap.maxIterations} итераций).\n` +
182
- `Для паузы: /goal pause | Для сброса: /goal clear`,
183
- };
184
- }
185
-
186
- default:
187
- return {
188
- kind: 'error',
189
- text: `Неизвестная команда. ${USAGE}`,
190
- };
191
- }
192
- }
1
+ import { randomUUID } from 'node:crypto';
2
+ import { GoalState } from './goal-engine.js';
3
+
4
+ export const USAGE = 'Использование: /goal [<цель>|clear|pause|resume]';
5
+
6
+ /**
7
+ * Парсинг строки ввода для команды /goal
8
+ * @param {string} rawInput
9
+ * @returns {{ action: string, text?: string }}
10
+ */
11
+ export function parseGoalInput(rawInput = '') {
12
+ const input = String(rawInput || '').trim();
13
+ if (!input) {
14
+ return { action: 'show' };
15
+ }
16
+
17
+ const lower = input.toLowerCase();
18
+ if (lower === 'clear') return { action: 'clear' };
19
+ if (lower === 'pause') return { action: 'pause' };
20
+ if (lower === 'resume') return { action: 'resume' };
21
+
22
+ return { action: 'start', text: input };
23
+ }
24
+
25
+ /**
26
+ * Создание валидного identified сообщения пользователя для отправки агенту через followup
27
+ * @param {string} text
28
+ * @returns {Object}
29
+ */
30
+ export function createGoalUserMessage(text) {
31
+ return {
32
+ id: randomUUID(),
33
+ role: 'user',
34
+ content: [{ type: 'text', text }],
35
+ source: { kind: 'user' },
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Формирование стартового промпта с жестким требованием составить план работ
41
+ * @param {string} goalText
42
+ * @returns {string}
43
+ */
44
+ export function formatGoalStartPrompt(goalText) {
45
+ return `🎯 Активирован режим цели (Goal Mode): "${goalText}"\n\n` +
46
+ `СТРОГИЙ КОНТРАКТ АВТОНОМНОГО РЕЖИМА:\n` +
47
+ `1. ОБЯЗАТЕЛЬНЫЙ ШАГ №1: Твоим ПЕРВЫМ действием ДО выполнения любой другой работы или ответа должен быть вызов инструмента goal_set_milestones с массивом из 3-7 конкретных, последовательных пунктов плана работ. Пользователь видит этот план в интерфейсе в реальном времени.\n` +
48
+ `2. ШАГ №2: После установки плана переходи к выполнению шагов. Перед началом работы над каждым шагом отметь его статус как in_progress через goal_update_progress(milestone_id, "in_progress").\n` +
49
+ `3. ШАГ №3: После успешного завершения шага отметь его статус как completed через goal_update_progress(milestone_id, "completed", "краткие итоги шага").\n` +
50
+ `4. ШАГ №4: Когда все пункты плана выполнены, вызови инструмент goal_finish(summary) с подробным резюме достигнутых результатов.`;
51
+ }
52
+
53
+ /**
54
+ * Форматирование текстового ответа для UI и постановка/управление задачей агента
55
+ * @param {import('./goal-engine.js').GoalEngine} engine
56
+ * @param {{ action: string, text?: string }} parsed
57
+ * @param {Object} [config]
58
+ * @param {Object} [agent]
59
+ * @param {string} [sessionId='default']
60
+ * @returns {{ kind: 'success' | 'error', text: string }}
61
+ */
62
+ export function executeGoalSlashCommand(engine, parsed, config = {}, agent = null, sessionId = 'default') {
63
+ const sid = sessionId || 'default';
64
+ const snap = engine.getSnapshot(sid);
65
+
66
+ switch (parsed.action) {
67
+ case 'show': {
68
+ if (!snap.hasActiveGoal) {
69
+ return {
70
+ kind: 'success',
71
+ text: `🎯 Режим цели (Goal Mode): цель не установлена.\n${USAGE}`,
72
+ };
73
+ }
74
+
75
+ const milestonesInfo = snap.milestones.length > 0
76
+ ? `\nПлан работ:\n` + snap.milestones.map((m, i) => ` ${i + 1}. [${m.status}] ${m.title}`).join('\n')
77
+ : '';
78
+
79
+ return {
80
+ kind: 'success',
81
+ text: `🎯 Цель: «${snap.title}»\n` +
82
+ `Статус: ${snap.state}\n` +
83
+ `Время: ${snap.formattedElapsed}\n` +
84
+ `Итераций: ${snap.iterationsCount}/${snap.maxIterations}` +
85
+ milestonesInfo +
86
+ `\n\nКоманды: /goal pause, /goal resume, /goal clear, /goal <новая цель>`,
87
+ };
88
+ }
89
+
90
+ case 'clear': {
91
+ if (!snap.hasActiveGoal) {
92
+ return {
93
+ kind: 'success',
94
+ text: 'Цель не была установлена.',
95
+ };
96
+ }
97
+ engine.clear(sid);
98
+ if (agent && typeof agent.cancel === 'function') {
99
+ try {
100
+ agent.cancel({ kind: 'user', reason: 'Goal cleared by user' });
101
+ } catch (err) {
102
+ console.warn('[dsh-goal] Failed to cancel agent on clear:', err);
103
+ }
104
+ }
105
+ return {
106
+ kind: 'success',
107
+ text: '🎯 Цель сброшена.',
108
+ };
109
+ }
110
+
111
+ case 'pause': {
112
+ if (!snap.hasActiveGoal) {
113
+ return {
114
+ kind: 'error',
115
+ text: `Нельзя приостановить: активная цель отсутствует.\n${USAGE}`,
116
+ };
117
+ }
118
+ if (snap.state === GoalState.PAUSED) {
119
+ return {
120
+ kind: 'success',
121
+ text: '⏸ Цель уже на паузе.',
122
+ };
123
+ }
124
+ engine.pause('Пауза по команде пользователя (/goal pause)', sid);
125
+ if (agent && typeof agent.cancel === 'function') {
126
+ try {
127
+ agent.cancel({ kind: 'user', reason: 'Goal paused by user' });
128
+ } catch (err) {
129
+ console.warn('[dsh-goal] Failed to cancel agent on pause:', err);
130
+ }
131
+ }
132
+ return {
133
+ kind: 'success',
134
+ text: `⏸ Цель приостановлена: «${snap.title}». Работа агента остановлена. Используйте /goal resume или кнопку ▶️ для продолжения.`,
135
+ };
136
+ }
137
+
138
+ case 'resume': {
139
+ if (!snap.hasActiveGoal) {
140
+ return {
141
+ kind: 'error',
142
+ text: `Нельзя возобновить: активная цель отсутствует.\n${USAGE}`,
143
+ };
144
+ }
145
+ if (snap.state === GoalState.RUNNING) {
146
+ return {
147
+ kind: 'success',
148
+ text: '▶️ Цель уже выполняется.',
149
+ };
150
+ }
151
+ engine.resume(sid);
152
+ if (agent && typeof agent.followup === 'function') {
153
+ try {
154
+ const resumePrompt = `▶️ Цель возобновлена пользователем. Продолжай выполнение плана работ с того места, где остановился. Отмечай шаги через goal_update_progress.`;
155
+ agent.followup(createGoalUserMessage(resumePrompt));
156
+ } catch (err) {
157
+ console.warn('[dsh-goal] Failed to followup agent on resume:', err);
158
+ }
159
+ }
160
+ return {
161
+ kind: 'success',
162
+ text: `▶️ Цель возобновлена: «${snap.title}». Агент продолжает работу.`,
163
+ };
164
+ }
165
+
166
+ case 'start': {
167
+ const maxIterations = config.maxIterations ?? snap.maxIterations ?? 25;
168
+ const newSnap = engine.startGoal(parsed.text, { maxIterations }, sid);
169
+
170
+ // Если команда вызвана в контексте агента, отправляем цель в очередь модели
171
+ if (agent && typeof agent.followup === 'function') {
172
+ try {
173
+ const userMsg = createGoalUserMessage(formatGoalStartPrompt(parsed.text));
174
+ agent.followup(userMsg);
175
+ } catch (err) {
176
+ console.warn('[dsh-goal] Failed to dispatch goal to agent followup:', err);
177
+ }
178
+ }
179
+
180
+ return {
181
+ kind: 'success',
182
+ text: `🎯 Активирована цель: «${newSnap.title}» (макс. ${newSnap.maxIterations} итераций).\n` +
183
+ `Для паузы: /goal pause | Для сброса: /goal clear`,
184
+ };
185
+ }
186
+
187
+ default:
188
+ return {
189
+ kind: 'error',
190
+ text: `Неизвестная команда. ${USAGE}`,
191
+ };
192
+ }
193
+ }
@@ -51,6 +51,7 @@ export class GoalEngine {
51
51
  this.goals = new Map();
52
52
  this.listeners = new Set();
53
53
  this.saveTimer = null;
54
+ this.stallCounters = new Map(); // sessionId -> number of consecutive turns without progress
54
55
 
55
56
  const defaultStorageDir = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
56
57
  this.storagePath = options.storagePath ?? null;
@@ -77,15 +78,45 @@ export class GoalEngine {
77
78
  const raw = fs.readFileSync(this.storagePath, 'utf8');
78
79
  const data = JSON.parse(raw);
79
80
  if (data && typeof data === 'object') {
81
+ let dirty = false;
80
82
  if (data.sessions && typeof data.sessions === 'object') {
81
83
  for (const [sid, goal] of Object.entries(data.sessions)) {
82
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
+ }
83
99
  this.goals.set(sid, goal);
84
100
  }
85
101
  }
86
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
+ }
87
115
  this.goals.set('default', data);
88
116
  }
117
+ if (dirty) {
118
+ this.scheduleSave(true);
119
+ }
89
120
  }
90
121
  }
91
122
  } catch (err) {
@@ -132,6 +163,10 @@ export class GoalEngine {
132
163
  sessions: sessionsObj,
133
164
  ...(this.goals.has('default') ? this.goals.get('default') : {}),
134
165
  };
166
+ const dir = path.dirname(this.storagePath);
167
+ if (!fs.existsSync(dir)) {
168
+ fs.mkdirSync(dir, { recursive: true });
169
+ }
135
170
  const tmp = `${this.storagePath}.tmp.${Date.now()}`;
136
171
  fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
137
172
  fs.renameSync(tmp, this.storagePath);
@@ -175,6 +210,27 @@ export class GoalEngine {
175
210
  }
176
211
  }
177
212
 
213
+ /**
214
+ * Отслеживание прогресса для Smart Progress Guard
215
+ */
216
+ recordProgress(sessionId = 'default') {
217
+ const sid = sessionId || 'default';
218
+ this.stallCounters.set(sid, 0);
219
+ }
220
+
221
+ incrementStallCount(sessionId = 'default') {
222
+ const sid = sessionId || 'default';
223
+ const current = this.stallCounters.get(sid) || 0;
224
+ const next = current + 1;
225
+ this.stallCounters.set(sid, next);
226
+ return next;
227
+ }
228
+
229
+ getStallCount(sessionId = 'default') {
230
+ const sid = sessionId || 'default';
231
+ return this.stallCounters.get(sid) || 0;
232
+ }
233
+
178
234
  /**
179
235
  * Динамическое обновление настроек на лету
180
236
  * @param {Object} config
@@ -199,7 +255,7 @@ export class GoalEngine {
199
255
  }
200
256
 
201
257
  /**
202
- * Запуск новой цели
258
+ * Получение цели сессии
203
259
  */
204
260
  getGoal(sessionId = 'default') {
205
261
  return this.goals.get(sessionId || 'default') || null;
@@ -217,11 +273,11 @@ export class GoalEngine {
217
273
  inactive.push({ sid, completedAt: goal.completedAt || goal.startedAt || 0 });
218
274
  }
219
275
  }
220
- // Сортируем от самых старых к новым
221
276
  inactive.sort((a, b) => a.completedAt - b.completedAt);
222
277
  while (this.goals.size >= this.maxSessions && inactive.length > 0) {
223
278
  const oldest = inactive.shift();
224
279
  this.goals.delete(oldest.sid);
280
+ this.stallCounters.delete(oldest.sid);
225
281
  }
226
282
  }
227
283
 
@@ -241,6 +297,7 @@ export class GoalEngine {
241
297
  const cleanTitle = title.trim();
242
298
  const now = Date.now();
243
299
  const sid = sessionId || 'default';
300
+ this.stallCounters.set(sid, 0);
244
301
 
245
302
  const goal = {
246
303
  id: `goal-${now}-${Math.random().toString(36).substring(2, 7)}`,
@@ -318,6 +375,8 @@ export class GoalEngine {
318
375
  }
319
376
 
320
377
  goal.state = GoalState.RUNNING;
378
+ this.stallCounters.set(sid, 0); // сбрасываем счетчик простоя при возобновлении
379
+
321
380
  goal.logs.push({
322
381
  timestamp: now,
323
382
  type: 'info',
@@ -362,6 +421,7 @@ export class GoalEngine {
362
421
  clear(sessionId = 'default') {
363
422
  const sid = sessionId || 'default';
364
423
  this.goals.delete(sid);
424
+ this.stallCounters.delete(sid);
365
425
  this.emit(sid, true);
366
426
  return this.getSnapshot(sid);
367
427
  }
@@ -395,6 +455,7 @@ export class GoalEngine {
395
455
  }
396
456
  }
397
457
 
458
+ this.stallCounters.delete(sid);
398
459
  this.emit(sid, true);
399
460
  return this.getSnapshot(sid);
400
461
  }
@@ -420,6 +481,7 @@ export class GoalEngine {
420
481
  });
421
482
  }
422
483
 
484
+ this.recordProgress(sid);
423
485
  if (shouldEmit) this.emit(sid);
424
486
  }
425
487
 
@@ -451,6 +513,7 @@ export class GoalEngine {
451
513
  goal.logs = goal.logs.slice(-100);
452
514
  }
453
515
 
516
+ this.recordProgress(sid);
454
517
  this.emit(sid);
455
518
  return true;
456
519
  }