@ddtcorex/dsh-maestro-supervisor 0.5.2 → 0.5.3

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/lib/cli.js CHANGED
@@ -44,9 +44,40 @@ Commands:
44
44
  pollHealth: () => pollHealth(),
45
45
  writeLKG: () => writeLKG(dshHome, lkgRoot),
46
46
  writeFailed: () => writeLKG(dshHome, failedRoot),
47
- writeReport: async ({ ts, health, action }) => {
48
- const { writeReport } = await import('./report.js');
49
- return writeReport({ reportsRoot, ts, health, gitDiff: '', logTail: '', action });
47
+ writeReport: async ({ ts, health, action, logTail, gitDiff }) => {
48
+ const { writeReport, collectGitDiff } = await import('./report.js');
49
+ const { collectLogTail } = await import('./health-poller.js');
50
+ // Prefer supervisor-provided tail/diff (from health.logTail); fallback to live collect
51
+ let tail = logTail ?? health.logTail ?? '';
52
+ if (!tail) {
53
+ try {
54
+ tail = await collectLogTail();
55
+ }
56
+ catch {
57
+ tail = '';
58
+ }
59
+ }
60
+ let diff = gitDiff ?? '';
61
+ if (!diff) {
62
+ try {
63
+ const harnessRoot = process.env.MAESTRO_HARNESS_ROOT ?? path.join(os.homedir(), 'Work/htdocs/maestro-harness');
64
+ diff = await collectGitDiff(harnessRoot).catch(() => '');
65
+ if (!diff) {
66
+ // fallback: try git diff in cwd
67
+ const { execSync } = await import('node:child_process');
68
+ try {
69
+ diff = execSync('git diff 2>/dev/null | head -n 200', { encoding: 'utf-8', timeout: 2000 });
70
+ }
71
+ catch {
72
+ diff = '';
73
+ }
74
+ }
75
+ }
76
+ catch {
77
+ diff = '';
78
+ }
79
+ }
80
+ return writeReport({ reportsRoot, ts, health, gitDiff: diff, logTail: tail, action });
50
81
  },
51
82
  rollback: async () => {
52
83
  // Find latest LKG and restore
@@ -143,8 +143,22 @@ function buildPrompt(ctx) {
143
143
  }
144
144
  function tryApplyLLMFix(response, writeFile, exec) {
145
145
  try {
146
- // Try to parse JSON block from response
147
- const jsonMatch = response.match(/\{[\s\S]*\}/);
146
+ // First try to extract inner JSON if response is OpenAI wrapper (choices) — unwrap message.content
147
+ let candidate = response;
148
+ try {
149
+ const outer = JSON.parse(response);
150
+ if (outer.choices?.[0]?.message?.content)
151
+ candidate = outer.choices[0].message.content;
152
+ else if (outer.output) {
153
+ // openai-responses wrapper: find message with output_text
154
+ const msg = outer.output.find((o) => o.type === 'message' && o.content?.[0]?.text);
155
+ if (msg)
156
+ candidate = msg.content[0].text;
157
+ }
158
+ }
159
+ catch { }
160
+ // Also handle case where response is already the inner JSON string or contains it
161
+ const jsonMatch = candidate.match(/\{[\s\S]*\}/);
148
162
  if (!jsonMatch)
149
163
  return false;
150
164
  const obj = JSON.parse(jsonMatch[0]);
@@ -163,7 +177,19 @@ function tryApplyLLMFix(response, writeFile, exec) {
163
177
  }
164
178
  function extractSummary(response) {
165
179
  try {
166
- const m = response.match(/\{[\s\S]*\}/);
180
+ let candidate = response;
181
+ try {
182
+ const outer = JSON.parse(response);
183
+ if (outer.choices?.[0]?.message?.content)
184
+ candidate = outer.choices[0].message.content;
185
+ else if (outer.output) {
186
+ const msg = outer.output.find((o) => o.type === 'message' && o.content?.[0]?.text);
187
+ if (msg)
188
+ candidate = msg.content[0].text;
189
+ }
190
+ }
191
+ catch { }
192
+ const m = candidate.match(/\{[\s\S]*\}/);
167
193
  if (m) {
168
194
  const obj = JSON.parse(m[0]);
169
195
  if (obj.analysis)
@@ -206,51 +232,144 @@ async function defaultFetchLLM(prompt) {
206
232
  const cfg = await resolveLLMConfig();
207
233
  if (!cfg.key)
208
234
  throw new Error('no api key — set DEEPSEEK_API_KEY / OMNI_ROUTE_API_KEY / OPENCODE_GO_API_KEY / OPENAI_API_KEY or ~/.dsh/.credentials.yaml or AI_API_KEY');
209
- const res = await fetch(cfg.url, {
235
+ // Build URLs from cfg.url (may be base or full endpoint). Support any provider/model — try completions then responses.
236
+ const base = cfg.url.replace(/\/v1\/(chat\/completions|responses).*/, '/v1').replace(/\/$/, '');
237
+ const completionsUrl = cfg.url.includes('/chat/completions') ? cfg.url : (cfg.url.includes('/responses') ? cfg.url.replace('/responses', '/chat/completions') : `${base}/chat/completions`);
238
+ const responsesUrl = cfg.url.includes('/responses') ? cfg.url : `${base}/responses`;
239
+ // Try openai-completions first (works for deepseek-v4, openai, openrouter, omni-route completions)
240
+ try {
241
+ const res = await fetch(completionsUrl, {
242
+ method: 'POST',
243
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${cfg.key}` },
244
+ body: JSON.stringify({
245
+ model: cfg.model,
246
+ temperature: 0.2,
247
+ messages: [
248
+ { role: 'system', content: 'You are a systematic-debugging agent for DSH Web resilience. Follow the 4 phases: root cause, pattern analysis, hypothesis, implementation. Always propose minimal single-file fix.' },
249
+ { role: 'user', content: prompt },
250
+ ],
251
+ }),
252
+ });
253
+ if (res.ok) {
254
+ const j = await res.json();
255
+ const content = j.choices?.[0]?.message?.content;
256
+ if (content)
257
+ return content;
258
+ // Some providers return different shape but still ok — return stringified
259
+ return JSON.stringify(j);
260
+ }
261
+ // If completions fails, fall through to responses for providers that use openai-responses (e.g. opencode-go muse-spark)
262
+ const txt = await res.text().catch(() => '');
263
+ // Only fall through for 4xx/5xx that likely indicate wrong API — otherwise throw
264
+ if (res.status >= 400 && res.status < 600) {
265
+ // try responses as fallback for any provider/model
266
+ }
267
+ else {
268
+ throw new Error(`LLM ${res.status} ${txt}`);
269
+ }
270
+ }
271
+ catch (e) {
272
+ // Network error — if completionsUrl was tried and failed, try responses as generic fallback
273
+ if (!e.message?.includes('LLM ')) {
274
+ // will try responses below
275
+ }
276
+ else {
277
+ throw e;
278
+ }
279
+ }
280
+ // Fallback: try openai-responses (used by opencode-go muse-spark, minimax-m3, qwen3.7, etc.) — works for any provider that supports it
281
+ const res2 = await fetch(responsesUrl, {
210
282
  method: 'POST',
211
- headers: {
212
- 'Content-Type': 'application/json',
213
- 'Authorization': `Bearer ${cfg.key}`,
214
- },
283
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${cfg.key}` },
215
284
  body: JSON.stringify({
216
285
  model: cfg.model,
217
- temperature: 0.2,
218
- messages: [
286
+ input: [
219
287
  { role: 'system', content: 'You are a systematic-debugging agent for DSH Web resilience. Follow the 4 phases: root cause, pattern analysis, hypothesis, implementation. Always propose minimal single-file fix.' },
220
288
  { role: 'user', content: prompt },
221
289
  ],
290
+ max_output_tokens: 4000,
291
+ reasoning: { effort: 'low' },
222
292
  }),
223
293
  });
224
- if (!res.ok)
225
- throw new Error(`LLM ${res.status} ${await res.text().catch(() => '')}`);
226
- const j = await res.json();
227
- return j.choices?.[0]?.message?.content ?? JSON.stringify(j);
294
+ if (!res2.ok)
295
+ throw new Error(`LLM ${res2.status} ${await res2.text().catch(() => '')}`);
296
+ const j2 = await res2.json();
297
+ // Extract text from responses output (opencode-go) or completions fallback
298
+ if (j2.output) {
299
+ const msg = j2.output.find((o) => o.type === 'message' && o.content?.[0]?.text);
300
+ if (msg)
301
+ return msg.content[0].text;
302
+ }
303
+ return j2.choices?.[0]?.message?.content ?? JSON.stringify(j2);
228
304
  }
229
305
  async function resolveLLMConfig() {
230
- // Custom AI provider: env overrides, then credentials.yaml, then settings.json, then defaults
231
- // Url: AI_API_URL / DEEPSEEK_API_URL / OPENAI_API_BASE / OMNI_ROUTE_API_URL -> default deepseek
232
- const url = process.env.AI_API_URL ??
306
+ // Custom AI provider: env overrides, then settings.yaml (llm-pi-ai providers, DeepSeek suggested setup), then settings.json, then defaults
307
+ // Supports any provider/model the user configures via DeepSeek harness (opencode-go, omni-route, deepseek, openrouter, etc.)
308
+ let url = process.env.AI_API_URL ??
233
309
  process.env.DEAPSEEK_API_URL ??
234
310
  process.env.DEEPSEEK_API_URL ??
235
311
  process.env.OPENAI_API_BASE ??
236
312
  process.env.OMNI_ROUTE_API_URL ??
237
313
  process.env.OPENCODE_API_URL ??
238
- 'https://api.deepseek.com/v1/chat/completions';
239
- // Model: AI_MODEL / DEEPSEEK_MODEL / settings.json domains.review.model
240
- let model = process.env.AI_MODEL ?? process.env.DEEPSEEK_MODEL ?? process.env.OPENAI_MODEL ?? 'deepseek-chat';
241
- try {
242
- const { readFileSync } = await import('node:fs');
243
- const { homedir } = await import('node:os');
244
- const settingsPath = `${homedir()}/.dsh/maestro/settings.json`;
245
- const raw = readFileSync(settingsPath, 'utf-8');
246
- const j = JSON.parse(raw);
247
- const m = j?.domains?.review?.model?.model ?? j?.domains?.review?.model;
248
- if (typeof m === 'string' && !process.env.AI_MODEL && !process.env.DEEPSEEK_MODEL)
249
- model = m;
250
- else if (m?.model && typeof m.model === 'string' && !process.env.AI_MODEL)
251
- model = m.model;
314
+ null;
315
+ // Model: AI_MODEL / DEEPSEEK_MODEL / settings.json domains.review.model / default
316
+ let model = process.env.AI_MODEL ?? process.env.DEEPSEEK_MODEL ?? process.env.OPENAI_MODEL ?? null;
317
+ // Try settings.json first (review model is the user's current default model)
318
+ if (!model) {
319
+ try {
320
+ const { readFileSync } = await import('node:fs');
321
+ const { homedir } = await import('node:os');
322
+ const settingsPath = `${homedir()}/.dsh/maestro/settings.json`;
323
+ const raw = readFileSync(settingsPath, 'utf-8');
324
+ const j = JSON.parse(raw);
325
+ const m = j?.domains?.review?.model?.model ?? j?.domains?.review?.model;
326
+ if (typeof m === 'string')
327
+ model = m;
328
+ else if (m?.model && typeof m.model === 'string')
329
+ model = m.model;
330
+ }
331
+ catch { }
252
332
  }
253
- catch { }
333
+ if (!model)
334
+ model = 'deepseek-chat';
335
+ // If url not set via env, try to resolve from ~/.dsh/settings.yaml llm-pi-ai providers (DeepSeek suggested setup)
336
+ if (!url) {
337
+ try {
338
+ const { readFileSync } = await import('node:fs');
339
+ const { homedir } = await import('node:os');
340
+ const yamlPath = `${homedir()}/.dsh/settings.yaml`;
341
+ const yaml = readFileSync(yamlPath, 'utf-8');
342
+ // Find provider that owns the current model (e.g. muse-spark -> opencode-go)
343
+ // settings.yaml structure: llm-pi-ai: providers: <provider>: { baseURL, models: [{id}] }
344
+ // Use regex to extract provider blocks
345
+ const providerBlocks = [...yaml.matchAll(/^ {4}(\S+):\s*\n([\s\S]*?)(?=^ {4}\S+:|\n\S)/gm)];
346
+ for (const [, provider, block] of providerBlocks) {
347
+ if (block.includes(`id: ${model}`)) {
348
+ const m = block.match(/baseURL:\s*(\S+)/);
349
+ if (m) {
350
+ url = m[1].trim();
351
+ break;
352
+ }
353
+ }
354
+ }
355
+ // Also check agent-default-model provider
356
+ if (!url) {
357
+ const defM = yaml.match(/agent-default-model:\s*\n\s*provider:\s*(\S+)/);
358
+ if (defM) {
359
+ const prov = defM[1].trim();
360
+ const provBlock = yaml.match(new RegExp(`^ {4}${prov}:\\s*\\n([\\s\\S]*?)(?=^ {4}\\S+:|\\n\\S)`, 'm'));
361
+ if (provBlock) {
362
+ const m = provBlock[1].match(/baseURL:\s*(\S+)/);
363
+ if (m)
364
+ url = m[1].trim();
365
+ }
366
+ }
367
+ }
368
+ }
369
+ catch { }
370
+ }
371
+ if (!url)
372
+ url = 'https://api.deepseek.com/v1/chat/completions';
254
373
  // Key: AI_API_KEY / DEEPSEEK_API_KEY / OMNI_ROUTE_API_KEY / OPENCODE_GO_API_KEY / OPENAI_API_KEY
255
374
  let key = process.env.AI_API_KEY ??
256
375
  process.env.DEEPSEEK_API_KEY ??
@@ -3,6 +3,7 @@ export interface HealthState {
3
3
  httpCode?: number;
4
4
  error?: string;
5
5
  degraded?: boolean;
6
+ logTail?: string;
6
7
  }
7
8
  export interface PollHealthOpts {
8
9
  fetch?: () => Promise<{
@@ -15,3 +16,4 @@ export interface PollHealthOpts {
15
16
  timeoutMs?: number;
16
17
  }
17
18
  export declare function pollHealth(opts?: PollHealthOpts): Promise<HealthState>;
19
+ export declare function collectLogTail(): Promise<string>;
@@ -53,6 +53,7 @@ export async function pollHealth(opts = {}) {
53
53
  httpCode,
54
54
  error: logError ? `${fetchError} + ${logError}` : fetchError,
55
55
  degraded: false,
56
+ logTail: logContent.slice(-5000),
56
57
  };
57
58
  }
58
59
  if (logError) {
@@ -63,6 +64,7 @@ export async function pollHealth(opts = {}) {
63
64
  httpCode,
64
65
  error: logError,
65
66
  degraded: true,
67
+ logTail: logContent.slice(-5000),
66
68
  };
67
69
  }
68
70
  return {
@@ -70,6 +72,7 @@ export async function pollHealth(opts = {}) {
70
72
  httpCode,
71
73
  error: logError,
72
74
  degraded: false,
75
+ logTail: logContent.slice(-5000),
73
76
  };
74
77
  }
75
78
  // Also check psAlive as secondary signal — if fetch ok but ps dead, still down
@@ -82,7 +85,7 @@ export async function pollHealth(opts = {}) {
82
85
  catch {
83
86
  // ignore
84
87
  }
85
- return { up: httpCode === 200, httpCode };
88
+ return { up: httpCode === 200, httpCode, logTail: logContent.slice(-5000) };
86
89
  }
87
90
  function defaultFetch(url, timeoutMs) {
88
91
  return async () => {
@@ -108,22 +111,50 @@ async function defaultPsAlive() {
108
111
  return true;
109
112
  }
110
113
  }
111
- async function defaultLogTail() {
114
+ export async function collectLogTail() {
112
115
  try {
113
- const { readFileSync } = await import('node:fs');
116
+ const { readFileSync, existsSync, statSync } = await import('node:fs');
114
117
  const { homedir } = await import('node:os');
115
- const candidates = [`${homedir()}/.dsh/dsh-web.log`, `${homedir()}/.dsh.log`];
118
+ const candidates = [
119
+ `${homedir()}/.dsh/dsh-web.log`,
120
+ `${homedir()}/.dsh/.supervisor/supervisor.log`,
121
+ `${homedir()}/.dsh.log`,
122
+ ];
116
123
  for (const logPath of candidates) {
117
124
  try {
125
+ if (!existsSync(logPath))
126
+ continue;
127
+ // avoid reading huge files fully — if >1MB, read tail via shell
128
+ try {
129
+ const sz = statSync(logPath).size;
130
+ if (sz > 1024 * 1024) {
131
+ const { execSync } = await import('node:child_process');
132
+ const out = execSync(`tail -c 5000 ${JSON.stringify(logPath)} 2>/dev/null || cat ${JSON.stringify(logPath)} 2>/dev/null | tail -c 5000`, { encoding: 'utf-8', timeout: 2000 });
133
+ if (out)
134
+ return out.slice(-5000);
135
+ }
136
+ }
137
+ catch { }
118
138
  const content = readFileSync(logPath, 'utf-8');
119
- if (content)
139
+ if (content && content.trim())
120
140
  return content.slice(-5000);
121
141
  }
122
142
  catch { }
123
143
  }
144
+ // fallback: try journalctl for the dsh-web or supervisor units (if running via systemd)
145
+ try {
146
+ const { execSync } = await import('node:child_process');
147
+ const journal = execSync('journalctl --user -u dsh-web-supervisor --no-pager -n 100 2>/dev/null | tail -c 5000 || journalctl --user --no-pager -n 100 2>/dev/null | tail -c 5000 || true', { encoding: 'utf-8', timeout: 2000 });
148
+ if (journal && journal.trim())
149
+ return journal.slice(-5000);
150
+ }
151
+ catch { }
124
152
  return '';
125
153
  }
126
154
  catch {
127
155
  return '';
128
156
  }
129
157
  }
158
+ async function defaultLogTail() {
159
+ return collectLogTail();
160
+ }
@@ -13,6 +13,8 @@ export interface SupervisorDeps {
13
13
  ts: string;
14
14
  health: HealthState;
15
15
  action: string;
16
+ logTail?: string;
17
+ gitDiff?: string;
16
18
  }) => Promise<string>;
17
19
  rollback: (ts?: string) => Promise<void>;
18
20
  notify: (msg: string) => Promise<void>;
@@ -41,6 +43,7 @@ export declare class Supervisor {
41
43
  constructor(deps: SupervisorDeps);
42
44
  private getRunDebugAgent;
43
45
  private getFindInterrupted;
46
+ private collectGitDiff;
44
47
  private handleDebugResult;
45
48
  tick(): Promise<void>;
46
49
  start(): void;
package/lib/supervisor.js CHANGED
@@ -16,6 +16,25 @@ export class Supervisor {
16
16
  getFindInterrupted() {
17
17
  return this.deps.findInterrupted ?? defaultFindInterrupted;
18
18
  }
19
+ async collectGitDiff() {
20
+ try {
21
+ const { execSync } = await import('node:child_process');
22
+ const ws = process.env.MAESTRO_HARNESS_ROOT ?? '/home/kai/Work/htdocs/maestro-harness';
23
+ try {
24
+ const out = execSync(`git -C ${JSON.stringify(ws)} status --porcelain 2>/dev/null | head -n 50`, { encoding: 'utf-8', timeout: 2000 });
25
+ if (out.trim()) {
26
+ const diff = execSync(`git -C ${JSON.stringify(ws)} diff 2>/dev/null | head -n 200`, { encoding: 'utf-8', timeout: 2000 });
27
+ return diff || out;
28
+ }
29
+ }
30
+ catch { }
31
+ const diff = execSync('git diff 2>/dev/null | head -n 200', { encoding: 'utf-8', timeout: 2000 });
32
+ return diff.trim() ? diff : '';
33
+ }
34
+ catch {
35
+ return '';
36
+ }
37
+ }
19
38
  handleDebugResult(reportPath, res) {
20
39
  if (res.fixed) {
21
40
  void this.deps.notify(`FIXED: debug-agent fixed ${reportPath} — ${res.reason}`).catch(() => { });
@@ -46,7 +65,9 @@ export class Supervisor {
46
65
  this.lastDegradedNotify = now;
47
66
  try {
48
67
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
49
- const reportPath = await this.deps.writeReport({ ts, health, action: `degraded — ${health.error ?? 'plugin'}` }).catch(() => '');
68
+ const logTail = health.logTail ?? '';
69
+ const gitDiff = await this.collectGitDiff().catch(() => '');
70
+ const reportPath = await this.deps.writeReport({ ts, health, action: `degraded — ${health.error ?? 'plugin'}`, logTail, gitDiff }).catch(() => '');
50
71
  await this.deps.notify(`DEGRADED: ${health.error ?? 'plugin'} (report: ${reportPath})`).catch(() => { });
51
72
  // Phase 3: debug + resume — use injected fn if provided (even in VITEST), otherwise fire-and-forget real impl (skip in VITEST)
52
73
  const runner = this.getRunDebugAgent();
@@ -100,7 +121,9 @@ export class Supervisor {
100
121
  try {
101
122
  const failed = await this.deps.writeFailed().catch(() => ({ ts: new Date().toISOString().replace(/[:.]/g, '-'), manifest: null }));
102
123
  const ts = failed?.ts ?? new Date().toISOString().replace(/[:.]/g, '-');
103
- const reportPath = await this.deps.writeReport({ ts, health, action: `rollback — ${health.error ?? 'down'}` }).catch(() => '');
124
+ const logTail = health.logTail ?? '';
125
+ const gitDiff = await this.collectGitDiff().catch(() => '');
126
+ const reportPath = await this.deps.writeReport({ ts, health, action: `rollback — ${health.error ?? 'down'}`, logTail, gitDiff }).catch(() => '');
104
127
  await this.deps.rollback();
105
128
  await this.deps.notify(`CRASH detected → rollback (report: ${reportPath}, error: ${health.error ?? 'down'})`).catch(() => { });
106
129
  const runner = this.getRunDebugAgent();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ddtcorex/dsh-maestro-supervisor",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Supervisor daemon for DSH Web resilience — auto-detect crashes, rollback to LKG, report",
5
5
  "type": "module",
6
6
  "bin": {