@myvillage/cli 2.15.0 → 2.16.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.
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "2.15.0",
3
+ "version": "2.16.1",
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
@@ -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
+ }
@@ -14,7 +14,7 @@
14
14
  * datasets/models never pulls the heavy runtime in.
15
15
  */
16
16
  import { readFileSync, existsSync } from 'fs';
17
- import { join, dirname, extname } from 'path';
17
+ import { join, dirname, extname, basename } from 'path';
18
18
  import { fileURLToPath } from 'url';
19
19
  import { createRequire } from 'module';
20
20
 
@@ -27,6 +27,16 @@ function requirePkg(name) {
27
27
  export const BACKBONE_ID = 'mobilenet-v3-small';
28
28
  export const INPUT_SIZE = 224;
29
29
  export const EMBED_DIM = 1024;
30
+ /**
31
+ * Pictures up to this size are embedded exactly as lab-runtime embeds them
32
+ * (its MAX_MEGAPIXELS), so a shared model reads them the same way. Anything
33
+ * bigger, like a 48 MP phone photo, is box-shrunk to fit first: it keeps the
34
+ * float copy small and averages the detail the 224×224 resize would skip.
35
+ */
36
+ const MAX_DIRECT_PIXELS = 16 * 1000 * 1000;
37
+ /** jpeg-js's working memory cap; 512 MB stops short of 48 MP phone photos.
38
+ * Its 100 MP resolution cap still applies. */
39
+ const JPEG_MEMORY_MB = 2048;
30
40
 
31
41
  const BACKBONE_DIR = join(
32
42
  dirname(fileURLToPath(import.meta.url)),
@@ -99,18 +109,66 @@ function decodeImage(path) {
99
109
  if (!existsSync(path)) throw new Error(`Image not found: ${path}`);
100
110
  const buf = readFileSync(path);
101
111
  const ext = extname(path).toLowerCase();
102
- if (ext === '.png') {
103
- // Lazy require keeps these decoders out of the hot path for text/tabular.
104
- const { PNG } = requirePkg('pngjs');
105
- const png = PNG.sync.read(buf);
106
- return { width: png.width, height: png.height, data: png.data };
112
+ if (ext !== '.png' && ext !== '.jpg' && ext !== '.jpeg') {
113
+ throw new Error(`Unsupported image type "${ext}". Use .jpg, .jpeg, or .png.`);
107
114
  }
108
- if (ext === '.jpg' || ext === '.jpeg') {
115
+ try {
116
+ if (ext === '.png') {
117
+ // Lazy require keeps these decoders out of the hot path for text/tabular.
118
+ const { PNG } = requirePkg('pngjs');
119
+ const png = PNG.sync.read(buf);
120
+ return { width: png.width, height: png.height, data: png.data };
121
+ }
109
122
  const jpeg = requirePkg('jpeg-js');
110
- const img = jpeg.decode(buf, { useTArray: true, maxMemoryUsageInMB: 512 });
123
+ const img = jpeg.decode(buf, { useTArray: true, maxMemoryUsageInMB: JPEG_MEMORY_MB });
111
124
  return { width: img.width, height: img.height, data: img.data };
125
+ } catch (e) {
126
+ const name = basename(path);
127
+ if (/maxResolutionInMP|maxMemoryUsageInMB/.test(e?.message || '')) {
128
+ throw new Error(`The picture "${name}" is too big to read. Make it smaller (under 100 megapixels) and add it again.`);
129
+ }
130
+ throw new Error(`The picture "${name}" couldn't be read. Try a different JPEG or PNG.`);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * RGBA bytes → RGB floats in [0,1]. Pictures over MAX_DIRECT_PIXELS are
136
+ * averaged down by the smallest whole factor that fits (any leftover edge
137
+ * pixels are dropped); smaller ones are copied pixel for pixel.
138
+ */
139
+ function toRgb({ width, height, data }) {
140
+ let k = 1;
141
+ while (Math.floor(width / k) * Math.floor(height / k) > MAX_DIRECT_PIXELS) k++;
142
+ const w = Math.floor(width / k);
143
+ const h = Math.floor(height / k);
144
+ const rgb = new Float32Array(w * h * 3);
145
+ if (k === 1) {
146
+ for (let i = 0, j = 0; i < data.length; i += 4, j += 3) {
147
+ rgb[j] = data[i] / 255;
148
+ rgb[j + 1] = data[i + 1] / 255;
149
+ rgb[j + 2] = data[i + 2] / 255;
150
+ }
151
+ return { rgb, w, h };
152
+ }
153
+ const scale = 1 / (k * k * 255);
154
+ for (let y = 0; y < h; y++) {
155
+ for (let x = 0; x < w; x++) {
156
+ let r = 0, g = 0, b = 0;
157
+ for (let dy = 0; dy < k; dy++) {
158
+ let i = ((y * k + dy) * width + x * k) * 4;
159
+ for (let dx = 0; dx < k; dx++, i += 4) {
160
+ r += data[i];
161
+ g += data[i + 1];
162
+ b += data[i + 2];
163
+ }
164
+ }
165
+ const j = (y * w + x) * 3;
166
+ rgb[j] = r * scale;
167
+ rgb[j + 1] = g * scale;
168
+ rgb[j + 2] = b * scale;
169
+ }
112
170
  }
113
- throw new Error(`Unsupported image type "${ext}". Use .jpg, .jpeg, or .png.`);
171
+ return { rgb, w, h };
114
172
  }
115
173
 
116
174
  /**
@@ -120,17 +178,11 @@ function decodeImage(path) {
120
178
  export async function embedImage(path) {
121
179
  const tf = await import('@tensorflow/tfjs');
122
180
  const model = await getBackbone();
123
- const { width, height, data } = decodeImage(path);
181
+ const { rgb, w, h } = toRgb(decodeImage(path));
124
182
 
125
183
  return tf.tidy(() => {
126
- // RGBA → RGB float [0,1], shaped [h, w, 3], resized to the backbone input.
127
- const rgb = new Float32Array(width * height * 3);
128
- for (let i = 0, j = 0; i < data.length; i += 4, j += 3) {
129
- rgb[j] = data[i] / 255;
130
- rgb[j + 1] = data[i + 1] / 255;
131
- rgb[j + 2] = data[i + 2] / 255;
132
- }
133
- const img = tf.tensor3d(rgb, [height, width, 3]);
184
+ // RGB float [0,1], shaped [h, w, 3], resized to the backbone input.
185
+ const img = tf.tensor3d(rgb, [h, w, 3]);
134
186
  const resized = tf.image.resizeBilinear(img, [INPUT_SIZE, INPUT_SIZE]);
135
187
  const batched = resized.expandDims(0); // [1, 224, 224, 3]
136
188
  const out = model.predict(batched); // [1, 1024]