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,719 +0,0 @@
1
- import type {
2
- LLMAdapter, LLMMessage, ToolDefinition, Channel, BlockConfig, ChannelMessage,
3
- IToolRegistry,
4
- } from '../types.js';
5
- import { MemoryManager } from './memory.js';
6
- import { Gatekeeper } from './gatekeeper.js';
7
- import { ConversationLogger } from './conversation-log.js';
8
- import { CostTracker } from './cost-tracker.js';
9
- import { savePulseState, saveBlockConfig, loadGlobalConfig, resolveBlocksDir, getWsRoot } from '../utils/config.js';
10
- import { ensureDir, pathExists } from '../utils/fs.js';
11
- import { log } from '../cli/logger.js';
12
- import { t } from '@memoryblock/locale';
13
- import { SYSTEM_PROMPTS } from './prompts.js';
14
- import { promises as fsp } from 'node:fs';
15
- import { join } from 'node:path';
16
-
17
-
18
- // Safe commands that skip approval — monitor can auto-execute these
19
- const SAFE_PREFIXES = [
20
- 'ls', 'cat', 'head', 'tail', 'wc', 'find', 'grep', 'which', 'echo', 'pwd',
21
- 'node --version', 'bun --version', 'pnpm --version', 'npm --version',
22
- 'git status', 'git log', 'git diff', 'git branch',
23
- 'tsc --noEmit', 'npx eslint', 'pnpm lint', 'npm run lint',
24
- 'pnpm build', 'npm run build', 'pnpm test', 'npm test',
25
- ];
26
-
27
- export interface MonitorConfig {
28
- blockPath: string;
29
- blockConfig: BlockConfig;
30
- adapter: LLMAdapter;
31
- registry: IToolRegistry;
32
- channel: Channel;
33
- }
34
-
35
- /**
36
- * The Monitor — a block's resident intelligence.
37
- *
38
- * Features:
39
- * - Identity (name, emoji, personality) persisted to monitor.md
40
- * - Conversation logging to logs/
41
- * - System-level cost tracking (no model tokens wasted)
42
- * - Smart memory: saves only key context at threshold/stop
43
- * - Safe-command auto-execution (lint, build, test, grep — no approval needed)
44
- * - Sandbox toggle: sandbox=false gives full filesystem access
45
- */
46
- export class Monitor {
47
- private memory: MemoryManager;
48
- private gatekeeper: Gatekeeper;
49
- private logger: ConversationLogger;
50
- private channel: Channel;
51
- private blockPath: string;
52
- private costTracker: CostTracker;
53
- private messages: LLMMessage[] = [];
54
- private running = false;
55
- private toolsDiscovered = false;
56
- private toolsUsedThisCycle = false; // Track when tools have been used after discovery
57
- private monitorName: string;
58
- private monitorEmoji: string;
59
- private blockConfig: BlockConfig;
60
- private adapter: LLMAdapter;
61
- private registry: IToolRegistry;
62
-
63
- constructor(options: {
64
- blockPath: string;
65
- blockConfig: BlockConfig;
66
- adapter: LLMAdapter;
67
- registry: IToolRegistry;
68
- channel: Channel;
69
- }) {
70
- this.blockPath = options.blockPath;
71
- this.blockConfig = options.blockConfig;
72
- this.adapter = options.adapter;
73
- this.registry = options.registry;
74
- this.channel = options.channel;
75
-
76
- this.monitorName = options.blockConfig.monitorName || 'Monitor';
77
- this.monitorEmoji = options.blockConfig.monitorEmoji || '✨';
78
- this.memory = new MemoryManager(
79
- options.blockConfig.memory.maxContextTokens,
80
- options.blockConfig.memory.thresholdPercent,
81
- );
82
- this.gatekeeper = new Gatekeeper(
83
- options.channel,
84
- options.blockConfig.name,
85
- this.monitorName,
86
- );
87
- this.logger = new ConversationLogger(options.blockPath);
88
- this.costTracker = new CostTracker(options.blockPath, options.blockConfig.adapter.model);
89
- }
90
-
91
- async start(): Promise<void> {
92
- this.running = true;
93
- const { blockConfig, blockPath } = this;
94
-
95
- // Load previous cost totals
96
- await this.costTracker.load();
97
-
98
- await savePulseState(blockPath, {
99
- status: 'ACTIVE',
100
- lastRun: new Date().toISOString(),
101
- nextWakeUp: null,
102
- currentTask: 'Online',
103
- error: null,
104
- });
105
-
106
- // founder.md lives at the workspace root (~/.memoryblock/ws/), shared across blocks
107
- const wsRoot = getWsRoot();
108
- const [monitorContent, memoryContent, founderContent] = await Promise.all([
109
- this.readFile(join(blockPath, 'monitor.md')),
110
- this.readFile(join(blockPath, 'memory.md')),
111
- this.readFile(join(wsRoot, 'founder.md')),
112
- ]);
113
-
114
- const isFirstRun = !blockConfig.monitorName;
115
-
116
- const systemPrompt = this.buildSystemPrompt(
117
- blockConfig, monitorContent, memoryContent, founderContent, isFirstRun,
118
- );
119
- // System prompt pushed ONCE at session start, never re-sent per message
120
- this.messages = [{ role: 'system', content: systemPrompt }];
121
-
122
- await this.logger.init(blockConfig.name, this.monitorName, this.channel.name);
123
-
124
- const displayName = `${this.monitorEmoji} ${this.monitorName}`;
125
- log.monitor(blockConfig.name, displayName, 'Online. Listening...');
126
-
127
- this.channel.onMessage(async (msg: ChannelMessage) => {
128
- if (!this.running) return;
129
- await this.handleUserMessage(msg.content, msg._sourceChannel);
130
- });
131
-
132
- await this.channel.start();
133
- }
134
-
135
- async stop(): Promise<void> {
136
- this.running = false;
137
-
138
- // Save smart memory summary before shutdown
139
- await this.saveSmartMemory();
140
-
141
- // Persist cost data
142
- await this.costTracker.save();
143
-
144
- await this.logger.close();
145
- await this.channel.stop();
146
- // Only write SLEEPING state if we still own the lock
147
- let isSuperseded = false;
148
- try {
149
- const pidStr = await fsp.readFile(join(this.blockPath, '.lock'), 'utf8');
150
- if (Number(pidStr.trim()) !== process.pid) isSuperseded = true;
151
- } catch { /* proceed if no lock */ }
152
-
153
- if (!isSuperseded) {
154
- await savePulseState(this.blockPath, {
155
- status: 'SLEEPING',
156
- lastRun: new Date().toISOString(),
157
- nextWakeUp: null,
158
- currentTask: null,
159
- error: null,
160
- });
161
- }
162
-
163
- const sessionReport = this.costTracker.getSessionReport();
164
- const totalReport = this.costTracker.getTotalReport();
165
- console.log('');
166
- log.monitor(this.blockConfig.name, this.monitorName, t.monitor.goingToSleep);
167
- log.dim(` ${t.monitor.currentSession}: ${sessionReport}`);
168
- log.dim(` ${t.monitor.completeSession}: ${totalReport}`);
169
- }
170
-
171
- private async handleUserMessage(content: string, sourceChannel?: string): Promise<void> {
172
- this.logger.logUser(content, {
173
- channel: sourceChannel || this.blockConfig.channel.type,
174
- chatId: this.blockConfig.channel.telegram?.chatId,
175
- });
176
-
177
- let llmContent = content;
178
-
179
- // Native Slash Commands Interception
180
- const text = content.trim();
181
- if (text.startsWith('/')) {
182
- const parts = text.split(/\s+/);
183
- const cmd = parts[0].toLowerCase();
184
- let handled = false;
185
- let output = '';
186
- let shouldContinue = false;
187
-
188
- if (cmd === '/status') {
189
- handled = true;
190
- output = await this.getSystemStatus();
191
- shouldContinue = parts.length > 1;
192
- llmContent = text.substring(cmd.length).trim();
193
- } else if (cmd === '/create-block') {
194
- handled = true;
195
- if (parts.length < 2) {
196
- output = 'Usage: /create-block <name> [optional instructions]';
197
- } else {
198
- const name = parts[1];
199
- if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(name)) {
200
- output = 'Invalid block name. Use lowercase letters, numbers, hyphens (max 32).';
201
- } else {
202
- output = await this.createBlockNatively(name);
203
- shouldContinue = parts.length > 2;
204
- llmContent = text.substring(cmd.length + name.length + 1).trim();
205
- }
206
- }
207
- } else if (cmd.startsWith('/switch')) {
208
- handled = true;
209
- const target = cmd.startsWith('/switch-') ? cmd.slice(8) : parts[1];
210
- if (!target) {
211
- output = 'Usage: /switch-<block> or /switch <block>';
212
- } else {
213
- output = `To switch to \`${target}\`, suspend this session (Ctrl+C) and run:\n\`mblk start ${target}\``;
214
- }
215
- }
216
-
217
- if (handled) {
218
- if (output) {
219
- // Send directly back to where it came from if multi-channel
220
- await this.sendToChannel(`⚙️ **System:**\n${output}`, sourceChannel);
221
- }
222
- if (!shouldContinue) return;
223
- }
224
- }
225
-
226
- // Apply source headers for multi-channel differentiation before sending to LLM
227
- if (sourceChannel) {
228
- llmContent = `[Source: channel:${sourceChannel}]\n${llmContent || content}`;
229
- } else {
230
- llmContent = llmContent || content;
231
- }
232
-
233
- // If command had trailing text (or wasn't a command), let the AI respond.
234
- this.messages.push({ role: 'user', content: llmContent });
235
- try {
236
- await this.runConversationLoop(sourceChannel);
237
- } catch (err) {
238
- const message = err instanceof Error ? err.message : String(err);
239
- log.error(`Monitor error: ${message}`);
240
- const errMsg = `Something went wrong on my end. Give me a moment and try again.\n\n_${message}_`;
241
- await this.sendToChannel(errMsg);
242
- }
243
- }
244
-
245
- private async runConversationLoop(sourceChannel?: string): Promise<void> {
246
- const { adapter, blockConfig, blockPath } = this;
247
-
248
- while (this.running) {
249
- const tools = this.getToolDefinitions();
250
-
251
- const streamCb = this.channel.streamChunk ? (chunk: string) => this.channel.streamChunk!(chunk) : undefined;
252
- const response = adapter.converseStream && streamCb
253
- ? await adapter.converseStream(this.messages, tools, streamCb)
254
- : await adapter.converse(this.messages, tools);
255
-
256
- // System-level cost tracking — no model tokens used
257
- this.memory.trackUsage(response.usage);
258
- this.costTracker.track(response.usage);
259
-
260
- this.messages.push(response.message);
261
-
262
- if (response.stopReason === 'tool_use' && response.message.toolCalls) {
263
- // Log tool calls compactly in a single dim line
264
- const toolNames = response.message.toolCalls.map(tc => `[${tc.name}]`).join(' ');
265
- log.system(blockConfig.name, `\nTOOLS: ${toolNames}`);
266
-
267
- const toolResults = await this.dispatchToolCalls(response.message.toolCalls);
268
-
269
- // CRITICAL: Every tool_use must have a corresponding tool_result in the very next message
270
- this.messages.push({ role: 'tool', toolResults });
271
-
272
- if (response.message.toolCalls.some((tc: any) => tc.name === 'list_tools_available')) {
273
- this.toolsDiscovered = true;
274
- this.toolsUsedThisCycle = false;
275
- } else if (this.toolsDiscovered) {
276
- this.toolsUsedThisCycle = true;
277
- }
278
-
279
- // Trim tool results in history to save tokens on next turn
280
- this.trimHistory();
281
-
282
- await this.syncIdentityFromFiles();
283
- continue;
284
- }
285
-
286
- if (response.message.content) {
287
- // Send response (token info passed as metadata)
288
- await this.sendToChannel(response.message.content, sourceChannel, this.costTracker.getPerTurnReport());
289
- }
290
-
291
- // Trim history AFTER sending response — keeps API messages lean
292
- this.trimHistory();
293
-
294
- // Save trimmed session state for resumption on crash/restart
295
- await this.saveSessionState();
296
-
297
- if (this.memory.shouldSummarize()) {
298
- this.logger.logSystem('Memory threshold reached — summarizing context.');
299
- log.system(blockConfig.name, 'Memory threshold. Smart-saving context...');
300
-
301
- // Save smart memory (key info only, not full conversation)
302
- const freshMemory = await this.memory.summarize(adapter, this.messages, blockPath);
303
- const monitorContent = await this.readFile(join(blockPath, 'monitor.md'));
304
- const wsRoot = getWsRoot();
305
- const founderContent = await this.readFile(join(wsRoot, 'founder.md'));
306
- const systemPrompt = this.buildSystemPrompt(
307
- blockConfig, monitorContent, freshMemory, founderContent, false,
308
- );
309
- this.messages = [{ role: 'system', content: systemPrompt }];
310
- this.toolsDiscovered = false;
311
-
312
- // Save costs on threshold
313
- await this.costTracker.save();
314
- }
315
-
316
- break;
317
- }
318
- }
319
-
320
- /** Save smart memory — only key context, not full conversation. For session resumption. */
321
- private async saveSmartMemory(): Promise<void> {
322
- const { blockPath } = this;
323
- const memoryPath = join(blockPath, 'memory.md');
324
-
325
- // Only save if we had meaningful conversation
326
- if (this.messages.length <= 2) return;
327
-
328
- try {
329
- // Extract key context from conversation (last user messages and assistant responses)
330
- const keyMessages = this.messages
331
- .filter((m) => m.role === 'user' || (m.role === 'assistant' && m.content))
332
- .slice(-10); // Last 10 meaningful exchanges
333
-
334
- const summary = [
335
- '# Memory',
336
- '',
337
- `> Last session: ${new Date().toISOString()}`,
338
- `> Session cost: ${this.costTracker.getSessionReport()}`,
339
- '',
340
- '## Recent Context',
341
- ];
342
-
343
- for (const msg of keyMessages) {
344
- if (msg.role === 'user') {
345
- summary.push(`- **User:** ${(msg.content || '').slice(0, 200)}`);
346
- } else if (msg.content) {
347
- summary.push(`- **Monitor:** ${msg.content.slice(0, 200)}`);
348
- }
349
- }
350
-
351
- summary.push('', '## Notes');
352
- summary.push('(Search logs/ for full conversation history)');
353
-
354
- await fsp.writeFile(memoryPath, summary.join('\n'), 'utf-8');
355
- } catch {
356
- // Non-critical — don't crash on memory save failure
357
- }
358
- }
359
-
360
- private async getSystemStatus(): Promise<string> {
361
- try {
362
- const globalConfig = await loadGlobalConfig();
363
- const blocksDir = resolveBlocksDir(globalConfig);
364
- const dirs = await fsp.readdir(blocksDir);
365
- let out = '🚥 **System Status**\n\n';
366
- for (const d of dirs) {
367
- if (d.startsWith('_') || d.startsWith('.')) continue;
368
- const bPath = join(blocksDir, d);
369
- const isDir = await fsp.stat(bPath).then(s => s.isDirectory()).catch(() => false);
370
- if (!isDir) continue;
371
-
372
- try {
373
- const blockCfgStr = await fsp.readFile(join(bPath, 'config.json'), 'utf8');
374
- const blockCfg = JSON.parse(blockCfgStr);
375
- const pulseStr = await fsp.readFile(join(bPath, 'pulse.json'), 'utf8').catch(() => '{}');
376
- const pulse = JSON.parse(pulseStr);
377
- const status = pulse.status || 'UNKNOWN';
378
- const icon = status === 'ACTIVE' ? '🟢' : '💤';
379
- out += `${icon} **${blockCfg.name}** — ${blockCfg.description || 'No description'} _(${status})_\n`;
380
- } catch {
381
- // ignore corrupted blocks
382
- }
383
- }
384
- return out;
385
- } catch (err) {
386
- return `Failed to read status: ${(err as Error).message}`;
387
- }
388
- }
389
-
390
- private async createBlockNatively(name: string): Promise<string> {
391
- try {
392
- const globalConfig = await loadGlobalConfig();
393
- const blocksDir = resolveBlocksDir(globalConfig);
394
- const blockPath = join(blocksDir, name);
395
-
396
- if (await pathExists(blockPath)) {
397
- return `Block \`${name}\` already exists.`;
398
- }
399
-
400
- await ensureDir(blockPath);
401
- await ensureDir(join(blockPath, 'agents'));
402
- await ensureDir(join(blockPath, 'logs'));
403
-
404
- const config = {
405
- name,
406
- description: 'Created via slash command',
407
- adapter: globalConfig.defaults.adapter,
408
- memory: globalConfig.defaults.memory,
409
- pulse: globalConfig.defaults.pulse,
410
- channel: { type: 'cli' },
411
- tools: { enabled: ['*'], sandbox: false }
412
- };
413
-
414
- await saveBlockConfig(blockPath, config as any);
415
- await savePulseState(blockPath, {
416
- status: 'SLEEPING',
417
- lastRun: new Date().toISOString(),
418
- nextWakeUp: null,
419
- currentTask: null,
420
- error: null
421
- } as any);
422
-
423
- await fsp.writeFile(join(blockPath, 'memory.md'), `# ${name}\n\n(no memory yet)`, 'utf8');
424
- await fsp.writeFile(join(blockPath, 'monitor.md'), `You are an AI assistant in the block: ${name}.`, 'utf8');
425
-
426
- return `✅ Block \`${name}\` created natively.`;
427
- } catch (err) {
428
- return `Failed to create block: ${(err as Error).message}`;
429
- }
430
- }
431
-
432
- /**
433
- * Trim tool results in message history to save tokens.
434
- *
435
- * This is SYSTEM-LEVEL compaction — no model calls, zero extra cost.
436
- * Only the internal `this.messages` array is trimmed. Full content is
437
- * preserved in ConversationLogger and CLIChannel output.
438
- *
439
- * Rules:
440
- * - list_tools_available results → "(N tools discovered)"
441
- * - read_file results > 500 chars → truncated with note
442
- * - search_files results > 500 chars → truncated with note
443
- * - write_file / replace_in_file → kept as-is (already compact)
444
- * - execute_command output > 1000 chars → truncated
445
- */
446
- private trimHistory(): void {
447
- // Max chars for different tool types in history
448
- const TRIM_LIMITS: Record<string, { limit: number; hint: string }> = {
449
- list_tools_available: { limit: 50, hint: '' },
450
- read_file: { limit: 500, hint: 'call read_file again if you need the full content' },
451
- search_files: { limit: 500, hint: 'call search_files again to see more results' },
452
- execute_command: { limit: 1000, hint: 're-run the command if you need the full output' },
453
- };
454
-
455
- for (const msg of this.messages) {
456
- if (msg.role !== 'tool' || !msg.toolResults) continue;
457
-
458
- for (const result of msg.toolResults) {
459
- // Special case: list_tools_available → ultra compact
460
- if (result.name === 'list_tools_available') {
461
- const toolCount = (result.content.match(/^- /gm) || []).length;
462
- result.content = `(${toolCount} tools discovered)`;
463
- continue;
464
- }
465
-
466
- const config = TRIM_LIMITS[result.name];
467
- if (config && result.content.length > config.limit) {
468
- result.content = result.content.slice(0, config.limit) +
469
- `\n...(trimmed for efficiency — ${config.hint})`;
470
- }
471
- }
472
- }
473
- }
474
-
475
- /**
476
- * Save trimmed session state for resumption.
477
- * If the terminal crashes or the user restarts, we can pick up
478
- * from the trimmed messages rather than starting from scratch.
479
- * This is separate from memory.md (which is for cross-session context).
480
- */
481
- private async saveSessionState(): Promise<void> {
482
- const sessionPath = join(this.blockPath, 'session.json');
483
- try {
484
- // Only save user/assistant messages (not system — rebuilt on start)
485
- const sessionMessages = this.messages
486
- .filter((m) => m.role !== 'system')
487
- .map((m) => ({
488
- role: m.role,
489
- content: m.content,
490
- toolCalls: m.toolCalls,
491
- toolResults: m.toolResults,
492
- }));
493
-
494
- await fsp.writeFile(sessionPath, JSON.stringify({
495
- monitorName: this.monitorName,
496
- monitorEmoji: this.monitorEmoji,
497
- toolsDiscovered: this.toolsDiscovered,
498
- toolsUsedThisCycle: this.toolsUsedThisCycle,
499
- messages: sessionMessages,
500
- savedAt: new Date().toISOString(),
501
- }, null, 2), 'utf-8');
502
- } catch {
503
- // Non-critical — session state is a convenience, not a requirement
504
- }
505
- }
506
-
507
- /** Sync monitor identity after tool calls (since background daemons never restart natively). */
508
- private async syncIdentityFromFiles(): Promise<void> {
509
- const { blockPath } = this;
510
- let identityChanged = false;
511
-
512
- // 1. Always prioritize reading the latest config.json (updated by tools)
513
- try {
514
- const configStr = await this.readFile(join(blockPath, 'config.json'));
515
- const diskConfig = JSON.parse(configStr) as BlockConfig;
516
-
517
- // Check if identity changed in config
518
- if (diskConfig.monitorName && diskConfig.monitorName !== this.monitorName) {
519
- this.monitorName = diskConfig.monitorName;
520
- identityChanged = true;
521
- }
522
- if (diskConfig.monitorEmoji && diskConfig.monitorEmoji !== this.monitorEmoji) {
523
- this.monitorEmoji = diskConfig.monitorEmoji;
524
- identityChanged = true;
525
- }
526
-
527
- // Apply all config updates to the local running map
528
- this.blockConfig = diskConfig;
529
- } catch { /* ignore parse errors */ }
530
-
531
- // 2. Fallback check for manual edits to monitor.md
532
- const content = await this.readFile(join(blockPath, 'monitor.md'));
533
- const nameMatch = content.match(/^\*\*Name:\*\*\s+(.+)$/m);
534
- const emojiMatch = content.match(/^\*\*Emoji:\*\*\s+(.+)$/m);
535
-
536
- const mdName = nameMatch?.[1]?.trim();
537
- const mdEmoji = emojiMatch?.[1]?.trim();
538
-
539
- if (mdName && mdName !== '(not set — will be chosen on first run)' &&
540
- (mdName !== this.monitorName || (mdEmoji && mdEmoji !== this.monitorEmoji))) {
541
-
542
- this.monitorName = mdName;
543
- this.monitorEmoji = mdEmoji || this.monitorEmoji;
544
- identityChanged = true;
545
-
546
- // Reflect markdown-driven identity changes back into the config
547
- this.blockConfig = { ...this.blockConfig, monitorName: this.monitorName, monitorEmoji: this.monitorEmoji };
548
- await saveBlockConfig(blockPath, this.blockConfig);
549
- }
550
-
551
- if (identityChanged) {
552
- log.monitor(this.blockConfig.name, `${this.monitorEmoji} ${this.monitorName}`, 'Identity updated dynamically.');
553
- }
554
- }
555
-
556
- private async dispatchToolCalls(toolCalls: NonNullable<LLMMessage['toolCalls']>) {
557
- const { registry, blockPath, blockConfig } = this;
558
- const results = [];
559
- const sandbox = blockConfig.tools.sandbox;
560
- const workingDir = blockConfig.tools.workingDir || blockPath;
561
-
562
- for (const tc of toolCalls) {
563
- try {
564
- const toolDef = registry.listTools().find((t: ToolDefinition) => t.name === tc.name);
565
-
566
- // Smart approval: skip approval for safe commands
567
- if (toolDef?.requiresApproval && tc.name === 'execute_command') {
568
- const cmd = (tc.input as Record<string, string>).command || '';
569
- const isSafe = SAFE_PREFIXES.some((p) => cmd.trim().startsWith(p));
570
- if (!isSafe) {
571
- const approved = await this.gatekeeper.requestApproval(tc.name, tc.input);
572
- if (!approved) {
573
- results.push({ toolCallId: tc.id, name: tc.name, content: 'Action denied by user.', isError: true });
574
- continue;
575
- }
576
- }
577
- } else if (toolDef?.requiresApproval) {
578
- const approved = await this.gatekeeper.requestApproval(tc.name, tc.input);
579
- if (!approved) {
580
- results.push({ toolCallId: tc.id, name: tc.name, content: 'Action denied by user.', isError: true });
581
- continue;
582
- }
583
- }
584
-
585
- const result = await registry.execute(tc.name, tc.input, {
586
- blockPath,
587
- blockName: blockConfig.name,
588
- workingDir,
589
- sandbox,
590
- dispatchMessage: async (target: string, content: string) => {
591
- // Log it to the conversation history
592
- this.logger.logMonitor(content, {
593
- channel: target,
594
- monitorName: this.monitorName,
595
- emoji: this.monitorEmoji,
596
- });
597
-
598
- // Dispatch selectively via multi-channel manager overrides
599
- await this.channel.send({
600
- blockName: this.blockConfig.name,
601
- monitorName: `${this.monitorEmoji} ${this.monitorName}`,
602
- content,
603
- isSystem: false,
604
- timestamp: new Date().toISOString(),
605
- _targetChannel: target,
606
- });
607
- }
608
- });
609
- results.push({ toolCallId: tc.id, name: tc.name, content: result.content, isError: result.isError });
610
- } catch (err) {
611
- // Return errors as tool results to keep history healthy
612
- results.push({
613
- toolCallId: tc.id,
614
- name: tc.name,
615
- content: `Tool error: ${err instanceof Error ? err.message : String(err)}`,
616
- isError: true,
617
- });
618
- }
619
- }
620
-
621
- return results;
622
- }
623
-
624
- private getToolDefinitions(): ToolDefinition[] {
625
- // ALWAYS provide the discovery tool.
626
- const discoveryTool = this.registry.getDiscoveryTool();
627
-
628
- if (!this.toolsDiscovered) {
629
- // First contact: only provide discovery tool
630
- return [discoveryTool];
631
- }
632
-
633
- if (!this.toolsUsedThisCycle) {
634
- // Discovered but not yet used — send full schemas for ONE cycle
635
- return [discoveryTool, ...this.registry.listTools()];
636
- }
637
-
638
- // After tools have been used: only provide discovery tool + compact reminder
639
- // This saves ~2,500 tokens per turn by not re-sending all 11 tool schemas
640
- return [discoveryTool];
641
- }
642
-
643
- private async sendToChannel(content: string, targetChannel?: string, costReport?: string, sessionReport?: string, totalReport?: string): Promise<void> {
644
- this.logger.logMonitor(content, {
645
- channel: targetChannel || this.blockConfig.channel.type,
646
- monitorName: this.monitorName,
647
- emoji: this.monitorEmoji,
648
- });
649
-
650
- await this.channel.send({
651
- blockName: this.blockConfig.name,
652
- monitorName: `${this.monitorEmoji} ${this.monitorName}`,
653
- content,
654
- isSystem: false,
655
- timestamp: new Date().toISOString(),
656
- _targetChannel: targetChannel,
657
- costReport,
658
- sessionReport,
659
- totalReport,
660
- });
661
- }
662
-
663
- private async readFile(path: string): Promise<string> {
664
- try {
665
- return await fsp.readFile(path, 'utf-8');
666
- } catch {
667
- return '';
668
- }
669
- }
670
-
671
- private buildSystemPrompt(
672
- config: BlockConfig,
673
- monitorMd: string,
674
- memoryMd: string,
675
- founderMd: string,
676
- isFirstRun: boolean,
677
- ): string {
678
- const goals = config.goals.length > 0
679
- ? config.goals.map((g: string, i: number) => `${i + 1}. ${g}`).join('\n')
680
- : 'No specific goals set yet.';
681
-
682
- const parts: string[] = [];
683
- const absoluteMonitorPath = join(this.blockPath, 'monitor.md');
684
- const absoluteMemoryPath = join(this.blockPath, 'memory.md');
685
- const absoluteFounderPath = join(getWsRoot(), 'founder.md');
686
-
687
- if (isFirstRun) {
688
- parts.push(SYSTEM_PROMPTS.MONITOR_FIRST_RUN(config.name, absoluteMonitorPath, absoluteMemoryPath, absoluteFounderPath));
689
- } else {
690
- parts.push(SYSTEM_PROMPTS.MONITOR_RESUME(this.monitorName, this.monitorEmoji, config.name));
691
- }
692
-
693
- if (config.description) {
694
- parts.push(`\n**Block:** ${config.description}`);
695
- }
696
-
697
- parts.push(`\n## Your Goals\n${goals}`);
698
-
699
- parts.push(`\n${SYSTEM_PROMPTS.OPERATING_GUIDELINES(this.channel.getActiveChannels?.() || [this.channel.name])}`);
700
-
701
- if (monitorMd && !monitorMd.includes('(not set')) {
702
- parts.push(`\n## Your Identity\n${monitorMd}`);
703
- }
704
-
705
- if (founderMd && !founderMd.includes('(unknown)')) {
706
- parts.push(`\n## The Founder\n${founderMd}`);
707
- }
708
-
709
- if (memoryMd && !memoryMd.includes('No history yet')) {
710
- parts.push(`\n## Memory (Previous Context)\n${memoryMd}`);
711
- }
712
-
713
- if (this.toolsDiscovered && this.toolsUsedThisCycle) {
714
- parts.push(`\n${SYSTEM_PROMPTS.TOOL_REMINDER(this.registry.listTools().length)}`);
715
- }
716
-
717
- return parts.join('\n');
718
- }
719
- }