mixdog 0.7.3 → 0.7.5

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,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
4
4
  "description": "Claude Code all-in-one agent plugin — autonomous agents, continuous memory, cost-aware sub-agents, and syntax-aware code editing.",
5
5
  "hooks": "./hooks/hooks.json",
6
6
  "mcpServers": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
4
4
  "description": "Claude Code all-in-one bridge plugin: role-based bridge workers, continuous memory, and syntax-aware code editing.",
5
5
  "author": "mixdog contributors <dev@tribgames.com>",
6
6
  "license": "MIT",
package/setup/wizard.mjs CHANGED
@@ -13,8 +13,9 @@ import {
13
13
  mergeAgentConfig,
14
14
  mergeMemoryConfig,
15
15
  mergeConfig,
16
- mergeWebhookEndpointConfig,
16
+ mergeSearchConfig,
17
17
  } from './config-merge.mjs';
18
+ import { DEFAULT_MODELS } from '../src/search/lib/config.mjs';
18
19
 
19
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
21
  const REPO_ROOT = join(__dirname, '..');
@@ -33,6 +34,22 @@ const AG_API_PROVIDERS = [
33
34
 
34
35
  const WORKFLOW_ROLES = ['worker', 'reviewer', 'debugger', 'tester'];
35
36
 
37
+ /** Raw SERP API keys (setup.html SR_KEY_PROVIDERS) — independent of active provider. */
38
+ const SEARCH_RAW_KEY_PROVIDERS = [
39
+ { id: 'firecrawl', name: 'Firecrawl' },
40
+ { id: 'tavily', name: 'Tavily' },
41
+ { id: 'exa', name: 'Exa' },
42
+ ];
43
+ const SEARCH_OAUTH_PROVIDERS = new Set(['anthropic-oauth', 'openai-oauth', 'grok-oauth']);
44
+ const OPENAI_SEARCH_EFFORT_VALUES = new Set(['low', 'medium', 'high']);
45
+ const SEARCH_OAUTH_ALIASES = Object.freeze({
46
+ 'anthropic-oauth': 'anthropic-oauth',
47
+ anthropic: 'anthropic-oauth',
48
+ 'openai-oauth': 'openai-oauth',
49
+ openai: 'openai-oauth',
50
+ 'grok-oauth': 'grok-oauth',
51
+ grok: 'grok-oauth',
52
+ });
36
53
  function pluginDataDir() {
37
54
  const dir = process.env.CLAUDE_PLUGIN_DATA;
38
55
  if (!dir || typeof dir !== 'string' || !String(dir).trim()) {
@@ -122,11 +139,11 @@ function readHiddenLine(prompt) {
122
139
  async function loadConfigModules() {
123
140
  const { ensureDataSeeds } = await import('../src/shared/seed.mjs');
124
141
  const { readSection, updateSection } = await import('../src/shared/config.mjs');
125
- const { DEFAULT_PRESETS } = await import('../src/agent/orchestrator/config.mjs');
142
+ const { DEFAULT_PRESETS, DEFAULT_MAINTENANCE } = await import('../src/agent/orchestrator/config.mjs');
126
143
  const dataDir = pluginDataDir();
127
144
  mkdirSync(dataDir, { recursive: true });
128
145
  ensureDataSeeds(dataDir);
129
- return { readSection, updateSection, DEFAULT_PRESETS, dataDir };
146
+ return { readSection, updateSection, DEFAULT_PRESETS, DEFAULT_MAINTENANCE, dataDir };
130
147
  }
131
148
 
132
149
  function readUserWorkflow(dataDir) {
@@ -152,14 +169,6 @@ function readUserWorkflow(dataDir) {
152
169
  }
153
170
  }
154
171
 
155
- function readJsonFile(path) {
156
- try {
157
- return JSON.parse(readFileSync(path, 'utf8'));
158
- } catch {
159
- return {};
160
- }
161
- }
162
-
163
172
  function writeUserWorkflow(dataDir, data) {
164
173
  const path = join(dataDir, 'user-workflow.json');
165
174
  const roles = Array.isArray(data?.roles) ? data.roles.slice() : [];
@@ -184,18 +193,59 @@ function presetIdsFromAgent(agentSection) {
184
193
  return presets.map((p) => p.id || p.name).filter(Boolean);
185
194
  }
186
195
 
187
- async function stepDiscordToken(io, { updateSection }) {
188
- io.say('\n── Step 2/7: Discord bot token ──');
189
- io.say('Paste your Discord bot token (hidden). Enter to skip.');
190
- const token = (await io.askSecret('Discord bot token: ')).trim();
191
- if (isSkippableAnswer(token)) {
192
- io.say('• Skipped Discord token.');
196
+ export async function stepDiscordToken(io, { updateSection, readSection }) {
197
+ const { hasStoredSecret, SECRET_ACCOUNTS, getDiscordToken } = await import('../src/shared/config.mjs');
198
+ io.say('\n── Step 2/9: Discord ──');
199
+ io.say('Bot token (keychain), application ID, and optional main channel.');
200
+
201
+ const hadStoredToken = hasStoredSecret(SECRET_ACCOUNTS.discordToken);
202
+ const tokenPrompt = hadStoredToken
203
+ ? 'Discord bot token (stored, Enter=keep): '
204
+ : 'Discord bot token [hidden] (Enter=skip whole step): ';
205
+ const token = (await io.askSecret(tokenPrompt)).trim();
206
+ const enteredToken = !isSkippableAnswer(token);
207
+ if (!enteredToken && !hadStoredToken) {
208
+ io.say('• Skipped Discord setup.');
193
209
  return false;
194
210
  }
211
+
212
+ const channels = readSection('channels') || {};
213
+ const curDiscord = channels.discord && typeof channels.discord === 'object' ? channels.discord : {};
214
+ const curAppId = String(curDiscord.applicationId || '').trim();
215
+ const appIdBase = 'Application ID';
216
+ const appIdPrompt = curAppId
217
+ ? `${appIdBase} (current: ${curAppId}, Enter=keep): `
218
+ : `${appIdBase}: `;
219
+ const appIdRaw = await io.ask(appIdPrompt);
220
+ const appIdToSet = isSkippableAnswer(appIdRaw) ? '' : String(appIdRaw).trim();
221
+ // The main channel is conventionally named "main" with interactive mode;
222
+ // only the channel ID varies, so we ask just that. Extra channels and
223
+ // monitor mode are configured later in the UI.
224
+ const chIdRaw = await io.ask('Main channel ID (Enter=skip channel): ');
225
+ const channelId = isSkippableAnswer(chIdRaw) ? '' : String(chIdRaw).trim();
226
+ const channelName = 'main';
227
+ const mode = 'interactive';
195
228
  const secrets = {};
196
- updateSection('channels', (current) => mergeConfig(current, { discord: { token } }, secrets));
197
- io.say('• Discord token saved to keychain.');
198
- return true;
229
+ updateSection('channels', (current) => {
230
+ const payload = {};
231
+ const discord = {};
232
+ if (enteredToken) discord.token = token;
233
+ if (appIdToSet) discord.applicationId = appIdToSet;
234
+ if (Object.keys(discord).length > 0) payload.discord = discord;
235
+ if (channelId) {
236
+ const existingCfg = current.channelsConfig && typeof current.channelsConfig === 'object'
237
+ ? { ...current.channelsConfig }
238
+ : {};
239
+ existingCfg[channelName] = { channelId, mode };
240
+ payload.channelsConfig = existingCfg;
241
+ payload.mainChannel = channelName;
242
+ }
243
+ return mergeConfig(current, payload, secrets);
244
+ });
245
+ if (enteredToken) io.say('• Discord token saved to keychain.');
246
+ if (appIdToSet) io.say('• Application ID saved.');
247
+ if (channelId) io.say(`• Main channel "${channelName}" configured (${mode}).`);
248
+ return enteredToken || hadStoredToken || !!getDiscordToken();
199
249
  }
200
250
 
201
251
  function formatVoiceProgress(p) {
@@ -230,7 +280,7 @@ async function installVoiceRuntime(dataDir, io) {
230
280
  /** Mirrors setup.html channels save: `voice` via POST /config → mergeConfig. */
231
281
  async function stepVoiceTranscription(io, ctx, discordTokenSaved) {
232
282
  if (!discordTokenSaved) return;
233
- io.say('\n── Step 2a/7: Voice transcription (음성 전사) ──');
283
+ io.say('\n── Step 2a/9: Voice transcription (음성 전사) ──');
234
284
  io.say('Install local Speech-to-text (whisper.cpp) for Discord voice messages.');
235
285
  const raw = await io.ask('Enable voice transcription? [y/N] (Enter=skip): ');
236
286
  if (isSkippableAnswer(raw)) {
@@ -256,7 +306,7 @@ async function stepVoiceTranscription(io, ctx, discordTokenSaved) {
256
306
  }
257
307
 
258
308
  async function stepAddressForm(io, { updateSection, readSection }) {
259
- io.say('\n── Step 1/7: Address form (호칭) ──');
309
+ io.say('\n── Step 1/9: Address form (호칭) ──');
260
310
  const memory = readSection('memory');
261
311
  const curTitle = memory?.user?.title || '';
262
312
  const curName = memory?.user?.name || '';
@@ -276,10 +326,10 @@ async function stepAddressForm(io, { updateSection, readSection }) {
276
326
  io.say('• Saved memory.user (title/name).');
277
327
  }
278
328
 
279
- async function stepWebhookRegister(io, { dataDir }) {
280
- io.say('\n── Step 3/7: Webhook registration ──');
281
- io.say('Create a webhook endpoint (saved under webhooks/<name>/). Enter to skip.');
282
- const enableRaw = await io.ask('Create webhook endpoint? [y/N]: ');
329
+ export async function stepWebhookReceiver(io, { updateSection, readSection }) {
330
+ io.say('\n── Step 3/9: Inbound webhooks (ngrok receiver) ──');
331
+ io.say('Global webhook tunnel for inbound HTTP (channels.webhook). Per-endpoint registration is configured later in the UI.');
332
+ const enableRaw = await io.ask('Enable inbound webhooks? [y/N]: ');
283
333
  if (isSkippableAnswer(enableRaw)) {
284
334
  io.say('• Skipped webhook setup.');
285
335
  return;
@@ -289,31 +339,31 @@ async function stepWebhookRegister(io, { dataDir }) {
289
339
  io.say('• Skipped webhook setup.');
290
340
  return;
291
341
  }
292
- const nameRaw = await io.ask('Endpoint name: ');
293
- const name = sanitizeName(String(nameRaw).trim());
294
- if (!name) {
295
- io.say('• Invalid or missing endpoint name skipped webhook setup.');
296
- return;
342
+ const { hasStoredSecret, SECRET_ACCOUNTS } = await import('../src/shared/config.mjs');
343
+ const channels = readSection('channels') || {};
344
+ const curWebhook = channels.webhook && typeof channels.webhook === 'object' ? channels.webhook : {};
345
+ const curDomain = String(curWebhook.domain || curWebhook.ngrokDomain || '').trim();
346
+ const domainBase =
347
+ 'Domain (ngrok, e.g. your-name.ngrok-free.dev — get it at dashboard.ngrok.com/domains)';
348
+ const domainPrompt = curDomain
349
+ ? `${domainBase} (current: ${curDomain}, Enter=keep): `
350
+ : `${domainBase}: `;
351
+ const domainRaw = await io.ask(domainPrompt);
352
+ const webhook = { enabled: true };
353
+ if (!isSkippableAnswer(domainRaw)) {
354
+ webhook.domain = String(domainRaw).trim();
297
355
  }
298
- const secret = (await io.askSecret('Webhook secret (Enter=skip): ')).trim();
299
- const channel = (await io.ask('Discord channel id (Enter=skip): ')).trim();
300
- const model = (await io.ask('Agent preset id (Enter=skip): ')).trim();
301
- const instructions = (await io.ask('Instructions (Enter=skip): ')).trim();
302
- const dir = join(dataDir, 'webhooks', name);
303
- mkdirSync(dir, { recursive: true });
304
- const configPath = join(dir, 'config.json');
305
- const incoming = {};
306
- if (secret) incoming.secret = secret;
307
- if (channel) incoming.channel = channel;
308
- if (model) incoming.model = model;
309
- const merged = mergeWebhookEndpointConfig(readJsonFile(configPath), incoming);
310
- writeFileSync(configPath, JSON.stringify(merged, null, 2) + '\n', 'utf8');
311
- writeFileSync(join(dir, 'instructions.md'), instructions, 'utf8');
312
- io.say(`• Webhook endpoint "${name}" saved (role: ${merged.role}).`);
356
+ const authPrompt = hasStoredSecret(SECRET_ACCOUNTS.webhookAuth)
357
+ ? 'Auth Token (stored, Enter=keep): '
358
+ : 'ngrok Auth Token [hidden]: ';
359
+ webhook.authtoken = (await io.askSecret(authPrompt)).trim();
360
+ const secrets = {};
361
+ updateSection('channels', (current) => mergeConfig(current, { webhook }, secrets));
362
+ io.say('• Inbound webhook receiver saved (channels.webhook enabled/domain; authtoken in keychain).');
313
363
  }
314
364
 
315
365
  async function stepProviderKeys(io, { updateSection }) {
316
- io.say('\n── Step 4/7: Provider API keys ──');
366
+ io.say('\n── Step 4/9: Provider API keys ──');
317
367
  io.say('Optional API keys (hidden). Enter to skip a provider.');
318
368
  const providers = {};
319
369
  for (const p of AG_API_PROVIDERS) {
@@ -332,7 +382,7 @@ async function stepProviderKeys(io, { updateSection }) {
332
382
  }
333
383
 
334
384
  async function stepPresets(io, { readSection, updateSection, DEFAULT_PRESETS }) {
335
- io.say('\n── Step 5/7: Agent presets ──');
385
+ io.say('\n── Step 5/9: Agent presets ──');
336
386
  const agent = readSection('agent');
337
387
  const existing = presetIdsFromAgent(agent);
338
388
  if (existing.length > 0) {
@@ -359,11 +409,11 @@ async function stepPresets(io, { readSection, updateSection, DEFAULT_PRESETS })
359
409
  }
360
410
 
361
411
  async function stepRolePresetMapping(io, { readSection, dataDir }) {
362
- io.say('\n── Step 6/7: Role → preset mapping ──');
412
+ io.say('\n── Step 6/9: Role → preset mapping ──');
363
413
  const agent = readSection('agent');
364
414
  const presetIds = presetIdsFromAgent(agent);
365
415
  if (presetIds.length === 0) {
366
- io.say('No presets on disk — run step 4 first or configure presets in the Mixdog UI later.');
416
+ io.say('No presets on disk — run step 5 first or configure presets in the Mixdog UI later.');
367
417
  return;
368
418
  }
369
419
  io.say(`Available presets: ${presetIds.join(', ')}`);
@@ -393,6 +443,154 @@ async function stepRolePresetMapping(io, { readSection, dataDir }) {
393
443
  io.say('• Role → preset mapping saved to user-workflow.json.');
394
444
  }
395
445
 
446
+ function resolveSearchBackendInput(raw) {
447
+ if (isSkippableAnswer(raw)) return null;
448
+ const key = String(raw).trim().toLowerCase();
449
+ return SEARCH_OAUTH_ALIASES[key] || null;
450
+ }
451
+
452
+ function parseYesNo(raw) {
453
+ if (isSkippableAnswer(raw)) return null;
454
+ const v = String(raw).trim().toLowerCase();
455
+ if (v === 'y' || v === 'yes' || v === 'true' || v === '1') return true;
456
+ if (v === 'n' || v === 'no' || v === 'false' || v === '0') return false;
457
+ return undefined;
458
+ }
459
+
460
+ /** Mirrors POST /search/config → mergeSearchConfig. */
461
+ export async function stepSearchBackend(io, { updateSection, readSection }) {
462
+ io.say('\n── Step 7/9: Search backend ──');
463
+ io.say('Active provider: anthropic-oauth | openai-oauth | grok-oauth (OAuth — uses Agent credentials).');
464
+ const { hasStoredSecret, SECRET_ACCOUNTS, getSearchApiKey } = await import('../src/shared/config.mjs');
465
+ const search = readSection('search') || {};
466
+ const curProvider = String(search.provider || 'anthropic-oauth').trim() || 'anthropic-oauth';
467
+ const backendRaw = await io.ask(`Search provider [${curProvider}]: `);
468
+ let provider = curProvider;
469
+ if (!isSkippableAnswer(backendRaw)) {
470
+ const resolved = resolveSearchBackendInput(backendRaw);
471
+ if (!resolved) {
472
+ io.say(` ! Unknown provider "${String(backendRaw).trim()}" — keeping ${curProvider}.`);
473
+ } else if (!SEARCH_OAUTH_PROVIDERS.has(resolved)) {
474
+ io.say(` ! Provider "${resolved}" is not a supported OAuth backend — keeping ${curProvider}.`);
475
+ } else {
476
+ provider = resolved;
477
+ }
478
+ }
479
+
480
+ const payload = {};
481
+ if (provider !== curProvider) payload.provider = provider;
482
+
483
+ const searchProviders = {};
484
+ for (const p of SEARCH_RAW_KEY_PROVIDERS) {
485
+ const hadKey = hasStoredSecret(SECRET_ACCOUNTS.searchApiKey(p.id));
486
+ const keyPrompt = hadKey
487
+ ? `${p.name} API key (stored, Enter=keep): `
488
+ : `${p.name} API key [hidden] (Enter=skip): `;
489
+ const key = (await io.askSecret(keyPrompt)).trim();
490
+ if (!isSkippableAnswer(key)) {
491
+ searchProviders[p.id] = key;
492
+ }
493
+ }
494
+ if (Object.keys(searchProviders).length) payload.searchProviders = searchProviders;
495
+
496
+ if (provider === 'openai-oauth') {
497
+ const curModel = (search.models && search.models.openai)
498
+ || DEFAULT_MODELS.openai
499
+ || '';
500
+ const modelRaw = await io.ask(`OpenAI model [${curModel}] (Enter=keep): `);
501
+ if (!isSkippableAnswer(modelRaw)) {
502
+ const model = String(modelRaw).trim();
503
+ if (model) payload.models = { ...(payload.models || {}), openai: model };
504
+ }
505
+ const curEffort = String(search.modelOptions?.openai?.effort || 'medium').trim() || 'medium';
506
+ const effortRaw = await io.ask(`OpenAI effort (low/medium/high) [${curEffort}] (Enter=keep): `);
507
+ if (!isSkippableAnswer(effortRaw)) {
508
+ const effort = String(effortRaw).trim().toLowerCase();
509
+ if (!OPENAI_SEARCH_EFFORT_VALUES.has(effort)) {
510
+ io.say(` ! Unknown effort "${effortRaw.trim()}" — keeping ${curEffort}.`);
511
+ } else {
512
+ const openaiOpts = { ...(search.modelOptions?.openai || {}), effort };
513
+ payload.modelOptions = { ...(payload.modelOptions || {}), openai: openaiOpts };
514
+ }
515
+ }
516
+ const curFast = !!search.modelOptions?.openai?.fast;
517
+ const fastRaw = await io.ask(`OpenAI Fast mode [${curFast ? 'y' : 'N'}] (y/N, Enter=keep): `);
518
+ const fastParsed = parseYesNo(fastRaw);
519
+ if (fastParsed === undefined && !isSkippableAnswer(fastRaw)) {
520
+ io.say(' ! Fast mode: answer y or n — keeping current.');
521
+ } else if (fastParsed !== null) {
522
+ const base = payload.modelOptions?.openai || search.modelOptions?.openai || {};
523
+ const openaiOpts = { ...base };
524
+ if (fastParsed) openaiOpts.fast = true;
525
+ else delete openaiOpts.fast;
526
+ // Write even an empty object: mergeSearchConfig treats an empty per-family
527
+ // entry as "clear this family", which is how an explicit Fast=n drops a
528
+ // lone fast flag (no effort left to keep the object non-empty).
529
+ payload.modelOptions = { ...(payload.modelOptions || {}), openai: openaiOpts };
530
+ }
531
+ } else if (provider === 'grok-oauth') {
532
+ const curModel = (search.models && search.models.xai)
533
+ || DEFAULT_MODELS.xai
534
+ || '';
535
+ const modelRaw = await io.ask(`xAI model [${curModel}] (Enter=keep): `);
536
+ if (!isSkippableAnswer(modelRaw)) {
537
+ const model = String(modelRaw).trim();
538
+ if (model) payload.models = { ...(payload.models || {}), xai: model };
539
+ }
540
+ }
541
+
542
+ const secrets = {};
543
+ updateSection('search', (current) => mergeSearchConfig(current, payload, secrets));
544
+ const after = readSection('search') || {};
545
+ const savedProvider = after.provider || curProvider;
546
+ io.say(`• Search provider: ${savedProvider}.`);
547
+ for (const p of SEARCH_RAW_KEY_PROVIDERS) {
548
+ io.say(`• ${p.name} API key: ${getSearchApiKey(p.id) ? 'stored' : 'not set'}.`);
549
+ }
550
+ if (savedProvider === 'openai-oauth' && after.models?.openai) {
551
+ io.say(`• OpenAI model: ${after.models.openai}.`);
552
+ }
553
+ if (savedProvider === 'grok-oauth' && after.models?.xai) {
554
+ io.say(`• xAI model: ${after.models.xai}.`);
555
+ }
556
+ if (savedProvider === 'openai-oauth' && after.modelOptions?.openai?.effort) {
557
+ io.say(`• OpenAI effort: ${after.modelOptions.openai.effort}.`);
558
+ }
559
+ if (savedProvider === 'openai-oauth' && after.modelOptions?.openai?.fast) {
560
+ io.say('• OpenAI Fast mode: on.');
561
+ }
562
+ }
563
+
564
+ /** Mirrors POST /agent/maintenance for the explore slot. */
565
+ export async function stepExplorerPreset(io, { readSection, updateSection, DEFAULT_PRESETS, DEFAULT_MAINTENANCE }) {
566
+ io.say('\n── Step 8/9: Explorer model (explore tool) ──');
567
+ const agent = readSection('agent') || {};
568
+ const presetIds = presetIdsFromAgent(agent);
569
+ const validIds = new Set([
570
+ ...presetIds,
571
+ ...DEFAULT_PRESETS.map((p) => p.id).filter(Boolean),
572
+ ]);
573
+ const curExplore = String(agent.maintenance?.explore || DEFAULT_MAINTENANCE.explore || 'haiku').trim() || 'haiku';
574
+ if (validIds.size > 0) {
575
+ io.say(`Available presets: ${[...validIds].join(', ')}`);
576
+ }
577
+ const raw = await io.ask(`Preset for explorer (explore tool) [${curExplore}]: `);
578
+ if (isSkippableAnswer(raw)) {
579
+ io.say(`• Explorer preset unchanged (${curExplore}).`);
580
+ return;
581
+ }
582
+ const preset = String(raw).trim();
583
+ if (!validIds.has(preset)) {
584
+ io.say(` ! Unknown preset "${preset}" — keeping ${curExplore}.`);
585
+ return;
586
+ }
587
+ updateSection('agent', (current) => ({
588
+ ...current,
589
+ maintenance: { ...(current.maintenance || {}), explore: preset },
590
+ }));
591
+ io.say(`• Explorer maintenance preset: ${preset}.`);
592
+ }
593
+
396
594
  /**
397
595
  * @param {object} [ioOverride]
398
596
  * @param {boolean} [ioOverride.interactive]
@@ -412,10 +610,12 @@ export async function runSetupWizard(ioOverride = null) {
412
610
  await stepAddressForm(io, ctx);
413
611
  const discordSaved = await stepDiscordToken(io, ctx);
414
612
  await stepVoiceTranscription(io, ctx, discordSaved);
415
- await stepWebhookRegister(io, ctx);
613
+ await stepWebhookReceiver(io, ctx);
416
614
  await stepProviderKeys(io, ctx);
417
615
  await stepPresets(io, ctx);
418
616
  await stepRolePresetMapping(io, ctx);
617
+ await stepSearchBackend(io, ctx);
618
+ await stepExplorerPreset(io, ctx);
419
619
  io.say('\n✓ Wizard complete. Restart Claude Code (or /reload-plugins) to load mixdog.');
420
620
  } catch (err) {
421
621
  io.say(`\n✗ Wizard error: ${err?.message || err}`);