@goodandready/dsh-goal 0.1.1 → 0.1.2
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/README.md +14 -0
- package/README.ru.md +14 -0
- package/docs/design/DESIGN.md +9 -5
- package/lib/card-form-state.js +110 -0
- package/lib/client.js +700 -159
- package/lib/command-handler.js +189 -0
- package/lib/goal-engine.js +86 -6
- package/lib/index.js +279 -13
- package/package.json +10 -6
package/lib/index.js
CHANGED
|
@@ -1,34 +1,243 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import z from '@deepseek-ai/schemastery';
|
|
1
4
|
import { GoalEngine, GoalState, MilestoneStatus } from './goal-engine.js';
|
|
5
|
+
import { parseGoalInput, executeGoalSlashCommand, createGoalUserMessage } from './command-handler.js';
|
|
2
6
|
|
|
3
7
|
export const name = '@goodandready/dsh-goal';
|
|
4
8
|
export const inject = ['webServer', 'settings'];
|
|
5
9
|
|
|
6
10
|
const NS = 'dsh-goal';
|
|
7
11
|
|
|
12
|
+
// Схема-функция: в rc.1 settings.register(NS, schema, { base }) ожидает
|
|
13
|
+
// schemastery-схему вторым аргументом.
|
|
14
|
+
// Issue #24: storagePath объявлен в схеме конфигурации плагина
|
|
15
|
+
export const Config = z.object({
|
|
16
|
+
maxIterations: z.number().default(25).description('Safety limit: max autonomous iterations per goal'),
|
|
17
|
+
autoDrive: z.boolean().default(true).description('Automatically continue the loop after each turn'),
|
|
18
|
+
enableSound: z.boolean().default(true).description('Play a sound when a goal completes'),
|
|
19
|
+
storagePath: z.string().default('').description('Custom filesystem path for persistent state storage'),
|
|
20
|
+
});
|
|
21
|
+
|
|
8
22
|
export function apply(ctx, config = {}) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
23
|
+
let settingsScope = null;
|
|
24
|
+
let agentsService = null;
|
|
25
|
+
const runningAgents = new Set();
|
|
26
|
+
let lastActiveAgent = null;
|
|
12
27
|
|
|
13
28
|
let currentSettings = {
|
|
14
29
|
maxIterations: config?.maxIterations ?? 25,
|
|
15
30
|
autoDrive: config?.autoDrive ?? true,
|
|
16
31
|
enableSound: config?.enableSound ?? true,
|
|
32
|
+
storagePath: config?.storagePath,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const defaultStorageDir = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
|
|
36
|
+
const storagePath = currentSettings.storagePath !== undefined && currentSettings.storagePath !== null
|
|
37
|
+
? currentSettings.storagePath
|
|
38
|
+
: path.join(defaultStorageDir, 'dsh-goal-state.json');
|
|
39
|
+
|
|
40
|
+
const engine = new GoalEngine({
|
|
41
|
+
defaultMaxIterations: currentSettings.maxIterations,
|
|
42
|
+
autoDrive: currentSettings.autoDrive,
|
|
43
|
+
enableSound: currentSettings.enableSound,
|
|
44
|
+
storagePath,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Динамическое внедрение сервиса agents для управления жизненным циклом
|
|
48
|
+
ctx.inject(['agents'], (actx) => {
|
|
49
|
+
agentsService = actx.agents;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Отслеживание выполняющихся агентов через глобальное событие ядра DSH
|
|
53
|
+
ctx.on?.('agent/status', ({ agent, status }) => {
|
|
54
|
+
if (status === 'running') {
|
|
55
|
+
runningAgents.add(agent);
|
|
56
|
+
lastActiveAgent = agent;
|
|
57
|
+
} else {
|
|
58
|
+
runningAgents.delete(agent);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const stopRunningAgents = () => {
|
|
63
|
+
// 1. Отмена всех агентов, находящихся в активном выполнении
|
|
64
|
+
for (const ag of runningAgents) {
|
|
65
|
+
try {
|
|
66
|
+
if (typeof ag.cancel === 'function') {
|
|
67
|
+
ag.cancel({ kind: 'user' });
|
|
68
|
+
}
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.warn('[dsh-goal] Failed to cancel running agent:', err);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
runningAgents.clear();
|
|
74
|
+
|
|
75
|
+
// 2. Отмена последнего известного агента
|
|
76
|
+
if (lastActiveAgent && lastActiveAgent.status === 'running' && typeof lastActiveAgent.cancel === 'function') {
|
|
77
|
+
try {
|
|
78
|
+
lastActiveAgent.cancel({ kind: 'user' });
|
|
79
|
+
} catch (err) {
|
|
80
|
+
console.warn('[dsh-goal] Failed to cancel lastActiveAgent:', err);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 3. Дополнительная проверка через agentsService.list()
|
|
85
|
+
if (agentsService && typeof agentsService.list === 'function') {
|
|
86
|
+
try {
|
|
87
|
+
for (const ag of agentsService.list()) {
|
|
88
|
+
if (ag && ag.status === 'running' && typeof ag.cancel === 'function') {
|
|
89
|
+
try {
|
|
90
|
+
ag.cancel({ kind: 'user' });
|
|
91
|
+
} catch (_) {}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.warn('[dsh-goal] Failed to cancel agents from list:', err);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const resumeActiveAgent = (promptText) => {
|
|
101
|
+
const resumeMsg = createGoalUserMessage(
|
|
102
|
+
promptText || '▶️ Цель возобновлена пользователем. Продолжай выполнение плана работ с того места, где остановился. Отмечай шаги через goal_update_progress.',
|
|
103
|
+
);
|
|
104
|
+
let target = null;
|
|
105
|
+
if (lastActiveAgent && typeof lastActiveAgent.followup === 'function') {
|
|
106
|
+
target = lastActiveAgent;
|
|
107
|
+
} else if (agentsService && typeof agentsService.list === 'function') {
|
|
108
|
+
const list = agentsService.list();
|
|
109
|
+
if (list && list.length > 0) {
|
|
110
|
+
target = list[list.length - 1];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (target && typeof target.followup === 'function') {
|
|
115
|
+
try {
|
|
116
|
+
target.followup(resumeMsg);
|
|
117
|
+
lastActiveAgent = target;
|
|
118
|
+
return true;
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.warn('[dsh-goal] Failed to resume agent:', err);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Issue #21: getConfig reads snap.value ONLY when status is 'ready' (or status is undefined in unit test mocks)
|
|
127
|
+
const getConfig = () => {
|
|
128
|
+
if (settingsScope) {
|
|
129
|
+
const snap = typeof settingsScope.getSnapshot === 'function' ? settingsScope.getSnapshot() : null;
|
|
130
|
+
if (snap) {
|
|
131
|
+
if (snap.status === 'ready' || snap.status === undefined) {
|
|
132
|
+
const live = snap.value || (typeof settingsScope.get === 'function' ? settingsScope.get() : null);
|
|
133
|
+
if (live && typeof live === 'object') {
|
|
134
|
+
return {
|
|
135
|
+
maxIterations: typeof live.maxIterations === 'number' ? live.maxIterations : currentSettings.maxIterations,
|
|
136
|
+
autoDrive: typeof live.autoDrive === 'boolean' ? live.autoDrive : currentSettings.autoDrive,
|
|
137
|
+
enableSound: typeof live.enableSound === 'boolean' ? live.enableSound : currentSettings.enableSound,
|
|
138
|
+
storagePath: typeof live.storagePath === 'string' ? live.storagePath : currentSettings.storagePath,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} else if (typeof settingsScope.get === 'function') {
|
|
143
|
+
const live = settingsScope.get();
|
|
144
|
+
if (live && typeof live === 'object') {
|
|
145
|
+
return {
|
|
146
|
+
maxIterations: typeof live.maxIterations === 'number' ? live.maxIterations : currentSettings.maxIterations,
|
|
147
|
+
autoDrive: typeof live.autoDrive === 'boolean' ? live.autoDrive : currentSettings.autoDrive,
|
|
148
|
+
enableSound: typeof live.enableSound === 'boolean' ? live.enableSound : currentSettings.enableSound,
|
|
149
|
+
storagePath: typeof live.storagePath === 'string' ? live.storagePath : currentSettings.storagePath,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return currentSettings;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const applySettings = () => {
|
|
158
|
+
const live = getConfig();
|
|
159
|
+
engine.updateConfig({
|
|
160
|
+
defaultMaxIterations: live.maxIterations,
|
|
161
|
+
autoDrive: live.autoDrive,
|
|
162
|
+
enableSound: live.enableSound,
|
|
163
|
+
});
|
|
17
164
|
};
|
|
18
165
|
|
|
19
166
|
// 1. Регистрация настроек плагина
|
|
20
167
|
ctx.inject(['settings'], (sctx) => {
|
|
21
168
|
try {
|
|
22
|
-
const scope = sctx.settings?.register?.(NS,
|
|
169
|
+
const scope = sctx.settings?.register?.(NS, Config, { base: currentSettings });
|
|
23
170
|
if (scope) {
|
|
24
|
-
|
|
171
|
+
settingsScope = scope;
|
|
172
|
+
applySettings();
|
|
173
|
+
if (typeof scope.subscribe === 'function') {
|
|
174
|
+
sctx.effect(() => {
|
|
175
|
+
const off = scope.subscribe(() => {
|
|
176
|
+
applySettings();
|
|
177
|
+
});
|
|
178
|
+
return () => {
|
|
179
|
+
if (typeof off === 'function') off();
|
|
180
|
+
settingsScope = null;
|
|
181
|
+
};
|
|
182
|
+
}, 'dsh-goal: settings subscription');
|
|
183
|
+
}
|
|
25
184
|
}
|
|
26
185
|
} catch (err) {
|
|
27
186
|
console.warn('[dsh-goal] Settings register skipped:', err.message);
|
|
28
187
|
}
|
|
29
188
|
});
|
|
30
189
|
|
|
31
|
-
// 2. Регистрация
|
|
190
|
+
// 2. Регистрация слэш-команды /goal в чате
|
|
191
|
+
ctx.inject(['commands'], (cctx) => {
|
|
192
|
+
try {
|
|
193
|
+
if (typeof cctx.commands?.register !== 'function') return;
|
|
194
|
+
|
|
195
|
+
const unregister = cctx.commands.register({
|
|
196
|
+
name: 'goal',
|
|
197
|
+
description: 'Управление режимом цели (Goal Mode): активация, вехи, пауза, сброс',
|
|
198
|
+
input: { hint: '[<цель>|clear|pause|resume]' },
|
|
199
|
+
handler: async (invocation) => {
|
|
200
|
+
if (invocation?.agent) {
|
|
201
|
+
lastActiveAgent = invocation.agent;
|
|
202
|
+
}
|
|
203
|
+
const parsed = parseGoalInput(invocation?.rawInput);
|
|
204
|
+
const liveConfig = getConfig();
|
|
205
|
+
return executeGoalSlashCommand(engine, parsed, liveConfig, invocation?.agent);
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (typeof unregister === 'function') {
|
|
210
|
+
cctx.effect(() => () => unregister(), 'dsh-goal: /goal command unregister');
|
|
211
|
+
}
|
|
212
|
+
} catch (err) {
|
|
213
|
+
console.warn('[dsh-goal] Commands register skipped:', err.message);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// 3. Инжекция контекста цели в системный промпт (systemPrompt)
|
|
218
|
+
ctx.inject(['systemPrompt'], (pctx) => {
|
|
219
|
+
try {
|
|
220
|
+
if (typeof pctx.systemPrompt?.section === 'function') {
|
|
221
|
+
const order = typeof pctx.systemPrompt.getSectionOrder === 'function'
|
|
222
|
+
? (pctx.systemPrompt.getSectionOrder('TOOL_GOAL') || 600)
|
|
223
|
+
: 600;
|
|
224
|
+
|
|
225
|
+
const unregisterSection = pctx.systemPrompt.section({
|
|
226
|
+
name: 'tool:dsh-goal',
|
|
227
|
+
order,
|
|
228
|
+
text: () => engine.getStatePromptInjection(),
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
if (typeof unregisterSection === 'function') {
|
|
232
|
+
pctx.effect(() => () => unregisterSection(), 'dsh-goal: system prompt section');
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} catch (err) {
|
|
236
|
+
console.warn('[dsh-goal] SystemPrompt section register skipped:', err.message);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// 4. Регистрация инструментов для модели (tools)
|
|
32
241
|
ctx.inject(['tools'], (tctx) => {
|
|
33
242
|
if (!tctx.tools?.register) return;
|
|
34
243
|
|
|
@@ -120,7 +329,7 @@ export function apply(ctx, config = {}) {
|
|
|
120
329
|
});
|
|
121
330
|
});
|
|
122
331
|
|
|
123
|
-
//
|
|
332
|
+
// 5. Регистрация HTTP REST API маршрутов
|
|
124
333
|
ctx.effect(() => {
|
|
125
334
|
if (!ctx.webServer?.register) return () => {};
|
|
126
335
|
|
|
@@ -140,7 +349,29 @@ export function apply(ctx, config = {}) {
|
|
|
140
349
|
}
|
|
141
350
|
|
|
142
351
|
// POST /dsh-goal/action
|
|
352
|
+
// Issue #22: CSRF & Same-Origin guard
|
|
143
353
|
if (req.method === 'POST' && (pathname === '/dsh-goal/action' || pathname === '/dsh-goal/action/')) {
|
|
354
|
+
const secFetchSite = req.headers['sec-fetch-site'];
|
|
355
|
+
if (secFetchSite && secFetchSite !== 'same-origin' && secFetchSite !== 'same-site' && secFetchSite !== 'none') {
|
|
356
|
+
res.statusCode = 403;
|
|
357
|
+
return res.end(JSON.stringify({ error: 'Forbidden: cross-site requests are rejected' }));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const origin = req.headers.origin;
|
|
361
|
+
const host = req.headers.host;
|
|
362
|
+
if (origin && host) {
|
|
363
|
+
try {
|
|
364
|
+
const originHost = new URL(origin).host;
|
|
365
|
+
if (originHost !== host) {
|
|
366
|
+
res.statusCode = 403;
|
|
367
|
+
return res.end(JSON.stringify({ error: 'Forbidden: origin mismatch' }));
|
|
368
|
+
}
|
|
369
|
+
} catch (_) {
|
|
370
|
+
res.statusCode = 403;
|
|
371
|
+
return res.end(JSON.stringify({ error: 'Forbidden: invalid origin' }));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
144
375
|
let body = '';
|
|
145
376
|
req.on('data', (chunk) => { body += chunk; });
|
|
146
377
|
req.on('end', () => {
|
|
@@ -151,19 +382,26 @@ export function apply(ctx, config = {}) {
|
|
|
151
382
|
let result = null;
|
|
152
383
|
switch (action) {
|
|
153
384
|
case 'start':
|
|
154
|
-
result = engine.startGoal(title || 'Новая цель', {
|
|
385
|
+
result = engine.startGoal(title || 'Новая цель', {
|
|
386
|
+
description,
|
|
387
|
+
maxIterations: getConfig().maxIterations,
|
|
388
|
+
});
|
|
155
389
|
break;
|
|
156
390
|
case 'pause':
|
|
157
|
-
result = engine.pause(reason);
|
|
391
|
+
result = engine.pause(reason || 'Пауза по кнопке интерфейса');
|
|
392
|
+
stopRunningAgents();
|
|
158
393
|
break;
|
|
159
394
|
case 'resume':
|
|
160
395
|
result = engine.resume();
|
|
396
|
+
resumeActiveAgent();
|
|
161
397
|
break;
|
|
162
398
|
case 'cancel':
|
|
163
|
-
result = engine.cancel(reason);
|
|
399
|
+
result = engine.cancel(reason || 'Отмена цели');
|
|
400
|
+
stopRunningAgents();
|
|
164
401
|
break;
|
|
165
402
|
case 'clear':
|
|
166
403
|
result = engine.clear();
|
|
404
|
+
stopRunningAgents();
|
|
167
405
|
break;
|
|
168
406
|
case 'update_milestone':
|
|
169
407
|
engine.updateMilestone(milestoneId, status, notes);
|
|
@@ -194,13 +432,41 @@ export function apply(ctx, config = {}) {
|
|
|
194
432
|
};
|
|
195
433
|
}, 'dsh-goal: HTTP WebServer Routes');
|
|
196
434
|
|
|
197
|
-
//
|
|
435
|
+
// 6. Подписка на события сессии (автономный цикл)
|
|
198
436
|
ctx.effect(() => {
|
|
199
437
|
// Подписка на завершение turn
|
|
200
|
-
const onTurnEnd = () => {
|
|
438
|
+
const onTurnEnd = (turn) => {
|
|
439
|
+
const liveConfig = getConfig();
|
|
440
|
+
if (!liveConfig.autoDrive) return; // Учитываем настройку autoDrive в рантайме
|
|
441
|
+
|
|
201
442
|
const snap = engine.getSnapshot();
|
|
202
443
|
if (snap.hasActiveGoal && snap.state === GoalState.RUNNING) {
|
|
203
|
-
engine.incrementIteration();
|
|
444
|
+
const canContinue = engine.incrementIteration();
|
|
445
|
+
if (!canContinue) return;
|
|
446
|
+
|
|
447
|
+
// Если turn не был прерван пользователем и цель ещё активна, отправляем следующий ход
|
|
448
|
+
if (turn?.reason?.kind !== 'aborted') {
|
|
449
|
+
setTimeout(() => {
|
|
450
|
+
const currentSnap = engine.getSnapshot();
|
|
451
|
+
if (currentSnap.hasActiveGoal && currentSnap.state === GoalState.RUNNING) {
|
|
452
|
+
const promptMsg = createGoalUserMessage(
|
|
453
|
+
'Продолжай автономное выполнение цели согласно плану работ. Отмечай каждый выполненный шаг через goal_update_progress, а по завершении всех задач вызови goal_finish.',
|
|
454
|
+
);
|
|
455
|
+
let target = lastActiveAgent;
|
|
456
|
+
if (!target && agentsService && typeof agentsService.list === 'function') {
|
|
457
|
+
const list = agentsService.list();
|
|
458
|
+
if (list && list.length > 0) target = list[list.length - 1];
|
|
459
|
+
}
|
|
460
|
+
if (target && typeof target.followup === 'function') {
|
|
461
|
+
try {
|
|
462
|
+
target.followup(promptMsg);
|
|
463
|
+
} catch (err) {
|
|
464
|
+
console.warn('[dsh-goal] Failed auto-drive followup:', err);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}, 300);
|
|
469
|
+
}
|
|
204
470
|
}
|
|
205
471
|
};
|
|
206
472
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-goal",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Goal mode & autonomous execution plugin for DeepSeek Harness with sticky top banner",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -44,14 +44,18 @@
|
|
|
44
44
|
},
|
|
45
45
|
"client": {
|
|
46
46
|
"platform": "web",
|
|
47
|
-
"inject": [
|
|
48
|
-
"@deepseek-ai/dsh-client-runtime",
|
|
49
|
-
"@deepseek-ai/dsh-client-ui-slots"
|
|
50
|
-
]
|
|
47
|
+
"inject": []
|
|
51
48
|
}
|
|
52
49
|
},
|
|
53
50
|
"peerDependencies": {
|
|
54
51
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
55
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6"
|
|
52
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
|
|
53
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
56
60
|
}
|
|
57
61
|
}
|