memoryblock 0.1.0-beta → 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.
@@ -1,843 +0,0 @@
1
- import * as p from '@clack/prompts';
2
- import chalk from 'chalk';
3
- import {
4
- loadGlobalConfig, loadBlockConfig, loadAuth, resolveBlockPath, isInitialized,
5
- saveBlockConfig, resolveBlocksDir, loadPulseState, savePulseState,
6
- } from '../../utils/config.js';
7
- import { t } from '@memoryblock/locale';
8
- import { Monitor } from '../../engine/monitor.js';
9
- import { promises as fsp } from 'node:fs';
10
- import { pathExists } from '../../utils/fs.js';
11
- import { log } from '../logger.js';
12
- import { join } from 'node:path';
13
- import { PROVIDERS, PLUGINS } from '../constants.js';
14
-
15
- // Use variable-based dynamic imports so TypeScript doesn't try to resolve
16
- // these at compile time. They are runtime-only dependencies.
17
- const ADAPTERS_PKG = '@memoryblock/adapters';
18
- const TOOLS_PKG = '@memoryblock/tools';
19
- const CHANNELS_PKG = '@memoryblock/channels';
20
- const WEB_SEARCH_PKG = '@memoryblock/plugin-web-search';
21
- const DAEMON_PKG = '@memoryblock/daemon';
22
- const AGENTS_PKG = '@memoryblock/plugin-agents';
23
-
24
- async function setupBlockRuntimeLogs(
25
- blockConfig: any,
26
- blockPath: string,
27
- auth: any,
28
- options: { channel?: string; daemon?: boolean } | undefined,
29
- channelType: string
30
- ) {
31
- const model = blockConfig.adapter.model.split('.').pop()?.replace(/-v\d.*$/, '') || blockConfig.adapter.model;
32
-
33
- if (options?.daemon) {
34
- log.dim(` ${model} · daemon · ${blockConfig.tools.sandbox ? 'sandboxed' : 'unrestricted'}`);
35
- return {};
36
- }
37
-
38
- log.dim(` ${model} · ${channelType} · ${blockConfig.tools.sandbox ? 'sandboxed' : 'unrestricted'}`);
39
- console.log('');
40
-
41
- // Initialize adapter
42
- let adapter: any;
43
- try {
44
- const adapters = await import(ADAPTERS_PKG);
45
- const provider = blockConfig.adapter.provider || 'bedrock';
46
-
47
- if (provider === 'openai') {
48
- adapter = new adapters.OpenAIAdapter({
49
- model: blockConfig.adapter.model,
50
- apiKey: auth.openai?.apiKey || process.env.OPENAI_API_KEY,
51
- });
52
- log.dim(` ✓ openai adapter`);
53
- } else if (provider === 'gemini') {
54
- adapter = new adapters.GeminiAdapter({
55
- model: blockConfig.adapter.model,
56
- apiKey: auth.gemini?.apiKey || process.env.GEMINI_API_KEY,
57
- });
58
- log.dim(` ✓ gemini adapter`);
59
- } else if (provider === 'anthropic') {
60
- adapter = new adapters.AnthropicAdapter({
61
- model: blockConfig.adapter.model,
62
- apiKey: auth.anthropic?.apiKey || process.env.ANTHROPIC_API_KEY,
63
- });
64
- log.dim(` ✓ anthropic adapter`);
65
- } else {
66
- // Bedrock: pass credentials directly
67
- adapter = new adapters.BedrockAdapter({
68
- model: blockConfig.adapter.model,
69
- region: blockConfig.adapter.region || auth.aws?.region || 'us-east-1',
70
- maxTokens: blockConfig.adapter.maxTokens,
71
- accessKeyId: auth.aws?.accessKeyId || process.env.AWS_ACCESS_KEY_ID,
72
- secretAccessKey: auth.aws?.secretAccessKey || process.env.AWS_SECRET_ACCESS_KEY,
73
- });
74
- log.dim(` ✓ bedrock adapter`);
75
- }
76
- } catch (err) {
77
- throw new Error(`Failed to load adapter: ${(err as Error).message}`);
78
- }
79
-
80
- // Initialize tool registry
81
- let registry: any;
82
- try {
83
- const tools = await import(TOOLS_PKG);
84
- registry = tools.createDefaultRegistry();
85
-
86
- // Try loading web search plugin
87
- try {
88
- const webSearch = await import(WEB_SEARCH_PKG);
89
- if (webSearch.tools) {
90
- for (const tool of webSearch.tools) {
91
- registry.register(tool);
92
- }
93
- }
94
- log.dim(` ✓ web search plugin`);
95
- } catch {
96
- // Not installed — that's fine, it's optional
97
- }
98
-
99
- // Try loading agent orchestration plugin
100
- try {
101
- const agentsPlugin = await import(AGENTS_PKG);
102
- if (agentsPlugin.tools) {
103
- for (const tool of agentsPlugin.tools) {
104
- registry.register(tool);
105
- }
106
- }
107
- log.dim(` ✓ agent orchestration plugin`);
108
- } catch {
109
- // Not installed — that's fine, it's optional
110
- }
111
-
112
- log.dim(` ✓ ${registry.listTools().length} tools loaded`);
113
- } catch (err) {
114
- throw new Error(`Failed to load tools: ${(err as Error).message}`);
115
- }
116
-
117
- // Initialize channel(s)
118
- let channel: any;
119
- try {
120
- const channels = await import(CHANNELS_PKG);
121
- const activeChannels: any[] = [];
122
-
123
- // Only bind CLI if TTY is available, otherwise background daemons will crash
124
- if (process.stdout.isTTY && !options?.daemon) {
125
- activeChannels.push(new channels.CLIChannel(blockConfig.name));
126
- }
127
-
128
- // Add Telegram if configured
129
- const telegramToken = auth.telegram?.botToken;
130
- if (telegramToken) {
131
- const globalConfig = await loadGlobalConfig();
132
- const chatId = blockConfig.channel.telegram?.chatId || auth.telegram?.chatId || '';
133
- const enableAlerts = globalConfig.channelAlerts ?? true;
134
- activeChannels.push(new channels.TelegramChannel(blockConfig.name, chatId, enableAlerts));
135
- }
136
-
137
- // Add WebChannel when: daemon mode, explicit 'web' or 'multi' channel, or block config says web
138
- if (options?.daemon || channelType === 'web' || channelType === 'multi' ||
139
- blockConfig.channel.type?.includes('web')) {
140
- activeChannels.push(new channels.WebChannel(blockConfig.name, blockPath));
141
- }
142
-
143
- // Wrap them in the MultiChannelManager
144
- channel = new channels.MultiChannelManager(activeChannels);
145
-
146
- const names = activeChannels.map((c: any) => c.name).join(', ');
147
- log.dim(` ✓ bound channels: ${names}`);
148
- } catch (err) {
149
- throw new Error(`Failed to load channel: ${(err as Error).message}`);
150
- }
151
-
152
- console.log('');
153
- return { adapter, registry, channel };
154
- }
155
-
156
- /**
157
- * Attach a CLI readline to a running daemon instance.
158
- * Instead of starting a new Monitor, we write messages to chat.json
159
- * and watch for assistant replies — piggybacking on the WebChannel.
160
- */
161
- async function attachCLIToRunningBlock(blockName: string, blockPath: string): Promise<void> {
162
- const { createInterface, moveCursor, clearLine } = await import('node:readline');
163
- const { watch } = await import('node:fs');
164
-
165
- const chatFile = join(blockPath, 'chat.json');
166
- const THEME = {
167
- brand: chalk.hex('#7C3AED'),
168
- brandBg: chalk.bgHex('#7C3AED').white.bold,
169
- founderBg: chalk.bgHex('#1c64c8ff').white.bold,
170
- system: chalk.hex('#6B7280'),
171
- dim: chalk.dim,
172
- };
173
-
174
- // Load block config to get monitor name
175
- const blockConfig = await loadBlockConfig(blockPath);
176
- const monitorLabel = blockConfig.monitorEmoji
177
- ? `${blockConfig.monitorEmoji} ${blockConfig.monitorName || 'Monitor'}`
178
- : blockConfig.monitorName || 'Monitor';
179
-
180
- console.log(THEME.system(' ╭───────────────────────────────────────────────────╮'));
181
- console.log(THEME.system(' │') + ' attached to running instance '
182
- + THEME.system(' │'));
183
- console.log(THEME.system(' │') + ' type a message and press enter. ctrl+c to detach. '
184
- + THEME.system('│'));
185
- console.log(THEME.system(' ╰───────────────────────────────────────────────────╯'));
186
- console.log('');
187
-
188
- // Track which messages we've already displayed
189
- let lastKnownLength = 0;
190
- try {
191
- const raw = await fsp.readFile(chatFile, 'utf8');
192
- const msgs = JSON.parse(raw);
193
- lastKnownLength = msgs.length;
194
- } catch {
195
- // chat.json doesn't exist yet, that's fine
196
- }
197
-
198
- // Watch chat.json for new assistant responses
199
- let debounceTimer: ReturnType<typeof setTimeout> | null = null;
200
- const watcher = watch(blockPath, (_, filename) => {
201
- if (filename === 'chat.json') {
202
- if (debounceTimer) clearTimeout(debounceTimer);
203
- debounceTimer = setTimeout(async () => {
204
- try {
205
- const raw = await fsp.readFile(chatFile, 'utf8');
206
- const msgs = JSON.parse(raw);
207
- // Display any new messages
208
- for (let i = lastKnownLength; i < msgs.length; i++) {
209
- const m = msgs[i];
210
- if (m.role === 'assistant' || (m.role === 'system' && !m.processed)) {
211
- console.log('');
212
- console.log(`${THEME.brandBg(` ${monitorLabel} `)} ${THEME.system(blockName)}`);
213
- console.log('');
214
- // Simple markdown formatting for terminal
215
- const formatted = (m.content || '')
216
- .replace(/\*\*(.*?)\*\*/g, (_: string, p1: string) => chalk.bold(p1))
217
- .replace(/`([^`]+)`/g, (_: string, p1: string) => chalk.cyan(p1))
218
- .replace(/_(.*?)_/g, (_: string, p1: string) => chalk.italic(p1));
219
- console.log(formatted);
220
- console.log('');
221
- }
222
- }
223
- lastKnownLength = msgs.length;
224
- } catch { /* ignore read errors */ }
225
- }, 300);
226
- }
227
- });
228
-
229
- // Also poll as fallback (FSEvents can miss cross-process writes)
230
- const pollInterval = setInterval(async () => {
231
- try {
232
- const raw = await fsp.readFile(chatFile, 'utf8');
233
- const msgs = JSON.parse(raw);
234
- if (msgs.length > lastKnownLength) {
235
- for (let i = lastKnownLength; i < msgs.length; i++) {
236
- const m = msgs[i];
237
- if (m.role === 'assistant') {
238
- console.log('');
239
- console.log(`${THEME.brandBg(` ${monitorLabel} `)} ${THEME.system(blockName)}`);
240
- console.log('');
241
- const formatted = (m.content || '')
242
- .replace(/\*\*(.*?)\*\*/g, (_: string, p1: string) => chalk.bold(p1))
243
- .replace(/`([^`]+)`/g, (_: string, p1: string) => chalk.cyan(p1))
244
- .replace(/_(.*?)_/g, (_: string, p1: string) => chalk.italic(p1));
245
- console.log(formatted);
246
- console.log('');
247
- }
248
- }
249
- lastKnownLength = msgs.length;
250
- }
251
- } catch { /* ignore */ }
252
- }, 2000);
253
-
254
- // Readline for user input
255
- const rl = createInterface({ input: process.stdin, output: process.stdout });
256
-
257
- rl.on('line', async (line: string) => {
258
- const content = line.trim();
259
- if (!content) return;
260
-
261
- // Style the user input
262
- moveCursor(process.stdout, 0, -1);
263
- clearLine(process.stdout, 0);
264
- console.log(`\n${THEME.founderBg(' Founder ')} ${content}`);
265
-
266
- // Write to chat.json so the daemon's WebChannel picks it up
267
- try {
268
- let msgs: any[] = [];
269
- try {
270
- const raw = await fsp.readFile(chatFile, 'utf8');
271
- msgs = JSON.parse(raw);
272
- } catch { msgs = []; }
273
-
274
- msgs.push({
275
- role: 'user',
276
- content,
277
- timestamp: new Date().toISOString(),
278
- processed: false, // WebChannel will pick this up
279
- });
280
-
281
- lastKnownLength = msgs.length; // Don't re-display our own message
282
- await fsp.writeFile(chatFile, JSON.stringify(msgs, null, 4), 'utf8');
283
- } catch (err) {
284
- console.error(THEME.system(` Failed to send: ${(err as Error).message}`));
285
- }
286
- });
287
-
288
- rl.on('SIGINT', () => {
289
- console.log(THEME.dim('\n Detached from running instance. Daemon continues in background.\n'));
290
- watcher.close();
291
- clearInterval(pollInterval);
292
- if (debounceTimer) clearTimeout(debounceTimer);
293
- rl.close();
294
- process.exit(0);
295
- });
296
-
297
- rl.on('close', () => {
298
- watcher.close();
299
- clearInterval(pollInterval);
300
- if (debounceTimer) clearTimeout(debounceTimer);
301
- });
302
-
303
- // Keep process alive
304
- await new Promise<void>(() => {}); // Block forever until Ctrl+C
305
- }
306
-
307
-
308
-
309
- /**
310
- * Model selection per provider.
311
- * For API-based providers: fetch available models dynamically.
312
- * Fallback: let user enter a model ID manually.
313
- */
314
- async function selectModel(provider: string, auth: any): Promise<string> {
315
- console.log('');
316
- p.intro(chalk.bold('Model Selection'));
317
- p.log.info(`Let's pick a model for the ${chalk.bold(provider)} provider.`);
318
-
319
- if (provider === 'openai') {
320
- // Try to fetch models from OpenAI API
321
- const apiKey = auth.openai?.apiKey || process.env.OPENAI_API_KEY;
322
- if (apiKey) {
323
- try {
324
- const s = p.spinner();
325
- s.start('Fetching available OpenAI models...');
326
- const res = await fetch('https://api.openai.com/v1/models', {
327
- headers: { 'Authorization': `Bearer ${apiKey}` },
328
- });
329
- const data = await res.json() as { data?: Array<{ id: string }> };
330
- s.stop('Models fetched.');
331
-
332
- if (data.data && data.data.length > 0) {
333
- // Filter to chat-capable models
334
- const chatModels = data.data
335
- .filter(m => m.id.includes('gpt') || m.id.includes('o1') || m.id.includes('o3') || m.id.includes('o4'))
336
- .sort((a, b) => a.id.localeCompare(b.id))
337
- .slice(0, 20);
338
-
339
- if (chatModels.length > 0) {
340
- const selected = await p.select({
341
- message: 'Select an OpenAI model:',
342
- options: [
343
- ...chatModels.map(m => ({ value: m.id, label: m.id })),
344
- { value: '_custom', label: 'Enter custom model ID...' },
345
- ],
346
- });
347
- if (p.isCancel(selected)) throw new Error('Model selection cancelled.');
348
- if (selected !== '_custom') return selected as string;
349
- }
350
- }
351
- } catch {
352
- // Fall through to manual entry
353
- }
354
- }
355
- } else if (provider === 'gemini') {
356
- // Try to fetch models from Gemini API
357
- const apiKey = auth.gemini?.apiKey || process.env.GEMINI_API_KEY;
358
- if (apiKey) {
359
- try {
360
- const s = p.spinner();
361
- s.start('Fetching available Gemini models...');
362
- const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`);
363
- const data = await res.json() as { models?: Array<{ name: string; displayName: string; supportedGenerationMethods?: string[] }> };
364
- s.stop('Models fetched.');
365
-
366
- if (data.models && data.models.length > 0) {
367
- const chatModels = data.models
368
- .filter(m => m.supportedGenerationMethods?.includes('generateContent'))
369
- .filter(m => m.name.includes('gemini'))
370
- .slice(0, 20);
371
-
372
- if (chatModels.length > 0) {
373
- const selected = await p.select({
374
- message: 'Select a Gemini model:',
375
- options: [
376
- ...chatModels.map(m => ({
377
- value: m.name.replace('models/', ''),
378
- label: m.displayName,
379
- hint: m.name.replace('models/', ''),
380
- })),
381
- { value: '_custom', label: 'Enter custom model ID...' },
382
- ],
383
- });
384
- if (p.isCancel(selected)) throw new Error('Model selection cancelled.');
385
- if (selected !== '_custom') return selected as string;
386
- }
387
- }
388
- } catch {
389
- // Fall through to manual entry
390
- }
391
- }
392
- } else if (provider === 'anthropic') {
393
- // Anthropic doesn't have a public model listing endpoint
394
- const selected = await p.select({
395
- message: 'Select an Anthropic model:',
396
- options: [
397
- { value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4', hint: 'latest' },
398
- { value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet', hint: 'balanced' },
399
- { value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku', hint: 'fast & affordable' },
400
- { value: '_custom', label: 'Enter custom model ID...' },
401
- ],
402
- });
403
- if (p.isCancel(selected)) throw new Error('Model selection cancelled.');
404
- if (selected !== '_custom') return selected as string;
405
- }
406
-
407
- // Bedrock, Ollama, or custom fallback
408
- const hint = provider === 'bedrock'
409
- ? 'e.g. us.anthropic.claude-sonnet-4-5-20250929-v1:0'
410
- : provider === 'ollama'
411
- ? 'e.g. llama3, mistral, codellama'
412
- : 'Enter model ID';
413
-
414
- const modelId = await p.text({
415
- message: `Enter the ${provider} model ID:`,
416
- placeholder: hint,
417
- validate: (v) => {
418
- if (!v || !v.trim()) return 'Model ID is required.';
419
- },
420
- });
421
-
422
- if (p.isCancel(modelId)) throw new Error('Model selection cancelled.');
423
- return (modelId as string).trim();
424
- }
425
-
426
- /**
427
- * Find existing blocks that have completed setup (have a monitor name + model configured).
428
- * These are candidates for copying settings from.
429
- */
430
- async function findConfiguredBlocks(globalConfig: any, currentBlockName: string): Promise<Array<{ name: string; provider: string; model: string; monitorName?: string }>> {
431
- const blocksDir = resolveBlocksDir(globalConfig);
432
- const configured: Array<{ name: string; provider: string; model: string; monitorName?: string }> = [];
433
-
434
- try {
435
- const dirs = await fsp.readdir(blocksDir);
436
- for (const d of dirs) {
437
- if (d === currentBlockName || d.startsWith('_') || d.startsWith('.')) continue;
438
- const bPath = join(blocksDir, d);
439
- const isDir = await fsp.stat(bPath).then(s => s.isDirectory()).catch(() => false);
440
- if (!isDir) continue;
441
-
442
- try {
443
- const cfgRaw = await fsp.readFile(join(bPath, 'config.json'), 'utf8');
444
- const cfg = JSON.parse(cfgRaw);
445
- // Only show blocks that have completed setup: model is configured and monitor has a name
446
- if (cfg.adapter?.model && cfg.monitorName) {
447
- configured.push({
448
- name: cfg.name || d,
449
- provider: cfg.adapter?.provider || 'bedrock',
450
- model: cfg.adapter?.model || '',
451
- monitorName: cfg.monitorName,
452
- });
453
- }
454
- } catch {
455
- // Skip corrupted blocks
456
- }
457
- }
458
- } catch {
459
- // blocks dir doesn't exist yet
460
- }
461
-
462
- return configured;
463
- }
464
-
465
- /**
466
- * Mini-onboarding flow for new blocks that haven't been configured yet.
467
- * Runs when `mblk start <block>` is called and the block has no model set.
468
- *
469
- * Steps:
470
- * 1. Offer to copy settings from an existing configured block (if any exist)
471
- * 2. Select provider
472
- * 3. Select model
473
- * 4. Skills & Plugins setup
474
- * 5. Save config
475
- */
476
- async function miniOnboarding(blockConfig: any, blockPath: string, blockName: string, auth: any, globalConfig: any): Promise<any> {
477
- console.log('');
478
- log.banner();
479
- p.intro(chalk.bold(`Block Setup — ${blockName}`));
480
- p.log.info('This block needs to be configured before it can start.');
481
-
482
- // ─── Step 0: Copy from existing block? ────────────────
483
- const configuredBlocks = await findConfiguredBlocks(globalConfig, blockName);
484
-
485
- if (configuredBlocks.length > 0) {
486
- const copyChoice = await p.select({
487
- message: 'How would you like to configure this block?',
488
- options: [
489
- ...configuredBlocks.map(b => {
490
- const shortModel = b.model.split('.').pop()?.replace(/-v\d.*$/, '') || b.model;
491
- return {
492
- value: b.name,
493
- label: `Copy from "${b.name}"`,
494
- // hint: `${b.monitorName || ''} · ${b.provider} / ${b.model.split('.').pop()?.replace(/-v\d.*$/, '') || b.model}`,
495
- hint: `${b.provider} · ${shortModel}`,
496
- };
497
- }),
498
- { value: '_fresh', label: 'Start fresh', hint: 'choose provider, model, and skills' },
499
- ],
500
- });
501
-
502
- if (p.isCancel(copyChoice)) throw new Error('Setup cancelled.');
503
-
504
- if (copyChoice !== '_fresh') {
505
- // Copy config from the selected block
506
- const sourceBlockPath = join(resolveBlocksDir(globalConfig), copyChoice as string);
507
- try {
508
- const sourceCfgRaw = await fsp.readFile(join(sourceBlockPath, 'config.json'), 'utf8');
509
- const sourceCfg = JSON.parse(sourceCfgRaw);
510
-
511
- // Copy adapter, memory, tools, permissions — but NOT name, monitorName, monitorEmoji, or channel
512
- const copied = {
513
- ...blockConfig,
514
- adapter: { ...sourceCfg.adapter },
515
- memory: { ...sourceCfg.memory },
516
- tools: { ...sourceCfg.tools },
517
- permissions: { ...sourceCfg.permissions },
518
- goals: [...(sourceCfg.goals || [])],
519
- };
520
-
521
- await saveBlockConfig(blockPath, copied);
522
- p.log.success(`Copied settings from "${copyChoice}" — provider: ${copied.adapter.provider}, model: ${copied.adapter.model.split('.').pop()?.replace(/-v\d.*$/, '') || copied.adapter.model}`);
523
- p.outro('Block configured. Starting...');
524
- return copied;
525
- } catch {
526
- p.log.warning(`Failed to copy from "${copyChoice}". Continuing with fresh setup.`);
527
- }
528
- }
529
- }
530
-
531
- // ─── Step 1: Provider Selection ───────────────────────
532
- const selectedProvider = await p.select({
533
- message: 'Select your LLM provider:',
534
- options: PROVIDERS,
535
- });
536
-
537
- if (p.isCancel(selectedProvider)) throw new Error('Setup cancelled.');
538
- const provider = selectedProvider as string;
539
-
540
- // ─── Step 2: Model Selection ──────────────────────────
541
- const model = await selectModel(provider, auth);
542
-
543
- // ─── Step 3: Skills & Plugins ─────────────────────────
544
- p.log.step(chalk.bold('Skills & Plugins'));
545
- p.log.info(`${chalk.green('✓')} Core tools (file ops, shell, dev) — always available`);
546
- p.log.info(`${chalk.green('✓')} Multi-Agent Orchestration — always available`);
547
-
548
- // Use the shared PLUGINS list, filtering to non-AWS plugins for block setup
549
- const skillOptions = PLUGINS.filter(p => p.value !== 'aws');
550
-
551
- let selectedSkills: symbol | string[] = [];
552
- if (skillOptions.length > 0) {
553
- selectedSkills = await p.multiselect({
554
- message: 'Enable additional skills:',
555
- options: skillOptions,
556
- required: false,
557
- });
558
-
559
- if (p.isCancel(selectedSkills)) throw new Error('Setup cancelled.');
560
- }
561
-
562
- // Check which plugins are actually installed
563
- const installedSkills: string[] = [];
564
- for (const skill of (selectedSkills as string[])) {
565
- try {
566
- await import(`@memoryblock/plugin-${skill}`);
567
- installedSkills.push(skill);
568
- } catch {
569
- p.log.warning(`Plugin "${skill}" is not installed. Run \`mblk add ${skill}\` to install.`);
570
- }
571
- }
572
-
573
- // ─── Step 4: Save Config ──────────────────────────────
574
- const updated = {
575
- ...blockConfig,
576
- adapter: { ...blockConfig.adapter, provider, model },
577
- };
578
-
579
- await saveBlockConfig(blockPath, updated);
580
-
581
- p.outro('Block configured. Starting...');
582
- return updated;
583
- }
584
-
585
- /**
586
- * Start all enabled blocks as daemons.
587
- * Skips blocks that are unconfigured (no model) or already running.
588
- * Used by `mblk start` (no args) and `mblk restart`.
589
- */
590
- export async function startAllEnabledBlocks(): Promise<void> {
591
- const globalConfig = await loadGlobalConfig();
592
- const blocksDir = resolveBlocksDir(globalConfig);
593
-
594
- if (!(await pathExists(blocksDir))) {
595
- log.dim(' No blocks directory found.');
596
- return;
597
- }
598
-
599
- const entries = await fsp.readdir(blocksDir, { withFileTypes: true });
600
- let started = 0;
601
-
602
- for (const entry of entries) {
603
- if (!entry.isDirectory() || entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
604
-
605
- const blockPath = join(blocksDir, entry.name);
606
- try {
607
- const blockConfig = await loadBlockConfig(blockPath);
608
-
609
- // Skip disabled blocks
610
- if (blockConfig.enabled === false) {
611
- log.dim(` ${entry.name}: disabled (skipped)`);
612
- continue;
613
- }
614
-
615
- // Skip unconfigured blocks (no model set)
616
- if (!blockConfig.adapter?.model) {
617
- log.dim(` ${entry.name}: not configured (skipped)`);
618
- continue;
619
- }
620
-
621
- // Skip already running blocks
622
- const pulse = await loadPulseState(blockPath);
623
- if (pulse.status === 'ACTIVE') {
624
- const lockFile = join(blockPath, '.lock');
625
- try {
626
- const pidStr = await fsp.readFile(lockFile, 'utf8');
627
- const pid = parseInt(pidStr.trim(), 10);
628
- try { process.kill(pid, 0); log.dim(` ${entry.name}: already running (PID ${pid})`); continue; } catch { /* stale */ }
629
- } catch { /* no lock file */ }
630
- }
631
-
632
- // Start as daemon
633
- await savePulseState(blockPath, {
634
- status: 'SLEEPING',
635
- lastRun: new Date().toISOString(),
636
- nextWakeUp: null,
637
- currentTask: null,
638
- error: null,
639
- });
640
- const daemon = await import(DAEMON_PKG);
641
- const pid = await daemon.spawnDaemon(blockConfig.name, 'multi', blockPath);
642
- log.success(` ${entry.name}: started (PID ${pid})`);
643
- started++;
644
- } catch (err) {
645
- log.warn(` ${entry.name}: failed to start — ${(err as Error).message}`);
646
- }
647
- }
648
-
649
- if (started === 0) {
650
- log.dim(' No enabled blocks to start.');
651
- } else {
652
- log.dim(`\n ${started} block(s) started as daemons.`);
653
- }
654
- }
655
-
656
- export async function startCommand(blockName?: string, options?: { channel?: string; daemon?: boolean }): Promise<void> {
657
- if (!(await isInitialized())) {
658
- throw new Error(t.general.notInitialized);
659
- }
660
-
661
- if (!blockName) {
662
- log.brand('Starting all blocks...\n');
663
- await startAllEnabledBlocks();
664
- return;
665
- }
666
-
667
- // Auto-install OS service hook quietly
668
- import('./service.js').then(s => s.silentServiceInstall()).catch(() => {});
669
-
670
- const globalConfig = await loadGlobalConfig();
671
- const blockPath = resolveBlockPath(globalConfig, blockName);
672
-
673
- if (!(await pathExists(blockPath))) {
674
- log.warn(`Block "${blockName}" does not exist.`);
675
- const createIt = await p.confirm({ message: `Would you like to create it now?` });
676
- if (p.isCancel(createIt) || !createIt) {
677
- process.exit(0);
678
- }
679
- const { createCommand } = await import('./create.js');
680
- await createCommand(blockName);
681
- }
682
-
683
- let blockConfig = await loadBlockConfig(blockPath);
684
- const auth = await loadAuth();
685
- const channelType = options?.channel || blockConfig.channel.type || 'cli';
686
-
687
- // ─── Single Instance Check ──────────────────────────────
688
- const pulse = await loadPulseState(blockPath);
689
- if (pulse.status === 'ACTIVE') {
690
- // Check if the lock PID is still alive (stale lock from a crash)
691
- const lockFile = join(blockPath, '.lock');
692
- let stale = false;
693
- try {
694
- const pidStr = await fsp.readFile(lockFile, 'utf8');
695
- const pid = parseInt(pidStr.trim(), 10);
696
- if (pid && pid !== process.pid) {
697
- try {
698
- process.kill(pid, 0); // signal 0 = check if process exists
699
- // Process is alive — block is genuinely running
700
- } catch {
701
- // Process is dead — stale lock
702
- stale = true;
703
- }
704
- } else if (!pid || isNaN(pid)) {
705
- stale = true;
706
- }
707
- } catch {
708
- // No lock file — might be stale from before locks existed
709
- stale = true;
710
- }
711
-
712
- if (!stale) {
713
- // Block is genuinely running — attach CLI to the running instance
714
- if (process.stdout.isTTY && !options?.daemon) {
715
- log.brand(`${blockName}\n`);
716
- await setupBlockRuntimeLogs(blockConfig, blockPath, auth, options, channelType);
717
- log.dim(' Block is already running. Attaching CLI to existing instance...\n');
718
- await attachCLIToRunningBlock(blockName, blockPath);
719
- return;
720
- }
721
- log.error(t.block.alreadyRunning(blockName));
722
- log.dim(` ${t.block.singleInstanceHint}`);
723
- log.dim(` ${t.block.stopHint(blockName)}\n`);
724
- return;
725
- }
726
-
727
- // Stale lock — clean it up and continue
728
- log.dim(` ${t.block.staleLockRecovered}`);
729
- try { await fsp.unlink(join(blockPath, '.lock')); } catch { /* ignore */ }
730
- }
731
-
732
- // Write lock file with our PID — but NOT when spawning a daemon,
733
- // because the daemon child process will write its own lock.
734
- if (!options?.daemon) {
735
- await fsp.writeFile(join(blockPath, '.lock'), String(process.pid), 'utf8');
736
- }
737
-
738
- // ─── Mini-Onboarding (if block has no model configured) ─────
739
- if (!blockConfig.adapter.model) {
740
- // Daemon / non-TTY mode cannot run interactive onboarding
741
- if (!process.stdout.isTTY || options?.daemon) {
742
- throw new Error(
743
- `Block "${blockName}" has no model configured. ` +
744
- `Run \`mblk start ${blockName}\` in a terminal first to complete setup.`
745
- );
746
- }
747
- blockConfig = await miniOnboarding(blockConfig, blockPath, blockName, auth, globalConfig);
748
- }
749
-
750
- // Mark block as enabled (persists across reboots)
751
- blockConfig.enabled = true;
752
- await saveBlockConfig(blockPath, blockConfig);
753
-
754
- // ─── AM I THE DAEMON CHILD? ───────────────────────────
755
- if (process.env.MBLK_IS_DAEMON === '1') {
756
- let shuttingDown = false;
757
- let monitor: any = null;
758
-
759
- const shutdown = async () => {
760
- if (shuttingDown) return;
761
- shuttingDown = true;
762
- if (monitor) {
763
- try { await monitor.stop(); } catch { /* ignore */ }
764
- }
765
- try {
766
- const lockFile = join(blockPath, '.lock');
767
- const pidStr = await fsp.readFile(lockFile, 'utf8');
768
- if (Number(pidStr.trim()) === process.pid) {
769
- await fsp.unlink(lockFile);
770
- }
771
- } catch { /* ignore */ }
772
- process.exit(0);
773
- };
774
-
775
- process.on('SIGINT', shutdown);
776
- process.on('SIGTERM', shutdown);
777
- process.on('uncaughtException', async (err) => {
778
- if (shuttingDown) return;
779
- log.error(t.errors.unexpected(err.message));
780
- await fsp.writeFile(join(blockPath, 'daemon-debug-error.log'), err.stack || err.message);
781
- await shutdown();
782
- });
783
- process.on('unhandledRejection', async (reason) => {
784
- if (shuttingDown) return;
785
- log.error(t.errors.unexpected(String(reason)));
786
- const stack = (reason as Error)?.stack || String(reason);
787
- await fsp.writeFile(join(blockPath, 'daemon-debug-error.log'), stack);
788
- await shutdown();
789
- });
790
-
791
- try {
792
- console.log("TRACE: Before setupBlockRuntimeLogs");
793
- const { adapter, registry, channel } = await setupBlockRuntimeLogs(blockConfig, blockPath, auth, options, channelType);
794
- console.log("TRACE: After setupBlockRuntimeLogs", { hasAdapter: !!adapter, hasRegistry: !!registry, hasChannel: !!channel });
795
- if (!adapter || !registry || !channel) return;
796
-
797
- // Create and start the monitor in the background
798
- console.log("TRACE: Creating monitor");
799
- monitor = new Monitor({ blockPath, blockConfig, adapter, registry, channel });
800
- console.log("TRACE: Starting monitor");
801
- await monitor.start();
802
- console.log("TRACE: Monitor started");
803
- } catch (err) {
804
- console.log("TRACE: Caught error", (err as Error).message);
805
- log.error(`Daemon init failed: ${(err as Error).message}`);
806
- await fsp.writeFile(join(blockPath, 'daemon-debug-error.log'), (err as Error).stack || (err as Error).message);
807
- process.exit(1);
808
- }
809
- console.log("TRACE: End of MBLK_IS_DAEMON block");
810
- return; // The daemon runs indefinitely here
811
- }
812
-
813
- // ─── I AM THE PARENT CLI ──────────────────────────────
814
- try {
815
- // Reset pulse so the daemon child doesn't see stale ACTIVE status
816
- await savePulseState(blockPath, {
817
- status: 'SLEEPING',
818
- lastRun: new Date().toISOString(),
819
- nextWakeUp: null,
820
- currentTask: null,
821
- error: null,
822
- });
823
- const daemon = await import(DAEMON_PKG);
824
- const pid = await daemon.spawnDaemon(blockConfig.name, channelType, blockPath);
825
-
826
- if (options?.daemon) {
827
- log.brand(`${blockConfig.name}\n`);
828
- log.success(`Daemon spawned successfully! PID: ${pid}`);
829
- return;
830
- }
831
-
832
- // Always background the daemon, then attach CLI natively
833
- log.brand(`${blockConfig.name}\n`);
834
- log.success(`Daemon spawned (PID ${pid}). Attaching CLI...\n`);
835
-
836
- // Let daemon init chat.json and WebChannel before tailing
837
- await new Promise(r => setTimeout(r, 1500));
838
- await setupBlockRuntimeLogs(blockConfig, blockPath, auth, options, channelType);
839
- await attachCLIToRunningBlock(blockName, blockPath);
840
- } catch (err) {
841
- throw new Error(`Failed to spawn daemon: ${(err as Error).message}`);
842
- }
843
- }