@myvillage/cli 2.9.0 → 2.12.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "2.9.0",
3
+ "version": "2.12.0",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,6 +30,13 @@ export const DATASET_READ_DESCRIPTION =
30
30
  'as absolute file paths (which model_predict accepts). Read-only — use ' +
31
31
  'dataset_append to add rows.';
32
32
 
33
+ export const DATASET_SPLIT_DESCRIPTION =
34
+ 'Shuffle a LOCAL dataset\'s working set into training rows and validation ' +
35
+ '(held-back checking) rows in one step. `valPercent` (default 20) is the share ' +
36
+ 'held back; classification datasets split each label evenly so every group ' +
37
+ 'is checked. Replaces any split already set by hand. Use it before training, ' +
38
+ 'or after adding many rows. Data stays local.';
39
+
33
40
  export const MODEL_PREDICT_DESCRIPTION =
34
41
  'Run a prediction with a LOCAL trained model on this machine. `input` is ' +
35
42
  'text (words models), the values of one table row as a list in column ' +
@@ -52,6 +59,12 @@ export const BUILTIN_TOOL_DEFS = [
52
59
  tier: 'villager',
53
60
  builtin: true,
54
61
  },
62
+ {
63
+ name: 'dataset_split',
64
+ description: DATASET_SPLIT_DESCRIPTION,
65
+ tier: 'villager',
66
+ builtin: true,
67
+ },
55
68
  {
56
69
  name: 'model_predict',
57
70
  description: MODEL_PREDICT_DESCRIPTION,
@@ -59,3 +72,25 @@ export const BUILTIN_TOOL_DEFS = [
59
72
  builtin: true,
60
73
  },
61
74
  ];
75
+
76
+ /** One line telling an agent what a local model is for and what it takes. */
77
+ export function describeLocalModel(m) {
78
+ const f = m.featurization ?? {};
79
+ const modality = f.modality ?? m.modality;
80
+ let takes;
81
+ if (modality === 'image') takes = 'a local image file path (.jpg/.png)';
82
+ else if (modality === 'audio') takes = 'a local .wav file path';
83
+ else if (modality === 'text') takes = 'some writing';
84
+ else if (Array.isArray(f.columns)) {
85
+ takes = 'a table row with ' + f.columns.map((c) => `${JSON.stringify(c.name)} (${c.type})`).join(', ');
86
+ } else if (Array.isArray(m.featureNames) && m.featureNames.length) {
87
+ takes = 'a table row of numbers: ' + m.featureNames.map((n) => JSON.stringify(n)).join(', ');
88
+ } else {
89
+ takes = `a list of ${m.inputDim ?? 'some'} numbers`;
90
+ }
91
+ const gives =
92
+ m.taskType === 'classification' && Array.isArray(m.labels) && m.labels.length
93
+ ? `sorts it into: ${m.labels.join(', ')}`
94
+ : 'gives back a number';
95
+ return `- "${m.slug}" (${m.name}): takes ${takes}; ${gives}.`;
96
+ }
@@ -14,6 +14,9 @@
14
14
  * what's been collected (to answer questions about it, avoid duplicate
15
15
  * appends, or feed rows to model_predict). Also local-only.
16
16
  *
17
+ * `dataset_split` wraps splitRows() — the same engine as
18
+ * `myvillage dataset split` — to deal rows into train / val in one write.
19
+ *
17
20
  * Tool contracts mirror @myvillage-studio/dataset-schema DATASET_APPEND_TOOL
18
21
  * and DATASET_READ_TOOL.
19
22
  */
@@ -24,6 +27,7 @@ import {
24
27
  datasetExists,
25
28
  readManifest,
26
29
  readRows,
30
+ splitRows,
27
31
  ingestAsset,
28
32
  getAssetPath,
29
33
  listDatasets,
@@ -31,8 +35,12 @@ import {
31
35
  import {
32
36
  DATASET_APPEND_DESCRIPTION,
33
37
  DATASET_READ_DESCRIPTION,
38
+ DATASET_SPLIT_DESCRIPTION,
34
39
  MODEL_PREDICT_DESCRIPTION,
40
+ describeLocalModel,
35
41
  } from './builtin-tools-registry.js';
42
+ import { readAgentModels } from '../utils/agent-models.js';
43
+ import { readModelManifest as readModelManifestSync } from '../utils/model-workspace.js';
36
44
  import { readToolsYaml } from '../utils/local-agent.js';
37
45
 
38
46
  export { BUILTIN_TOOL_DEFS } from './builtin-tools-registry.js';
@@ -91,11 +99,21 @@ function applyBuiltinAllowlist(builtins, agentName) {
91
99
  if (!Array.isArray(allowed)) return builtins;
92
100
  const filtered = {};
93
101
  for (const [name, t] of Object.entries(builtins)) {
94
- if (allowed.includes(name)) filtered[name] = t;
102
+ // model_predict is granted by the agent's `models:` list, not this allowlist.
103
+ if (name === 'model_predict' || allowed.includes(name)) filtered[name] = t;
95
104
  }
96
105
  return filtered;
97
106
  }
98
107
 
108
+ /** model_predict's description, naming the models this agent was given. */
109
+ function modelPredictDescription(slugs, readModelManifest) {
110
+ const lines = slugs.map((slug) => {
111
+ const m = readModelManifest(slug);
112
+ return m ? describeLocalModel(m) : `- "${slug}": not on this computer yet.`;
113
+ });
114
+ return `${MODEL_PREDICT_DESCRIPTION} Use one whenever its job matches what you are doing. The models you can use (the quoted slug is the modelId):\n${lines.join('\n')}`;
115
+ }
116
+
99
117
  /**
100
118
  * Build the native built-in tools for an agent.
101
119
  * @param {string} agentName
@@ -103,6 +121,8 @@ function applyBuiltinAllowlist(builtins, agentName) {
103
121
  * @returns {Record<string, object>} AI SDK tools keyed by tool name
104
122
  */
105
123
  export function getBuiltinTools(agentName, config) {
124
+ // Models are granted per agent, like tools: only the ones in its tools.yaml.
125
+ const grantedModels = readAgentModels(agentName);
106
126
  const all = {
107
127
  dataset_append: tool({
108
128
  description: DATASET_APPEND_DESCRIPTION,
@@ -246,8 +266,33 @@ export function getBuiltinTools(agentName, config) {
246
266
  },
247
267
  }),
248
268
 
269
+ dataset_split: tool({
270
+ description: DATASET_SPLIT_DESCRIPTION,
271
+ inputSchema: z.object({
272
+ datasetId: z.string().describe('Slug of the local dataset to split'),
273
+ valPercent: z
274
+ .number()
275
+ .min(0)
276
+ .max(90)
277
+ .optional()
278
+ .describe('Percent of rows held back for validation (default 20)'),
279
+ seed: z.number().int().optional().describe('Seed for a repeatable shuffle'),
280
+ }),
281
+ execute: async ({ datasetId, valPercent = 20, seed }) => {
282
+ if (!datasetExists(datasetId)) {
283
+ return { error: `Dataset "${datasetId}" not found locally.` };
284
+ }
285
+ try {
286
+ const r = splitRows(datasetId, { valPercent, seed });
287
+ return { ok: true, datasetId: r.datasetId, valPercent: r.valPercent, moved: r.moved, stats: r.stats };
288
+ } catch (e) {
289
+ return { error: e.message };
290
+ }
291
+ },
292
+ }),
293
+
249
294
  model_predict: tool({
250
- description: MODEL_PREDICT_DESCRIPTION,
295
+ description: modelPredictDescription(grantedModels, readModelManifestSync),
251
296
  inputSchema: z.object({
252
297
  modelId: z.string().describe('Slug of the local trained model'),
253
298
  input: z
@@ -263,6 +308,13 @@ export function getBuiltinTools(agentName, config) {
263
308
  ),
264
309
  }),
265
310
  execute: async ({ modelId, input }) => {
311
+ if (!grantedModels.includes(modelId)) {
312
+ return {
313
+ error:
314
+ `This agent hasn't been given the model "${modelId}". ` +
315
+ `It can use: ${grantedModels.join(', ')}.`,
316
+ };
317
+ }
266
318
  try {
267
319
  // Lazy-load the model/tfjs code so agent startup stays light — tfjs
268
320
  // only loads the first time an agent actually predicts.
@@ -286,5 +338,6 @@ export function getBuiltinTools(agentName, config) {
286
338
  },
287
339
  }),
288
340
  };
341
+ if (grantedModels.length === 0) delete all.model_predict;
289
342
  return applyBuiltinAllowlist(all, agentName);
290
343
  }
@@ -13,8 +13,10 @@
13
13
 
14
14
  import { listDatasets } from '../utils/dataset-workspace.js';
15
15
  import { listModels } from '../utils/model-workspace.js';
16
+ import { describeLocalModel } from './builtin-tools-registry.js';
17
+ import { readAgentModels } from '../utils/agent-models.js';
16
18
 
17
- export async function gatherContext(agentConfig, lastCheckIn, recentActions = []) {
19
+ export async function gatherContext(agentConfig, lastCheckIn, recentActions = [], agentName = agentConfig?.name) {
18
20
  const parts = [];
19
21
 
20
22
  parts.push(`Current time: ${new Date().toISOString()}`);
@@ -50,13 +52,12 @@ export async function gatherContext(agentConfig, lastCheckIn, recentActions = []
50
52
  /* no local datasets / store unavailable — skip */
51
53
  }
52
54
  try {
53
- const models = listModels();
55
+ // Only the models this agent was given (its tools.yaml `models:` list).
56
+ const granted = new Set(agentName ? readAgentModels(agentName) : []);
57
+ const models = listModels().filter((m) => granted.has(m.slug));
54
58
  if (models.length > 0) {
55
- parts.push('\nLocal models you can run with model_predict (use the slug as modelId):');
56
- for (const m of models.slice(0, 25)) {
57
- const labels = m.labels?.length ? ` → ${m.labels.join('/')}` : '';
58
- parts.push(`- ${m.slug} — ${m.modality}/${m.taskType}${labels}`);
59
- }
59
+ parts.push('\nModels you can run with model_predict (the quoted slug is the modelId). Use one whenever its job matches what you are doing:');
60
+ for (const m of models.slice(0, 25)) parts.push(describeLocalModel(m));
60
61
  }
61
62
  } catch {
62
63
  /* no local models / store unavailable — skip */
@@ -286,7 +286,7 @@ export async function agentLoop(agentName, { signal }) {
286
286
  // no longer auto-fetched here — the agent pulls them on demand via its
287
287
  // tools — so these activity counters stay at 0 unless a future signal
288
288
  // repopulates them.
289
- const contextResult = await gatherContext(config, lastCheckIn, recentActions);
289
+ const contextResult = await gatherContext(config, lastCheckIn, recentActions, agentName);
290
290
  let context = contextResult.text;
291
291
  const eventsFound = 0;
292
292
 
@@ -665,6 +665,7 @@ const ACTION_TOOLS = new Set([
665
665
  'moment_create',
666
666
  'pulse_create',
667
667
  'dataset_append',
668
+ 'dataset_split',
668
669
  'agent_join_community',
669
670
  'agent_leave_community',
670
671
  'wallet_send',
@@ -27,6 +27,7 @@ import { refreshAccessToken } from '../utils/api.js';
27
27
  import { listLocalAgents, readAgentConfig, getAgentDir, writeAgentConfig } from '../utils/local-agent.js';
28
28
  import { startDaemon, stopDaemon, isDaemonRunning, getDaemonInfo } from './daemon.js';
29
29
  import { shipLogsOnce, forgetLogCursor } from './log-shipper.js';
30
+ import { writeSyncedModel } from '../utils/agent-models.js';
30
31
 
31
32
  const RUNNER_STATE_FILE = path.join(os.homedir(), '.myvillage', 'runner.json');
32
33
  const DEFAULT_HEARTBEAT_MS = 20_000;
@@ -176,6 +177,16 @@ async function syncAgentConfig(ctx, assignment) {
176
177
  fs.writeFileSync(path.join(agentDir, 'tools.yaml'), c.toolsYaml);
177
178
  }
178
179
 
180
+ // The trained models this agent was given travel with its config.
181
+ if (c.models && typeof c.models === 'object') {
182
+ for (const [slug, entry] of Object.entries(c.models)) {
183
+ const result = writeSyncedModel(slug, entry);
184
+ if (result === 'written') log(`synced model ${slug} for ${name}`);
185
+ else if (result === 'kept-local') log(`kept this machine's own model ${slug} for ${name}`);
186
+ else if (result === 'invalid') log(`skipped model ${slug} for ${name}: its files didn't check out`);
187
+ }
188
+ }
189
+
179
190
  // Compose agent.config.yaml from server-known identity + published schedule
180
191
  // and brain, preserving anything already set locally that the control plane
181
192
  // does not own.
@@ -21,6 +21,7 @@ import {
21
21
  freezeVersion,
22
22
  deleteRow,
23
23
  updateRow,
24
+ splitRows,
24
25
  } from '../utils/dataset-workspace.js';
25
26
 
26
27
  // ── create ─────────────────────────────────────────────
@@ -250,6 +251,23 @@ export async function datasetRowUpdateCommand(options) {
250
251
  }
251
252
  }
252
253
 
254
+ // ── split ──────────────────────────────────────────────
255
+
256
+ export async function datasetSplitCommand(slug, options) {
257
+ try {
258
+ const result = splitRows(slug, { valPercent: options.val, seed: options.seed });
259
+ if (options.json) {
260
+ console.log(JSON.stringify(result, null, 2));
261
+ return;
262
+ }
263
+ success(`Split ${slug}: ${100 - result.valPercent}% train / ${result.valPercent}% val`);
264
+ info(` total: ${result.stats.rowCount} (train ${result.stats.bySplit.train} / val ${result.stats.bySplit.val}) · ${result.moved} rows moved`);
265
+ } catch (e) {
266
+ error(e.message);
267
+ process.exitCode = 1;
268
+ }
269
+ }
270
+
253
271
  // ── helpers ────────────────────────────────────────────
254
272
 
255
273
  function readRowsFromFile(path) {
package/src/index.js CHANGED
@@ -143,6 +143,7 @@ import {
143
143
  datasetFreezeCommand,
144
144
  datasetRowDeleteCommand,
145
145
  datasetRowUpdateCommand,
146
+ datasetSplitCommand,
146
147
  } from './commands/dataset.js';
147
148
  import {
148
149
  modelTrainCommand,
@@ -1054,6 +1055,14 @@ export function run() {
1054
1055
  .option('--json', 'Output raw JSON')
1055
1056
  .action(datasetRowUpdateCommand);
1056
1057
 
1058
+ datasetCmd
1059
+ .command('split <slug>')
1060
+ .description('Shuffle the working set into train and val (each label split evenly)')
1061
+ .option('--val <percent>', 'Percent of rows held back to check the model (0-90)', '20')
1062
+ .option('--seed <n>', 'Seed for a repeatable shuffle')
1063
+ .option('--json', 'Output raw JSON')
1064
+ .action(datasetSplitCommand);
1065
+
1057
1066
  // ── Studio Lab: model training (local, TF.js CPU engine) ───────────────
1058
1067
  const modelCmd = program
1059
1068
  .command('model')
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The trained models an agent may use, and the files that carry them between
3
+ * machines.
4
+ *
5
+ * An agent's models are granted like its tools: a `models:` list of local
6
+ * model slugs in its tools.yaml. No list means no models. Because tools.yaml
7
+ * is published verbatim to the control plane, the list follows the agent to
8
+ * any machine; the model files themselves travel in the published config's
9
+ * `models` field (see bundleAgentModels / writeSyncedModel).
10
+ *
11
+ * A model a machine trained itself is never overwritten by a synced copy. A
12
+ * synced copy is marked with `.synced-hash`, so the runner only replaces what
13
+ * it put there.
14
+ */
15
+ import { createHash } from 'crypto';
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
17
+ import { join } from 'path';
18
+ import { readToolsYaml } from './local-agent.js';
19
+ import { getModelDir, modelExists } from './model-workspace.js';
20
+
21
+ const MODEL_FILES = ['manifest.json', 'model.json', 'weights.b64'];
22
+ const SYNCED_MARKER = '.synced-hash';
23
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,127}$/;
24
+
25
+ /** Slugs listed under `models:` in an agent's tools.yaml (deduped, in order). */
26
+ export function readAgentModels(agentName) {
27
+ const list = readToolsYaml(agentName)?.models;
28
+ if (!Array.isArray(list)) return [];
29
+ return [...new Set(list.filter((s) => typeof s === 'string' && SLUG_RE.test(s)))];
30
+ }
31
+
32
+ function hashFiles(files) {
33
+ const h = createHash('sha256');
34
+ for (const f of MODEL_FILES) h.update(f).update('\0').update(files[f]).update('\0');
35
+ return h.digest('hex');
36
+ }
37
+
38
+ /**
39
+ * The files for each of an agent's models that exist on this machine, keyed by
40
+ * slug: { hash, files: { 'manifest.json', 'model.json', 'weights.b64' } }.
41
+ */
42
+ export function bundleAgentModels(agentName) {
43
+ const out = {};
44
+ for (const slug of readAgentModels(agentName)) {
45
+ if (!modelExists(slug)) continue;
46
+ const dir = getModelDir(slug);
47
+ try {
48
+ const files = Object.fromEntries(MODEL_FILES.map((f) => [f, readFileSync(join(dir, f), 'utf-8')]));
49
+ out[slug] = { hash: hashFiles(files), files };
50
+ } catch {
51
+ /* half-written or partly deleted model: skip it */
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /**
58
+ * Write a model that arrived with an agent's config. Returns what happened:
59
+ * 'written', 'current' (already this version), 'kept-local' (this machine
60
+ * trained its own model with that name), or 'invalid'.
61
+ */
62
+ export function writeSyncedModel(slug, entry) {
63
+ if (!SLUG_RE.test(slug) || !entry || typeof entry.files !== 'object') return 'invalid';
64
+ const files = entry.files;
65
+ if (!MODEL_FILES.every((f) => typeof files[f] === 'string' && files[f].length > 0)) return 'invalid';
66
+ const hash = hashFiles(files);
67
+ if (entry.hash && entry.hash !== hash) return 'invalid';
68
+ try {
69
+ if (JSON.parse(files['manifest.json']).slug !== slug) return 'invalid';
70
+ } catch {
71
+ return 'invalid';
72
+ }
73
+
74
+ const dir = getModelDir(slug);
75
+ const markerPath = join(dir, SYNCED_MARKER);
76
+ if (modelExists(slug)) {
77
+ if (!existsSync(markerPath)) return 'kept-local';
78
+ if (readFileSync(markerPath, 'utf-8').trim() === hash) return 'current';
79
+ }
80
+ mkdirSync(dir, { recursive: true });
81
+ // Weights first and the manifest last: a model counts as present once its
82
+ // manifest exists, so a half-finished write never looks like a whole model.
83
+ writeFileSync(join(dir, 'weights.b64'), files['weights.b64']);
84
+ writeFileSync(join(dir, 'model.json'), files['model.json']);
85
+ writeFileSync(join(dir, 'manifest.json'), files['manifest.json']);
86
+ writeFileSync(markerPath, hash);
87
+ return 'written';
88
+ }
@@ -494,6 +494,68 @@ export function updateRow(slug, rowId, updates = {}) {
494
494
  return { datasetId: slug, updated: merged.id, stats: manifest.stats };
495
495
  }
496
496
 
497
+ // ── Split the working set into train / val ─────────────
498
+
499
+ /** Small seeded random (mulberry32), so a --seed split is repeatable. */
500
+ function seededRandom(seed) {
501
+ let a = seed >>> 0;
502
+ return () => {
503
+ a = (a + 0x6d2b79f5) >>> 0;
504
+ let t = a;
505
+ t = Math.imul(t ^ (t >>> 15), t | 1);
506
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
507
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
508
+ };
509
+ }
510
+
511
+ function shuffle(items, rand) {
512
+ for (let i = items.length - 1; i > 0; i--) {
513
+ const j = Math.floor(rand() * (i + 1));
514
+ [items[i], items[j]] = [items[j], items[i]];
515
+ }
516
+ return items;
517
+ }
518
+
519
+ /**
520
+ * Re-deal every working-set row into train or val in one write. Rows are
521
+ * shuffled, and for classification each label is split on its own, so every
522
+ * group gets its fair share of val rows (a label with 2+ rows always keeps at
523
+ * least one of each). Records the ratio in manifest.splitConfig.
524
+ * @param {string} slug
525
+ * @param {{valPercent?:number, seed?:number}} opts valPercent 0–90, default 20
526
+ * @returns {{datasetId:string, valPercent:number, moved:number, stats:object}}
527
+ */
528
+ export function splitRows(slug, { valPercent = 20, seed } = {}) {
529
+ const manifest = readManifest(slug);
530
+ if (!manifest) throw new Error(`Dataset "${slug}" not found.`);
531
+ const pct = Number(valPercent);
532
+ if (!Number.isFinite(pct) || pct < 0 || pct > 90) {
533
+ throw new Error('The share held back for checking must be between 0 and 90 percent.');
534
+ }
535
+ const rows = readRows(slug);
536
+ if (rows.length === 0) throw new Error('This dataset has no rows to split yet.');
537
+ const rand = seed != null && seed !== '' ? seededRandom(Number(seed)) : Math.random;
538
+
539
+ const groups = new Map();
540
+ for (const row of rows) {
541
+ const key = manifest.taskType === 'classification' ? (row.label ?? '') : '';
542
+ if (!groups.has(key)) groups.set(key, []);
543
+ groups.get(key).push(row);
544
+ }
545
+ const before = new Map(rows.map((r) => [r.id, r.split]));
546
+ for (const group of groups.values()) {
547
+ let nVal = Math.round((group.length * pct) / 100);
548
+ if (pct > 0 && group.length >= 2) nVal = Math.min(Math.max(nVal, 1), group.length - 1);
549
+ shuffle([...group], rand).forEach((row, i) => {
550
+ row.split = i < nVal ? 'val' : 'train';
551
+ });
552
+ }
553
+ const moved = rows.filter((r) => before.get(r.id) !== r.split).length;
554
+ manifest.splitConfig = { ...(manifest.splitConfig || {}), trainRatio: 1 - pct / 100 };
555
+ writeWorkingSet(slug, manifest, rows);
556
+ return { datasetId: slug, valPercent: pct, moved, stats: manifest.stats };
557
+ }
558
+
497
559
  // ── Freeze a version ───────────────────────────────────
498
560
 
499
561
  /**