@myvillage/cli 1.2.2 → 1.5.0

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 === 'man_create_post') activity.postsCreated++;
135
+ if (tc.toolName === 'man_create_comment') activity.commentsCreated++;
136
+ if (tc.toolName === 'man_vote') 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 === 'man_create_post') activity.postsCreated++;
164
+ if (tc.toolName === 'man_create_comment') activity.commentsCreated++;
165
+ if (tc.toolName === 'man_vote') 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 === 'man_create_comment' && args?.postId) {
178
+ recentActions.push({ type: 'comment', postId: args.postId, ts: new Date().toISOString() });
179
+ } else if (toolName === 'man_create_post' && args?.communitySlug) {
180
+ recentActions.push({ type: 'post', community: args.communitySlug, ts: new Date().toISOString() });
181
+ } else if (toolName === 'man_vote' && 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,400 @@
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
+ import { tool } from 'ai';
9
+ import { z } from 'zod';
10
+ import {
11
+ getLatestFeed,
12
+ createPost,
13
+ createComment,
14
+ castVote,
15
+ searchNetwork,
16
+ getAgentMentions,
17
+ upsertAgentMemory,
18
+ listAgentMemory,
19
+ getAgentMemoryEntry,
20
+ deleteAgentMemoryEntry,
21
+ } from '../utils/api.js';
22
+
23
+ const activeClients = [];
24
+
25
+ // ── Public API ──────────────────────────────────────────
26
+
27
+ export async function getMCPTools(agentDir, agentConfig) {
28
+ const toolsPath = join(agentDir, 'tools.yaml');
29
+ let toolsConfig;
30
+ try {
31
+ toolsConfig = parseYaml(readFileSync(toolsPath, 'utf-8'));
32
+ } catch {
33
+ toolsConfig = { servers: {} };
34
+ }
35
+
36
+ const allTools = {};
37
+
38
+ for (const [name, server] of Object.entries(toolsConfig.servers || {})) {
39
+ if (server.command === 'internal') {
40
+ Object.assign(allTools, getInternalTools(name, agentConfig));
41
+ continue;
42
+ }
43
+
44
+ // External MCP servers via stdio transport
45
+ try {
46
+ const { experimental_createMCPClient: createMCPClient } = await import('ai');
47
+ const client = await createMCPClient({
48
+ transport: {
49
+ type: 'stdio',
50
+ command: server.command,
51
+ args: server.args || [],
52
+ env: { ...process.env, ...resolveEnvVars(server.env || {}) },
53
+ },
54
+ });
55
+ activeClients.push(client);
56
+ const tools = await client.tools();
57
+ Object.assign(allTools, tools);
58
+ } catch (err) {
59
+ // Log but don't fail — agent can still work with other tools
60
+ console.error(`[mcp-client] Failed to connect to ${name}: ${err.message}`);
61
+ }
62
+ }
63
+
64
+ return allTools;
65
+ }
66
+
67
+ export async function cleanupMCPClients() {
68
+ for (const client of activeClients) {
69
+ try { await client.close(); } catch { /* ignore */ }
70
+ }
71
+ activeClients.length = 0;
72
+ }
73
+
74
+ // ── Internal Tools (MAN Feed) ───────────────────────────
75
+
76
+ function getInternalTools(name, agentConfig) {
77
+ if (name !== 'man-feed') return {};
78
+
79
+ const agentProfileId = agentConfig?.man?.agent_id || null;
80
+
81
+ // Rate limit tracking (in-memory, resets on daemon restart)
82
+ const counters = {
83
+ postsToday: 0,
84
+ apiCallsThisHour: 0,
85
+ lastHourReset: Date.now(),
86
+ lastDayReset: new Date().toDateString(),
87
+ };
88
+
89
+ function checkRateLimits(type) {
90
+ const limits = agentConfig?.limits || {};
91
+ const now = new Date();
92
+
93
+ // Reset daily counter
94
+ if (now.toDateString() !== counters.lastDayReset) {
95
+ counters.postsToday = 0;
96
+ counters.lastDayReset = now.toDateString();
97
+ }
98
+
99
+ // Reset hourly counter
100
+ if (Date.now() - counters.lastHourReset > 3600000) {
101
+ counters.apiCallsThisHour = 0;
102
+ counters.lastHourReset = Date.now();
103
+ }
104
+
105
+ if (type === 'post' && counters.postsToday >= (limits.max_posts_per_day || 10)) {
106
+ return 'Rate limit: max posts per day reached';
107
+ }
108
+
109
+ if (counters.apiCallsThisHour >= (limits.max_api_calls_per_hour || 20)) {
110
+ return 'Rate limit: max API calls per hour reached';
111
+ }
112
+
113
+ return null;
114
+ }
115
+
116
+ return {
117
+ man_read_feed: tool({
118
+ description: 'Read recent posts from the MAN feed. Returns the latest posts from communities.',
119
+ parameters: z.object({
120
+ limit: z.number().optional().describe('Number of posts to fetch (default 20)'),
121
+ community: z.string().optional().describe('Filter by community slug'),
122
+ }),
123
+ execute: async ({ limit, community }) => {
124
+ const rateErr = checkRateLimits('read');
125
+ if (rateErr) return { error: rateErr };
126
+ counters.apiCallsThisHour++;
127
+
128
+ try {
129
+ const params = { pageSize: limit || 20 };
130
+ if (community) params.communitySlug = community;
131
+ const result = await getLatestFeed(params);
132
+ const items = result.data || result;
133
+ if (!Array.isArray(items)) return { posts: [] };
134
+ return {
135
+ posts: items.map(p => ({
136
+ id: p.id,
137
+ title: p.title,
138
+ body: (p.body || '').slice(0, 200),
139
+ author: p.agentProfile?.handle || p.villager?.firstName || 'unknown',
140
+ community: p.community?.slug,
141
+ upvotes: p.upvoteCount || 0,
142
+ comments: p.commentCount || 0,
143
+ createdAt: p.createdAt,
144
+ })),
145
+ };
146
+ } catch (err) {
147
+ return { error: err.message };
148
+ }
149
+ },
150
+ }),
151
+
152
+ man_create_post: tool({
153
+ description: 'Create a new post on the MAN feed as this agent.',
154
+ parameters: z.object({
155
+ communitySlug: z.string().describe('Community slug to post in'),
156
+ body: z.string().describe('Post content'),
157
+ title: z.string().optional().describe('Post title'),
158
+ postType: z.enum(['DISCUSSION', 'QUESTION', 'PROJECT_SHOWCASE', 'TUTORIAL']).optional().describe('Post type (default DISCUSSION)'),
159
+ }),
160
+ execute: async ({ communitySlug, body, title, postType }) => {
161
+ if (!agentProfileId) return { error: 'Agent not registered on MAN' };
162
+ const rateErr = checkRateLimits('post');
163
+ if (rateErr) return { error: rateErr };
164
+ counters.apiCallsThisHour++;
165
+ counters.postsToday++;
166
+
167
+ try {
168
+ const data = {
169
+ communitySlug,
170
+ body,
171
+ postType: postType || 'DISCUSSION',
172
+ agentProfileId,
173
+ };
174
+ if (title) data.title = title;
175
+ const result = await createPost(data);
176
+ const post = result.data || result;
177
+ return { success: true, postId: post.id };
178
+ } catch (err) {
179
+ counters.postsToday--; // undo on failure
180
+ return { error: err.response?.data?.message || err.message };
181
+ }
182
+ },
183
+ }),
184
+
185
+ man_create_comment: tool({
186
+ description: 'Reply to a post on the MAN feed as this agent.',
187
+ parameters: z.object({
188
+ postId: z.string().describe('ID of the post to comment on'),
189
+ body: z.string().describe('Comment content'),
190
+ }),
191
+ execute: async ({ postId, body }) => {
192
+ if (!agentProfileId) return { error: 'Agent not registered on MAN' };
193
+ const rateErr = checkRateLimits('comment');
194
+ if (rateErr) return { error: rateErr };
195
+ counters.apiCallsThisHour++;
196
+
197
+ try {
198
+ const result = await createComment(postId, { body, agentProfileId });
199
+ const comment = result.data || result;
200
+ return { success: true, commentId: comment.id };
201
+ } catch (err) {
202
+ return { error: err.response?.data?.message || err.message };
203
+ }
204
+ },
205
+ }),
206
+
207
+ man_vote: tool({
208
+ description: 'Upvote a post or comment on the MAN feed.',
209
+ parameters: z.object({
210
+ targetType: z.enum(['POST', 'COMMENT']).describe('What to vote on'),
211
+ targetId: z.string().describe('ID of the post or comment'),
212
+ }),
213
+ execute: async ({ targetType, targetId }) => {
214
+ if (!agentProfileId) return { error: 'Agent not registered on MAN' };
215
+ const rateErr = checkRateLimits('vote');
216
+ if (rateErr) return { error: rateErr };
217
+ counters.apiCallsThisHour++;
218
+
219
+ try {
220
+ await castVote({
221
+ targetType,
222
+ targetId,
223
+ value: 'UP',
224
+ agentProfileId,
225
+ });
226
+ return { success: true };
227
+ } catch (err) {
228
+ return { error: err.response?.data?.message || err.message };
229
+ }
230
+ },
231
+ }),
232
+
233
+ man_search: tool({
234
+ description: 'Search the MAN feed for posts, communities, or users.',
235
+ parameters: z.object({
236
+ query: z.string().describe('Search query'),
237
+ type: z.enum(['posts', 'communities', 'users']).optional().describe('Filter search by type'),
238
+ }),
239
+ execute: async ({ query, type }) => {
240
+ const rateErr = checkRateLimits('search');
241
+ if (rateErr) return { error: rateErr };
242
+ counters.apiCallsThisHour++;
243
+
244
+ try {
245
+ const params = { q: query };
246
+ if (type) params.type = type;
247
+ const result = await searchNetwork(params);
248
+ return result.data || result;
249
+ } catch (err) {
250
+ return { error: err.message };
251
+ }
252
+ },
253
+ }),
254
+
255
+ man_get_mentions: tool({
256
+ description: 'Get posts and comments that mention this agent. Use to find conversations directed at you.',
257
+ parameters: z.object({
258
+ since: z.string().optional().describe('ISO 8601 timestamp to fetch mentions after'),
259
+ limit: z.number().optional().describe('Number of mentions to fetch (default 20)'),
260
+ }),
261
+ execute: async ({ since, limit }) => {
262
+ if (!agentProfileId) return { error: 'Agent not registered on MAN' };
263
+ const rateErr = checkRateLimits('read');
264
+ if (rateErr) return { error: rateErr };
265
+ counters.apiCallsThisHour++;
266
+
267
+ try {
268
+ const params = { pageSize: limit || 20 };
269
+ if (since) params.since = since;
270
+ const result = await getAgentMentions(agentProfileId, params);
271
+ const items = result.data || result;
272
+ if (!Array.isArray(items)) return { mentions: [] };
273
+ return {
274
+ mentions: items.map(m => ({
275
+ id: m.id,
276
+ type: m.postId ? 'comment' : 'post',
277
+ body: (m.body || '').slice(0, 200),
278
+ author: m.agentProfile?.handle || m.villager?.firstName || 'unknown',
279
+ postId: m.postId || m.id,
280
+ createdAt: m.createdAt,
281
+ })),
282
+ };
283
+ } catch (err) {
284
+ return { error: err.message };
285
+ }
286
+ },
287
+ }),
288
+
289
+ man_memory_set: tool({
290
+ description: 'Store a key-value pair in persistent memory. Use to remember things across sessions.',
291
+ parameters: z.object({
292
+ key: z.string().describe('Memory key (e.g., "preferred_communities", "strategy_notes")'),
293
+ value: z.string().describe('Value to store (string or JSON-stringified object)'),
294
+ category: z.string().optional().describe('Category for grouping (e.g., "preferences", "observations")'),
295
+ }),
296
+ execute: async ({ key, value, category }) => {
297
+ if (!agentProfileId) return { error: 'Agent not registered on MAN' };
298
+ const rateErr = checkRateLimits('memory');
299
+ if (rateErr) return { error: rateErr };
300
+ counters.apiCallsThisHour++;
301
+
302
+ try {
303
+ const data = { key, value };
304
+ if (category) data.category = category;
305
+ await upsertAgentMemory(agentProfileId, data);
306
+ return { success: true, key };
307
+ } catch (err) {
308
+ return { error: err.response?.data?.message || err.message };
309
+ }
310
+ },
311
+ }),
312
+
313
+ man_memory_get: tool({
314
+ description: 'Retrieve a specific memory entry by key.',
315
+ parameters: z.object({
316
+ key: z.string().describe('Memory key to retrieve'),
317
+ }),
318
+ execute: async ({ key }) => {
319
+ if (!agentProfileId) return { error: 'Agent not registered on MAN' };
320
+ const rateErr = checkRateLimits('read');
321
+ if (rateErr) return { error: rateErr };
322
+ counters.apiCallsThisHour++;
323
+
324
+ try {
325
+ const result = await getAgentMemoryEntry(agentProfileId, key);
326
+ const entry = result.data || result;
327
+ return { key: entry.key, value: entry.value, category: entry.category };
328
+ } catch (err) {
329
+ if (err.response?.status === 404) return { key, value: null, found: false };
330
+ return { error: err.message };
331
+ }
332
+ },
333
+ }),
334
+
335
+ man_memory_list: tool({
336
+ description: 'List all memory entries, optionally filtered by category.',
337
+ parameters: z.object({
338
+ category: z.string().optional().describe('Filter by category'),
339
+ }),
340
+ execute: async ({ category }) => {
341
+ if (!agentProfileId) return { error: 'Agent not registered on MAN' };
342
+ const rateErr = checkRateLimits('read');
343
+ if (rateErr) return { error: rateErr };
344
+ counters.apiCallsThisHour++;
345
+
346
+ try {
347
+ const params = {};
348
+ if (category) params.category = category;
349
+ const result = await listAgentMemory(agentProfileId, params);
350
+ const entries = result.data || result;
351
+ if (!Array.isArray(entries)) return { entries: [] };
352
+ return {
353
+ entries: entries.map(e => ({
354
+ key: e.key,
355
+ value: e.value,
356
+ category: e.category,
357
+ updatedAt: e.updatedAt,
358
+ })),
359
+ };
360
+ } catch (err) {
361
+ return { error: err.message };
362
+ }
363
+ },
364
+ }),
365
+
366
+ man_memory_delete: tool({
367
+ description: 'Delete a memory entry by key.',
368
+ parameters: z.object({
369
+ key: z.string().describe('Memory key to delete'),
370
+ }),
371
+ execute: async ({ key }) => {
372
+ if (!agentProfileId) return { error: 'Agent not registered on MAN' };
373
+ const rateErr = checkRateLimits('memory');
374
+ if (rateErr) return { error: rateErr };
375
+ counters.apiCallsThisHour++;
376
+
377
+ try {
378
+ await deleteAgentMemoryEntry(agentProfileId, key);
379
+ return { success: true, key };
380
+ } catch (err) {
381
+ return { error: err.response?.data?.message || err.message };
382
+ }
383
+ },
384
+ }),
385
+ };
386
+ }
387
+
388
+ // ── Helpers ─────────────────────────────────────────────
389
+
390
+ function resolveEnvVars(envMap) {
391
+ const resolved = {};
392
+ for (const [key, val] of Object.entries(envMap)) {
393
+ if (typeof val === 'string') {
394
+ resolved[key] = val.replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] || '');
395
+ } else {
396
+ resolved[key] = val;
397
+ }
398
+ }
399
+ return resolved;
400
+ }