@myvillage/cli 2.14.0 → 2.16.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.
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "2.14.0",
3
+ "version": "2.16.0",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "myvillage": "./bin/myvillage.js"
8
8
  },
9
+ "scripts": {
10
+ "test": "node --test test/*.test.js"
11
+ },
9
12
  "engines": {
10
13
  "node": ">=22.0.0"
11
14
  },
@@ -14,13 +14,26 @@
14
14
  import { listDatasets } from '../utils/dataset-workspace.js';
15
15
  import { listModels } from '../utils/model-workspace.js';
16
16
  import { readAgentModels, describeAgentModel } from '../utils/agent-models.js';
17
+ import { fetchAgentMemories, formatMemorySection, readEnvSecrets } from './memory-context.js';
17
18
 
18
- export async function gatherContext(agentConfig, lastCheckIn, recentActions = [], agentName = agentConfig?.name) {
19
+ // `opts.agentDir` lets saved notes be checked against the agent's .env so a
20
+ // secret never reaches the context; `opts.listMemory` swaps the memory fetch
21
+ // (tests).
22
+ export async function gatherContext(agentConfig, lastCheckIn, recentActions = [], agentName = agentConfig?.name, opts = {}) {
19
23
  const parts = [];
20
24
 
21
25
  parts.push(`Current time: ${new Date().toISOString()}`);
22
26
  parts.push(`Last check-in: ${lastCheckIn || 'never'}`);
23
27
 
28
+ // What the agent saved in earlier runs. Read by the runtime every loop, not
29
+ // left to the model to look up. Empty (and silent) if it can't be fetched.
30
+ const memories = await fetchAgentMemories(
31
+ agentConfig?.man?.agent_id,
32
+ opts.listMemory ? { list: opts.listMemory } : undefined,
33
+ );
34
+ const memorySection = formatMemorySection(memories, { secrets: readEnvSecrets(opts.agentDir) });
35
+ if (memorySection) parts.push(memorySection);
36
+
24
37
  // Include recent actions so the agent knows what it already did
25
38
  if (recentActions.length > 0) {
26
39
  parts.push('\nYour recent actions:');
@@ -76,5 +89,5 @@ export async function gatherContext(agentConfig, lastCheckIn, recentActions = []
76
89
  parts.push('You may post, comment, vote, read files, run your tools, or do nothing if appropriate.');
77
90
  parts.push('Be thoughtful — quality over quantity. Avoid repeating yourself.');
78
91
 
79
- return { text: parts.join('\n') };
92
+ return { text: parts.join('\n'), memoryCount: memories.length };
80
93
  }
@@ -12,6 +12,7 @@ import { getMCPTools, cleanupMCPClients } from './mcp-client.js';
12
12
  import { getBuiltinTools } from './builtin-tools.js';
13
13
  import { readAgentModels } from '../utils/agent-models.js';
14
14
  import { gatherContext } from './context.js';
15
+ import { RUN_NOTES_INSTRUCTION } from './memory-context.js';
15
16
  import { isWithinActiveHours, getNextCheckInMs } from './scheduler.js';
16
17
  import { parse as parseYaml } from 'yaml';
17
18
  import { postAgentHeartbeat, listAgentTasks, claimAgentTask, completeAgentTask, refreshAccessToken } from '../utils/api.js';
@@ -308,6 +309,10 @@ export async function agentLoop(agentName, { signal }) {
308
309
  logActivity(agentDir, { type: 'wisdom_loaded', count: wisdom.length, names: wisdom.map(w => w.name) });
309
310
  }
310
311
 
312
+ // Ask the agent to leave notes for its next run. Only when it can
313
+ // actually save them; the notes come back in the context every loop.
314
+ if (tools.agent_memory_set) systemPrompt = `${systemPrompt}\n\n${RUN_NOTES_INSTRUCTION}`;
315
+
311
316
  // Try to pull an assigned task first. Tasks take priority over ambient
312
317
  // feed-monitoring. If nothing is queued, fall through to the default.
313
318
  if (resuming) {
@@ -320,7 +325,7 @@ export async function agentLoop(agentName, { signal }) {
320
325
  // no longer auto-fetched here — the agent pulls them on demand via its
321
326
  // tools — so these activity counters stay at 0 unless a future signal
322
327
  // repopulates them.
323
- const contextResult = await gatherContext(config, lastCheckIn, recentActions, agentName);
328
+ const contextResult = await gatherContext(config, lastCheckIn, recentActions, agentName, { agentDir });
324
329
  let context = contextResult.text;
325
330
  const eventsFound = 0;
326
331
 
@@ -360,6 +365,7 @@ Guidelines:
360
365
  feedItems: feedItemsRead,
361
366
  mentions: mentionsFound,
362
367
  events: eventsFound,
368
+ memories: contextResult.memoryCount,
363
369
  });
364
370
 
365
371
  const conversation = resuming
@@ -167,7 +167,10 @@ export async function getMCPTools(agentDir, agentConfig) {
167
167
  // withhold everything else this server offers (e.g. grant `network_feed`
168
168
  // but not `wallet_send`). Absent/empty → all tools, the backward-compatible
169
169
  // default.
170
- const tools = applyToolAllowlist(await client.tools(), server.allowed_tools, name);
170
+ const tools = withSecretPlaceholders(
171
+ applyToolAllowlist(await client.tools(), server.allowed_tools, name),
172
+ server.secrets,
173
+ );
171
174
  const toolNames = Object.keys(tools);
172
175
  Object.assign(allTools, tools);
173
176
  servers.push({
@@ -216,6 +219,76 @@ export function applyToolAllowlist(tools, allowed, serverName) {
216
219
  return filtered;
217
220
  }
218
221
 
222
+ /**
223
+ * Let an agent use a secret without ever seeing it.
224
+ *
225
+ * `secrets` is a server's list of `.env` names from tools.yaml (e.g.
226
+ * `[SCHOOL_USERNAME, SCHOOL_PASSWORD]`). The agent writes `${SCHOOL_PASSWORD}`
227
+ * in a tool call, say as the value for `puppeteer_fill`, and the real value is
228
+ * swapped in right before the tool runs. Anything the tool sends back has the
229
+ * value turned back into its placeholder, so the secret never reaches the
230
+ * model, its context, or the activity log.
231
+ *
232
+ * Only the names listed are swapped, per server, so an agent cannot ask for
233
+ * `${MYVILLAGE_ACCESS_TOKEN}` or any other variable it wasn't given. Unknown or
234
+ * unset names are left as written.
235
+ */
236
+ export function withSecretPlaceholders(tools, secretNames, env = process.env) {
237
+ if (!Array.isArray(secretNames) || secretNames.length === 0) return tools;
238
+ const allowed = new Set(secretNames.filter((n) => typeof n === 'string' && /^\w+$/.test(n)));
239
+ if (allowed.size === 0) return tools;
240
+
241
+ const fill = (v) => {
242
+ if (typeof v === 'string') {
243
+ return v.replace(/\$\{(\w+)\}/g, (m, n) => (allowed.has(n) && env[n] ? env[n] : m));
244
+ }
245
+ if (Array.isArray(v)) return v.map(fill);
246
+ if (v && typeof v === 'object') {
247
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, fill(x)]));
248
+ }
249
+ return v;
250
+ };
251
+
252
+ // Longest first, so a value that contains another is replaced whole. Very
253
+ // short values are skipped: blanking every "a" in a page would wreck it.
254
+ const values = [...allowed]
255
+ .map((n) => [n, env[n]])
256
+ .filter(([, v]) => typeof v === 'string' && v.length >= 3)
257
+ .sort((a, b) => b[1].length - a[1].length);
258
+ const redact = (v) => {
259
+ if (typeof v === 'string') {
260
+ let out = v;
261
+ for (const [n, secret] of values) out = out.split(secret).join(`\${${n}}`);
262
+ return out;
263
+ }
264
+ if (Array.isArray(v)) return v.map(redact);
265
+ if (v && typeof v === 'object') {
266
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, redact(x)]));
267
+ }
268
+ return v;
269
+ };
270
+
271
+ const wrapped = {};
272
+ for (const [name, tool] of Object.entries(tools)) {
273
+ if (typeof tool?.execute !== 'function') {
274
+ wrapped[name] = tool;
275
+ continue;
276
+ }
277
+ wrapped[name] = {
278
+ ...tool,
279
+ execute: async (args, options) => {
280
+ try {
281
+ return redact(await tool.execute(fill(args), options));
282
+ } catch (err) {
283
+ if (err && typeof err.message === 'string') err.message = redact(err.message);
284
+ throw err;
285
+ }
286
+ },
287
+ };
288
+ }
289
+ return wrapped;
290
+ }
291
+
219
292
  export async function cleanupMCPClients() {
220
293
  for (const client of activeClients) {
221
294
  try { await client.close(); } catch { /* ignore */ }
@@ -0,0 +1,136 @@
1
+ // ── Memory Context ──────────────────────────────────────
2
+ // Every loop starts a fresh conversation, so an agent forgets what it worked
3
+ // out last run (a login selector that worked, an id it looked up) unless the
4
+ // model happens to call agent_memory_list. Relying on that choice failed in
5
+ // practice: an agent found the right selectors in run 1, never re-read them,
6
+ // and spent run 2 guessing again.
7
+ //
8
+ // So the runtime reads the agent's saved memories itself, at the start of
9
+ // every loop, and puts a compact "what you remember" section into the context.
10
+ // It reads the same store the hosted agent_memory_* MCP tools use
11
+ // (GET /api/network/agents/{agentProfileId}/memory), straight from the
12
+ // platform API, so it works whether or not the agent was granted those tools.
13
+ //
14
+ // Notes saved under RUN_NOTES_CATEGORY are shown first: that is the category
15
+ // the agent is asked to use for "things my next run should know".
16
+
17
+ import { readFileSync, existsSync } from 'fs';
18
+ import { join } from 'path';
19
+ import { listAgentMemory } from '../utils/api.js';
20
+
21
+ export const RUN_NOTES_CATEGORY = 'run_notes';
22
+
23
+ const DEFAULT_MAX_ENTRIES = 30;
24
+ const DEFAULT_MAX_CHARS = 4000;
25
+ const MAX_VALUE_CHARS = 500;
26
+ const FETCH_TIMEOUT_MS = 8000;
27
+
28
+ // Shown in the system prompt when the agent can call agent_memory_set. Kept
29
+ // constant so it doesn't break the cached system-prompt prefix.
30
+ export const RUN_NOTES_INSTRUCTION =
31
+ '## Notes for your next run\n' +
32
+ 'Each run starts fresh: the only things you carry over are the notes you save. ' +
33
+ 'Before you finish, if you worked something out that would save time next run (a selector, button or URL that worked, ' +
34
+ 'an id you looked up, a step that failed and why), save it with agent_memory_set using ' +
35
+ `category "${RUN_NOTES_CATEGORY}" and a short descriptive key (e.g. "login_selectors"). ` +
36
+ 'Update the same key rather than adding near-duplicates. ' +
37
+ 'Never save a password or secret value; write its ${NAME} placeholder instead.';
38
+
39
+ /**
40
+ * Fetch this agent's saved memories, newest first. Never throws: any failure
41
+ * (no agent id yet, offline, signed out, slow server) returns [] so the loop
42
+ * runs exactly as it did before.
43
+ */
44
+ export async function fetchAgentMemories(agentId, { list = listAgentMemory, timeoutMs = FETCH_TIMEOUT_MS } = {}) {
45
+ if (!agentId) return [];
46
+ let timer;
47
+ try {
48
+ const res = await Promise.race([
49
+ list(agentId),
50
+ new Promise((resolve) => { timer = setTimeout(() => resolve(null), timeoutMs); }),
51
+ ]);
52
+ const entries = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : [];
53
+ return entries.filter((e) => e && typeof e.key === 'string');
54
+ } catch {
55
+ return [];
56
+ } finally {
57
+ clearTimeout(timer);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Values from the agent's .env, so a secret the agent mistakenly saved to
63
+ * memory is shown back as its ${NAME} placeholder instead of the real value.
64
+ */
65
+ export function readEnvSecrets(agentDir) {
66
+ const out = [];
67
+ const path = agentDir && join(agentDir, '.env');
68
+ if (!path || !existsSync(path)) return out;
69
+ let text;
70
+ try { text = readFileSync(path, 'utf-8'); } catch { return out; }
71
+ for (const raw of text.split('\n')) {
72
+ const line = raw.trim();
73
+ if (!line || line.startsWith('#')) continue;
74
+ const eq = line.indexOf('=');
75
+ if (eq <= 0) continue;
76
+ const name = line.slice(0, eq).trim().replace(/^export\s+/, '');
77
+ let value = line.slice(eq + 1).trim();
78
+ if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.endsWith(value[0])) {
79
+ value = value.slice(1, -1);
80
+ }
81
+ if (/^\w+$/.test(name) && value.length >= 3) out.push([name, value]);
82
+ }
83
+ // Longest first, so a value that contains another is replaced whole.
84
+ return out.sort((a, b) => b[1].length - a[1].length);
85
+ }
86
+
87
+ function redact(text, secrets) {
88
+ let out = text;
89
+ for (const [name, value] of secrets) out = out.split(value).join(`\${${name}}`);
90
+ return out;
91
+ }
92
+
93
+ function oneLine(value) {
94
+ const s = (typeof value === 'string' ? value : JSON.stringify(value) ?? '').replace(/\s+/g, ' ').trim();
95
+ return s.length > MAX_VALUE_CHARS ? `${s.slice(0, MAX_VALUE_CHARS)}…` : s;
96
+ }
97
+
98
+ function updatedTime(e) {
99
+ const t = Date.parse(e.updatedAt || e.createdAt || '');
100
+ return Number.isNaN(t) ? 0 : t;
101
+ }
102
+
103
+ /**
104
+ * Render memories as a compact context section, or '' when there are none.
105
+ * Run notes come first, then everything else; newest first within each, capped
106
+ * at `maxEntries` lines and roughly `maxChars` characters.
107
+ */
108
+ export function formatMemorySection(
109
+ entries,
110
+ { maxEntries = DEFAULT_MAX_ENTRIES, maxChars = DEFAULT_MAX_CHARS, secrets = [] } = {},
111
+ ) {
112
+ if (!Array.isArray(entries) || entries.length === 0) return '';
113
+ const byNewest = [...entries].sort((a, b) => updatedTime(b) - updatedTime(a));
114
+ const ordered = [
115
+ ...byNewest.filter((e) => e.category === RUN_NOTES_CATEGORY),
116
+ ...byNewest.filter((e) => e.category !== RUN_NOTES_CATEGORY),
117
+ ];
118
+
119
+ const header = [
120
+ '\nWhat you remember from earlier runs (your own saved notes, newest first; use them instead of working things out again):',
121
+ ];
122
+ const lines = [];
123
+ let used = header[0].length;
124
+ for (const e of ordered) {
125
+ if (lines.length >= maxEntries) break;
126
+ const date = e.updatedAt ? ` (saved ${String(e.updatedAt).slice(0, 10)})` : '';
127
+ const cat = e.category && e.category !== RUN_NOTES_CATEGORY ? ` [${e.category}]` : '';
128
+ const line = redact(`- ${e.key}${cat}: ${oneLine(e.value)}${date}`, secrets);
129
+ if (lines.length > 0 && used + line.length + 1 > maxChars) break;
130
+ lines.push(line);
131
+ used += line.length + 1;
132
+ }
133
+ const hidden = ordered.length - lines.length;
134
+ if (hidden > 0) lines.push(`(${hidden} older note${hidden === 1 ? '' : 's'} not shown; agent_memory_list has them all.)`);
135
+ return [...header, ...lines].join('\n');
136
+ }