@myvillage/cli 1.3.0 → 1.5.1

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.
@@ -0,0 +1,281 @@
1
+ // ── Agent Loop ──────────────────────────────────────────
2
+ // Core agent loop using Vercel AI SDK. Reads prompt.md,
3
+ // gathers context, calls LLM with tools, logs results.
4
+
5
+ import { generateText } from 'ai';
6
+ import { createAnthropic } from '@ai-sdk/anthropic';
7
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync } from 'fs';
8
+ import { join } from 'path';
9
+ import { homedir } from 'os';
10
+ import { getMCPTools, cleanupMCPClients } from './mcp-client.js';
11
+ import { gatherContext } from './context.js';
12
+ import { isWithinActiveHours, getNextCheckInMs } from './scheduler.js';
13
+ import { parse as parseYaml } from 'yaml';
14
+ import { postAgentHeartbeat } from '../utils/api.js';
15
+
16
+ export async function agentLoop(agentName, { signal }) {
17
+ const agentDir = join(homedir(), '.myvillage', 'agents', agentName);
18
+ const configPath = join(agentDir, 'agent.config.yaml');
19
+ const config = parseYaml(readFileSync(configPath, 'utf-8'));
20
+
21
+ // Read API key from global config or environment
22
+ const globalConfigPath = join(homedir(), '.myvillage', 'config.json');
23
+ let apiKey = process.env.ANTHROPIC_API_KEY;
24
+ if (!apiKey && existsSync(globalConfigPath)) {
25
+ try {
26
+ const globalConfig = JSON.parse(readFileSync(globalConfigPath, 'utf-8'));
27
+ apiKey = globalConfig.anthropicApiKey;
28
+ } catch { /* ignore */ }
29
+ }
30
+
31
+ if (!apiKey) {
32
+ logActivity(agentDir, { type: 'error', error: 'No Anthropic API key configured' });
33
+ throw new Error('No Anthropic API key configured');
34
+ }
35
+
36
+ const anthropic = createAnthropic({ apiKey });
37
+ const modelId = config.brain?.model || 'claude-sonnet-4-5-20250929';
38
+ const model = anthropic(modelId);
39
+ const maxTokens = config.brain?.max_tokens_per_loop || 1000;
40
+
41
+ // Initialize MCP tools
42
+ let tools;
43
+ try {
44
+ tools = await getMCPTools(agentDir, config);
45
+ logActivity(agentDir, {
46
+ type: 'info',
47
+ message: `Agent started. Model: ${modelId}, Tools: ${Object.keys(tools).join(', ') || 'none'}`,
48
+ });
49
+ } catch (err) {
50
+ logActivity(agentDir, { type: 'error', error: `Failed to initialize tools: ${err.message}` });
51
+ throw err;
52
+ }
53
+
54
+ let lastCheckIn = null;
55
+ let iteration = 0;
56
+ const recentActions = []; // Track actions across iterations to avoid duplicates
57
+
58
+ while (!signal.aborted) {
59
+ // Check active hours
60
+ if (!isWithinActiveHours(config.schedule)) {
61
+ await sleep(60000, signal);
62
+ continue;
63
+ }
64
+
65
+ // For manual-only agents (interval=0), only run once then exit
66
+ const intervalMs = getNextCheckInMs(config.schedule);
67
+ if (intervalMs === 0 && iteration > 0) {
68
+ break;
69
+ }
70
+
71
+ iteration++;
72
+ const loopStart = Date.now();
73
+
74
+ logActivity(agentDir, { type: 'loop_start', iteration });
75
+ updateHeartbeat(agentDir);
76
+
77
+ // Activity counters for this iteration
78
+ const activity = {
79
+ postsCreated: 0,
80
+ commentsCreated: 0,
81
+ votesGiven: 0,
82
+ toolCalls: 0,
83
+ };
84
+ let feedItemsRead = 0;
85
+ let mentionsFound = 0;
86
+
87
+ try {
88
+ // Read prompt.md fresh each iteration (villager may have edited it)
89
+ const promptPath = join(agentDir, 'prompt.md');
90
+ const systemPrompt = existsSync(promptPath)
91
+ ? readFileSync(promptPath, 'utf-8')
92
+ : `You are an agent named ${config.display_name || agentName}. Be helpful and concise.`;
93
+
94
+ // Gather context (returns { text, mentionsCount })
95
+ const contextResult = await gatherContext(config, lastCheckIn, recentActions);
96
+ const context = contextResult.text;
97
+ mentionsFound = contextResult.mentionsCount;
98
+
99
+ // Count feed items from context
100
+ feedItemsRead = (context.match(/^- @/gm) || []).length;
101
+
102
+ logActivity(agentDir, {
103
+ type: 'context',
104
+ feedItems: feedItemsRead,
105
+ mentions: mentionsFound,
106
+ });
107
+
108
+ // Call LLM with tools
109
+ const result = await generateText({
110
+ model,
111
+ system: systemPrompt,
112
+ prompt: context,
113
+ tools,
114
+ maxSteps: 5,
115
+ maxTokens,
116
+ });
117
+
118
+ // Log LLM response
119
+ logActivity(agentDir, {
120
+ type: 'llm_response',
121
+ text: (result.text || '').slice(0, 500),
122
+ tokensUsed: {
123
+ prompt: result.usage?.promptTokens || 0,
124
+ completion: result.usage?.completionTokens || 0,
125
+ },
126
+ });
127
+
128
+ // Log tool calls and count activity
129
+ if (result.steps?.length) {
130
+ for (const step of result.steps) {
131
+ if (step.toolCalls?.length) {
132
+ for (const tc of step.toolCalls) {
133
+ activity.toolCalls++;
134
+ if (tc.toolName === 'post_create') activity.postsCreated++;
135
+ if (tc.toolName === 'comment_create') activity.commentsCreated++;
136
+ if (tc.toolName === 'vote_cast') activity.votesGiven++;
137
+ }
138
+ }
139
+ if (step.toolCalls?.length && step.toolResults?.length) {
140
+ for (let i = 0; i < step.toolResults.length; i++) {
141
+ const tr = step.toolResults[i];
142
+ const args = step.toolCalls[i]?.args;
143
+ logActivity(agentDir, {
144
+ type: 'tool_call',
145
+ tool: tr.toolName,
146
+ args,
147
+ result: typeof tr.result === 'string' ? tr.result.slice(0, 200) : 'ok',
148
+ });
149
+ }
150
+ } else if (step.toolResults?.length) {
151
+ for (const tr of step.toolResults) {
152
+ logActivity(agentDir, {
153
+ type: 'tool_call',
154
+ tool: tr.toolName,
155
+ result: typeof tr.result === 'string' ? tr.result.slice(0, 200) : 'ok',
156
+ });
157
+ }
158
+ }
159
+ }
160
+ } else if (result.toolCalls?.length) {
161
+ for (const tc of result.toolCalls) {
162
+ activity.toolCalls++;
163
+ if (tc.toolName === 'post_create') activity.postsCreated++;
164
+ if (tc.toolName === 'comment_create') activity.commentsCreated++;
165
+ if (tc.toolName === 'vote_cast') activity.votesGiven++;
166
+ logActivity(agentDir, {
167
+ type: 'tool_call',
168
+ tool: tc.toolName,
169
+ args: tc.args,
170
+ result: 'executed',
171
+ });
172
+ }
173
+ }
174
+
175
+ // Record actions for dedup in future iterations
176
+ const collectActions = (toolName, args) => {
177
+ if (toolName === 'comment_create' && args?.postId) {
178
+ recentActions.push({ type: 'comment', postId: args.postId, ts: new Date().toISOString() });
179
+ } else if (toolName === 'post_create' && args?.communitySlug) {
180
+ recentActions.push({ type: 'post', community: args.communitySlug, ts: new Date().toISOString() });
181
+ } else if (toolName === 'vote_cast' && args?.targetId) {
182
+ recentActions.push({ type: 'vote', targetId: args.targetId, targetType: args.targetType, ts: new Date().toISOString() });
183
+ }
184
+ };
185
+ if (result.steps?.length) {
186
+ for (const step of result.steps) {
187
+ for (const tc of step.toolCalls || []) {
188
+ collectActions(tc.toolName, tc.args);
189
+ }
190
+ }
191
+ } else if (result.toolCalls?.length) {
192
+ for (const tc of result.toolCalls) {
193
+ collectActions(tc.toolName, tc.args);
194
+ }
195
+ }
196
+ // Keep only last 50 actions to bound memory
197
+ if (recentActions.length > 50) recentActions.splice(0, recentActions.length - 50);
198
+
199
+ // Send server-side heartbeat
200
+ if (config.man?.agent_id) {
201
+ try {
202
+ await postAgentHeartbeat(config.man.agent_id, {
203
+ postsCreated: activity.postsCreated,
204
+ commentsCreated: activity.commentsCreated,
205
+ votesGiven: activity.votesGiven,
206
+ toolCalls: activity.toolCalls,
207
+ modelUsed: modelId,
208
+ tokensUsed: (result.usage?.promptTokens || 0) + (result.usage?.completionTokens || 0),
209
+ durationMs: Date.now() - loopStart,
210
+ activitySummary: { feedItemsRead, mentionsFound },
211
+ });
212
+ } catch {
213
+ logActivity(agentDir, { type: 'error', error: 'Failed to send server heartbeat' });
214
+ }
215
+ }
216
+ } catch (err) {
217
+ logActivity(agentDir, {
218
+ type: 'error',
219
+ error: err.message,
220
+ });
221
+ }
222
+
223
+ lastCheckIn = new Date().toISOString();
224
+ const duration = Date.now() - loopStart;
225
+
226
+ logActivity(agentDir, { type: 'loop_end', iteration, duration_ms: duration });
227
+ updateHeartbeat(agentDir);
228
+
229
+ // Sleep until next interval
230
+ if (intervalMs > 0 && !signal.aborted) {
231
+ await sleep(intervalMs, signal);
232
+ }
233
+ }
234
+
235
+ // Cleanup
236
+ await cleanupMCPClients();
237
+ }
238
+
239
+ // ── Helpers ─────────────────────────────────────────────
240
+
241
+ function logActivity(agentDir, entry) {
242
+ const logsDir = join(agentDir, 'logs');
243
+ if (!existsSync(logsDir)) {
244
+ mkdirSync(logsDir, { recursive: true });
245
+ }
246
+
247
+ const today = new Date().toISOString().slice(0, 10);
248
+ const logFile = join(logsDir, `${today}.jsonl`);
249
+
250
+ const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
251
+ appendFileSync(logFile, line);
252
+ }
253
+
254
+ function updateHeartbeat(agentDir) {
255
+ const pidFile = join(agentDir, 'daemon.pid');
256
+ if (!existsSync(pidFile)) return;
257
+
258
+ try {
259
+ const info = JSON.parse(readFileSync(pidFile, 'utf-8'));
260
+ info.lastHeartbeat = new Date().toISOString();
261
+ writeFileSync(pidFile, JSON.stringify(info));
262
+ } catch {
263
+ // PID file may be momentarily unreadable, skip
264
+ }
265
+ }
266
+
267
+ function sleep(ms, signal) {
268
+ return new Promise((resolve) => {
269
+ if (signal?.aborted) { resolve(); return; }
270
+
271
+ const timer = setTimeout(resolve, ms);
272
+
273
+ if (signal) {
274
+ const onAbort = () => {
275
+ clearTimeout(timer);
276
+ resolve();
277
+ };
278
+ signal.addEventListener('abort', onAbort, { once: true });
279
+ }
280
+ });
281
+ }
@@ -0,0 +1,93 @@
1
+ // ── MCP Client Manager ──────────────────────────────────
2
+ // Reads tools.yaml, spawns MCP servers, and exposes tools
3
+ // to the Vercel AI SDK for use in the agent loop.
4
+
5
+ import { readFileSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { parse as parseYaml } from 'yaml';
8
+
9
+ const activeClients = [];
10
+
11
+ // ── Public API ──────────────────────────────────────────
12
+
13
+ export async function getMCPTools(agentDir, agentConfig) {
14
+ const toolsPath = join(agentDir, 'tools.yaml');
15
+ let toolsConfig;
16
+ try {
17
+ toolsConfig = parseYaml(readFileSync(toolsPath, 'utf-8'));
18
+ } catch {
19
+ toolsConfig = { servers: {} };
20
+ }
21
+
22
+ const allTools = {};
23
+
24
+ for (const [name, server] of Object.entries(toolsConfig.servers || {})) {
25
+ if (server.command === 'internal') {
26
+ // Legacy internal tools — skip (replaced by MCP server)
27
+ continue;
28
+ }
29
+
30
+ try {
31
+ const { experimental_createMCPClient: createMCPClient } = await import('ai');
32
+ let client;
33
+
34
+ if (server.url) {
35
+ // Remote MCP server via Streamable HTTP transport
36
+ const headers = {};
37
+ if (process.env.MYVILLAGE_ACCESS_TOKEN) {
38
+ headers['Authorization'] = `Bearer ${process.env.MYVILLAGE_ACCESS_TOKEN}`;
39
+ }
40
+ if (process.env.MYVILLAGE_AGENT_ID) {
41
+ headers['X-Agent-Id'] = process.env.MYVILLAGE_AGENT_ID;
42
+ }
43
+ client = await createMCPClient({
44
+ transport: {
45
+ type: 'sse',
46
+ url: server.url,
47
+ headers,
48
+ },
49
+ });
50
+ } else {
51
+ // Local MCP servers via stdio transport
52
+ client = await createMCPClient({
53
+ transport: {
54
+ type: 'stdio',
55
+ command: server.command,
56
+ args: server.args || [],
57
+ env: { ...process.env, ...resolveEnvVars(server.env || {}) },
58
+ },
59
+ });
60
+ }
61
+
62
+ activeClients.push(client);
63
+ const tools = await client.tools();
64
+ Object.assign(allTools, tools);
65
+ } catch (err) {
66
+ // Log but don't fail — agent can still work with other tools
67
+ console.error(`[mcp-client] Failed to connect to ${name}: ${err.message}`);
68
+ }
69
+ }
70
+
71
+ return allTools;
72
+ }
73
+
74
+ export async function cleanupMCPClients() {
75
+ for (const client of activeClients) {
76
+ try { await client.close(); } catch { /* ignore */ }
77
+ }
78
+ activeClients.length = 0;
79
+ }
80
+
81
+ // ── Helpers ─────────────────────────────────────────────
82
+
83
+ function resolveEnvVars(envMap) {
84
+ const resolved = {};
85
+ for (const [key, val] of Object.entries(envMap)) {
86
+ if (typeof val === 'string') {
87
+ resolved[key] = val.replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] || '');
88
+ } else {
89
+ resolved[key] = val;
90
+ }
91
+ }
92
+ return resolved;
93
+ }
@@ -0,0 +1,53 @@
1
+ // ── Scheduler ───────────────────────────────────────────
2
+ // Active hours checking and interval timing for agent loops.
3
+
4
+ export function isWithinActiveHours(schedule) {
5
+ if (!schedule?.active_hours?.start || !schedule?.active_hours?.end) {
6
+ return true; // No active hours configured = always active
7
+ }
8
+
9
+ const now = new Date();
10
+ const tz = schedule.timezone || undefined;
11
+
12
+ // Get current hours/minutes in the configured timezone
13
+ let currentHours, currentMinutes;
14
+ if (tz) {
15
+ try {
16
+ const formatter = new Intl.DateTimeFormat('en-US', {
17
+ timeZone: tz,
18
+ hour: 'numeric',
19
+ minute: 'numeric',
20
+ hour12: false,
21
+ });
22
+ const parts = formatter.formatToParts(now);
23
+ currentHours = parseInt(parts.find(p => p.type === 'hour')?.value || '0');
24
+ currentMinutes = parseInt(parts.find(p => p.type === 'minute')?.value || '0');
25
+ } catch {
26
+ // Fallback to local time if timezone is invalid
27
+ currentHours = now.getHours();
28
+ currentMinutes = now.getMinutes();
29
+ }
30
+ } else {
31
+ currentHours = now.getHours();
32
+ currentMinutes = now.getMinutes();
33
+ }
34
+
35
+ const currentTime = currentHours * 60 + currentMinutes;
36
+
37
+ const [startH, startM] = schedule.active_hours.start.split(':').map(Number);
38
+ const [endH, endM] = schedule.active_hours.end.split(':').map(Number);
39
+ const startTime = startH * 60 + (startM || 0);
40
+ const endTime = endH * 60 + (endM || 0);
41
+
42
+ if (startTime <= endTime) {
43
+ return currentTime >= startTime && currentTime <= endTime;
44
+ }
45
+ // Handles overnight ranges (e.g., 22:00 - 06:00)
46
+ return currentTime >= startTime || currentTime <= endTime;
47
+ }
48
+
49
+ export function getNextCheckInMs(schedule) {
50
+ const interval = schedule?.check_in_interval;
51
+ if (!interval || interval <= 0) return 0; // manual only
52
+ return interval * 60 * 1000;
53
+ }