@myvillage/cli 2.10.0 → 2.13.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.10.0",
3
+ "version": "2.13.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,
@@ -60,8 +73,19 @@ export const BUILTIN_TOOL_DEFS = [
60
73
  },
61
74
  ];
62
75
 
63
- /** One line telling an agent what a local model is for and what it takes. */
64
- export function describeLocalModel(m) {
76
+ /** Longest example input shown to an agent, so a model's description stays short. */
77
+ const MAX_EXAMPLE_CHARS = 400;
78
+
79
+ /**
80
+ * Tell an agent what a local model is for, what it takes, and what it gives
81
+ * back, so it can decide whether the model fits the job.
82
+ *
83
+ * `extras` come from the dataset the model learned from: `labelNames` (the
84
+ * villager's names for each label id, e.g. waiting → "Spam and Junk"),
85
+ * `learnedFrom` (the dataset's description) and `example` (one training input,
86
+ * so the agent sends text shaped the same way).
87
+ */
88
+ export function describeLocalModel(m, extras = {}) {
65
89
  const f = m.featurization ?? {};
66
90
  const modality = f.modality ?? m.modality;
67
91
  let takes;
@@ -75,9 +99,18 @@ export function describeLocalModel(m) {
75
99
  } else {
76
100
  takes = `a list of ${m.inputDim ?? 'some'} numbers`;
77
101
  }
102
+ const names = extras.labelNames ?? {};
103
+ const label = (l) => (names[l] && names[l] !== l ? `"${l}" (${names[l]})` : `"${l}"`);
78
104
  const gives =
79
105
  m.taskType === 'classification' && Array.isArray(m.labels) && m.labels.length
80
- ? `sorts it into: ${m.labels.join(', ')}`
106
+ ? `sorts it into: ${m.labels.map(label).join(', ')}`
81
107
  : 'gives back a number';
82
- return `- "${m.slug}" (${m.name}): takes ${takes}; ${gives}.`;
108
+ const lines = [`- "${m.slug}" (${m.name}): takes ${takes}; ${gives}.`];
109
+ if (extras.learnedFrom) lines.push(` Learned from: ${extras.learnedFrom}`);
110
+ if (extras.example != null && modality !== 'image' && modality !== 'audio') {
111
+ let ex = typeof extras.example === 'string' ? extras.example : JSON.stringify(extras.example);
112
+ if (ex.length > MAX_EXAMPLE_CHARS) ex = ex.slice(0, MAX_EXAMPLE_CHARS) + '…';
113
+ lines.push(` Send input shaped like this training example: ${JSON.stringify(ex)}`);
114
+ }
115
+ return lines.join('\n');
83
116
  }
@@ -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,10 @@ import {
31
35
  import {
32
36
  DATASET_APPEND_DESCRIPTION,
33
37
  DATASET_READ_DESCRIPTION,
38
+ DATASET_SPLIT_DESCRIPTION,
34
39
  MODEL_PREDICT_DESCRIPTION,
35
40
  } from './builtin-tools-registry.js';
41
+ import { readAgentModels, describeAgentModel, modelDatasetExtras } from '../utils/agent-models.js';
36
42
  import { readToolsYaml } from '../utils/local-agent.js';
37
43
 
38
44
  export { BUILTIN_TOOL_DEFS } from './builtin-tools-registry.js';
@@ -91,11 +97,18 @@ function applyBuiltinAllowlist(builtins, agentName) {
91
97
  if (!Array.isArray(allowed)) return builtins;
92
98
  const filtered = {};
93
99
  for (const [name, t] of Object.entries(builtins)) {
94
- if (allowed.includes(name)) filtered[name] = t;
100
+ // model_predict is granted by the agent's `models:` list, not this allowlist.
101
+ if (name === 'model_predict' || allowed.includes(name)) filtered[name] = t;
95
102
  }
96
103
  return filtered;
97
104
  }
98
105
 
106
+ /** model_predict's description, naming the models this agent was given. */
107
+ function modelPredictDescription(slugs) {
108
+ const lines = slugs.map(describeAgentModel);
109
+ 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')}`;
110
+ }
111
+
99
112
  /**
100
113
  * Build the native built-in tools for an agent.
101
114
  * @param {string} agentName
@@ -103,6 +116,8 @@ function applyBuiltinAllowlist(builtins, agentName) {
103
116
  * @returns {Record<string, object>} AI SDK tools keyed by tool name
104
117
  */
105
118
  export function getBuiltinTools(agentName, config) {
119
+ // Models are granted per agent, like tools: only the ones in its tools.yaml.
120
+ const grantedModels = readAgentModels(agentName);
106
121
  const all = {
107
122
  dataset_append: tool({
108
123
  description: DATASET_APPEND_DESCRIPTION,
@@ -246,8 +261,33 @@ export function getBuiltinTools(agentName, config) {
246
261
  },
247
262
  }),
248
263
 
264
+ dataset_split: tool({
265
+ description: DATASET_SPLIT_DESCRIPTION,
266
+ inputSchema: z.object({
267
+ datasetId: z.string().describe('Slug of the local dataset to split'),
268
+ valPercent: z
269
+ .number()
270
+ .min(0)
271
+ .max(90)
272
+ .optional()
273
+ .describe('Percent of rows held back for validation (default 20)'),
274
+ seed: z.number().int().optional().describe('Seed for a repeatable shuffle'),
275
+ }),
276
+ execute: async ({ datasetId, valPercent = 20, seed }) => {
277
+ if (!datasetExists(datasetId)) {
278
+ return { error: `Dataset "${datasetId}" not found locally.` };
279
+ }
280
+ try {
281
+ const r = splitRows(datasetId, { valPercent, seed });
282
+ return { ok: true, datasetId: r.datasetId, valPercent: r.valPercent, moved: r.moved, stats: r.stats };
283
+ } catch (e) {
284
+ return { error: e.message };
285
+ }
286
+ },
287
+ }),
288
+
249
289
  model_predict: tool({
250
- description: MODEL_PREDICT_DESCRIPTION,
290
+ description: modelPredictDescription(grantedModels),
251
291
  inputSchema: z.object({
252
292
  modelId: z.string().describe('Slug of the local trained model'),
253
293
  input: z
@@ -263,6 +303,13 @@ export function getBuiltinTools(agentName, config) {
263
303
  ),
264
304
  }),
265
305
  execute: async ({ modelId, input }) => {
306
+ if (!grantedModels.includes(modelId)) {
307
+ return {
308
+ error:
309
+ `This agent hasn't been given the model "${modelId}". ` +
310
+ `It can use: ${grantedModels.join(', ')}.`,
311
+ };
312
+ }
266
313
  try {
267
314
  // Lazy-load the model/tfjs code so agent startup stays light — tfjs
268
315
  // only loads the first time an agent actually predicts.
@@ -279,12 +326,16 @@ export function getBuiltinTools(agentName, config) {
279
326
  const { predictWithModel } = await import('../utils/model-trainer.js');
280
327
  const record = readModelManifest(modelId);
281
328
  const model = await loadModel(modelId);
282
- return predictWithModel(model, record, input);
329
+ const result = await predictWithModel(model, record, input);
330
+ // Name the label the way the villager named it (e.g. "Spam and Junk").
331
+ const name = result?.label != null ? modelDatasetExtras(record).labelNames?.[result.label] : undefined;
332
+ return name && name !== result.label ? { ...result, labelName: name } : result;
283
333
  } catch (e) {
284
334
  return { error: e.message };
285
335
  }
286
336
  },
287
337
  }),
288
338
  };
339
+ if (grantedModels.length === 0) delete all.model_predict;
289
340
  return applyBuiltinAllowlist(all, agentName);
290
341
  }
@@ -13,9 +13,9 @@
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';
16
+ import { readAgentModels, describeAgentModel } from '../utils/agent-models.js';
17
17
 
18
- export async function gatherContext(agentConfig, lastCheckIn, recentActions = []) {
18
+ export async function gatherContext(agentConfig, lastCheckIn, recentActions = [], agentName = agentConfig?.name) {
19
19
  const parts = [];
20
20
 
21
21
  parts.push(`Current time: ${new Date().toISOString()}`);
@@ -51,10 +51,12 @@ export async function gatherContext(agentConfig, lastCheckIn, recentActions = []
51
51
  /* no local datasets / store unavailable — skip */
52
52
  }
53
53
  try {
54
- const models = listModels();
54
+ // Only the models this agent was given (its tools.yaml `models:` list).
55
+ const granted = new Set(agentName ? readAgentModels(agentName) : []);
56
+ const models = listModels().filter((m) => granted.has(m.slug));
55
57
  if (models.length > 0) {
56
- parts.push('\nLocal models you can run with model_predict (the quoted slug is the modelId). Use one whenever its job matches what you are doing:');
57
- for (const m of models.slice(0, 25)) parts.push(describeLocalModel(m));
58
+ 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:');
59
+ for (const m of models.slice(0, 25)) parts.push(describeAgentModel(m.slug));
58
60
  }
59
61
  } catch {
60
62
  /* no local models / store unavailable — skip */
@@ -10,6 +10,7 @@ import { join } from 'path';
10
10
  import { homedir } from 'os';
11
11
  import { getMCPTools, cleanupMCPClients } from './mcp-client.js';
12
12
  import { getBuiltinTools } from './builtin-tools.js';
13
+ import { readAgentModels } from '../utils/agent-models.js';
13
14
  import { gatherContext } from './context.js';
14
15
  import { isWithinActiveHours, getNextCheckInMs } from './scheduler.js';
15
16
  import { parse as parseYaml } from 'yaml';
@@ -140,7 +141,8 @@ export async function agentLoop(agentName, { signal }) {
140
141
  const result = await getMCPTools(agentDir, config);
141
142
  tools = result.tools;
142
143
  // Merge native built-in tools (in-process; no MCP server). e.g. dataset_append.
143
- Object.assign(tools, getBuiltinTools(agentName, config));
144
+ const builtins = getBuiltinTools(agentName, config);
145
+ Object.assign(tools, builtins);
144
146
  const servers = result.servers;
145
147
  const totalTools = Object.keys(tools).length;
146
148
  const failed = servers.filter(s => s.status === 'failed');
@@ -150,6 +152,9 @@ export async function agentLoop(agentName, { signal }) {
150
152
  model: modelId,
151
153
  totalTools,
152
154
  servers,
155
+ // Named so the log shows what the agent could use, models included.
156
+ builtinTools: Object.keys(builtins),
157
+ models: readAgentModels(agentName),
153
158
  });
154
159
 
155
160
  // Surface failed MCP connections as an error event too, so they show up
@@ -286,7 +291,7 @@ export async function agentLoop(agentName, { signal }) {
286
291
  // no longer auto-fetched here — the agent pulls them on demand via its
287
292
  // tools — so these activity counters stay at 0 unless a future signal
288
293
  // repopulates them.
289
- const contextResult = await gatherContext(config, lastCheckIn, recentActions);
294
+ const contextResult = await gatherContext(config, lastCheckIn, recentActions, agentName);
290
295
  let context = contextResult.text;
291
296
  const eventsFound = 0;
292
297
 
@@ -665,6 +670,7 @@ const ACTION_TOOLS = new Set([
665
670
  'moment_create',
666
671
  'pulse_create',
667
672
  'dataset_append',
673
+ 'dataset_split',
668
674
  'agent_join_community',
669
675
  'agent_leave_community',
670
676
  '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,141 @@
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, readModelManifest } from './model-workspace.js';
20
+ import { readManifest as readDatasetManifest, readRows } from './dataset-workspace.js';
21
+ import { describeLocalModel } from '../agent-runtime/builtin-tools-registry.js';
22
+
23
+ const MODEL_FILES = ['manifest.json', 'model.json', 'weights.b64'];
24
+ const SYNCED_MARKER = '.synced-hash';
25
+ const ABOUT_FILE = 'about.json';
26
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,127}$/;
27
+
28
+ /** Slugs listed under `models:` in an agent's tools.yaml (deduped, in order). */
29
+ export function readAgentModels(agentName) {
30
+ const list = readToolsYaml(agentName)?.models;
31
+ if (!Array.isArray(list)) return [];
32
+ return [...new Set(list.filter((s) => typeof s === 'string' && SLUG_RE.test(s)))];
33
+ }
34
+
35
+ function hashFiles(files) {
36
+ const h = createHash('sha256');
37
+ for (const f of MODEL_FILES) h.update(f).update('\0').update(files[f]).update('\0');
38
+ return h.digest('hex');
39
+ }
40
+
41
+ /**
42
+ * The files for each of an agent's models that exist on this machine, keyed by
43
+ * slug: { hash, files: { 'manifest.json', 'model.json', 'weights.b64' } }.
44
+ */
45
+ export function bundleAgentModels(agentName) {
46
+ const out = {};
47
+ for (const slug of readAgentModels(agentName)) {
48
+ if (!modelExists(slug)) continue;
49
+ const dir = getModelDir(slug);
50
+ try {
51
+ const files = Object.fromEntries(MODEL_FILES.map((f) => [f, readFileSync(join(dir, f), 'utf-8')]));
52
+ const manifest = JSON.parse(files['manifest.json']);
53
+ // What the dataset says about the model, since its dataset stays here.
54
+ out[slug] = { hash: hashFiles(files), files, about: modelDatasetExtras(manifest) };
55
+ } catch {
56
+ /* half-written or partly deleted model: skip it */
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /**
63
+ * Write a model that arrived with an agent's config. Returns what happened:
64
+ * 'written', 'current' (already this version), 'kept-local' (this machine
65
+ * trained its own model with that name), or 'invalid'.
66
+ */
67
+ export function writeSyncedModel(slug, entry) {
68
+ if (!SLUG_RE.test(slug) || !entry || typeof entry.files !== 'object') return 'invalid';
69
+ const files = entry.files;
70
+ if (!MODEL_FILES.every((f) => typeof files[f] === 'string' && files[f].length > 0)) return 'invalid';
71
+ const hash = hashFiles(files);
72
+ if (entry.hash && entry.hash !== hash) return 'invalid';
73
+ try {
74
+ if (JSON.parse(files['manifest.json']).slug !== slug) return 'invalid';
75
+ } catch {
76
+ return 'invalid';
77
+ }
78
+
79
+ const dir = getModelDir(slug);
80
+ const markerPath = join(dir, SYNCED_MARKER);
81
+ if (modelExists(slug)) {
82
+ if (!existsSync(markerPath)) return 'kept-local';
83
+ if (readFileSync(markerPath, 'utf-8').trim() === hash) {
84
+ writeAbout(dir, entry.about); // label names can change without retraining
85
+ return 'current';
86
+ }
87
+ }
88
+ mkdirSync(dir, { recursive: true });
89
+ // Weights first and the manifest last: a model counts as present once its
90
+ // manifest exists, so a half-finished write never looks like a whole model.
91
+ writeFileSync(join(dir, 'weights.b64'), files['weights.b64']);
92
+ writeFileSync(join(dir, 'model.json'), files['model.json']);
93
+ writeFileSync(join(dir, 'manifest.json'), files['manifest.json']);
94
+ writeFileSync(markerPath, hash);
95
+ writeAbout(dir, entry.about);
96
+ return 'written';
97
+ }
98
+
99
+ /** The synced copy of what a model's dataset says about it (see modelDatasetExtras). */
100
+ function writeAbout(dir, about) {
101
+ if (about && typeof about === 'object') writeFileSync(join(dir, ABOUT_FILE), JSON.stringify(about));
102
+ }
103
+
104
+ /**
105
+ * What the model's dataset adds to its description: the villager's names for
106
+ * its labels, what the dataset is, and one training input as an example.
107
+ * Falls back to the copy synced with the model, else empty; the model still
108
+ * works without it.
109
+ */
110
+ export function modelDatasetExtras(m) {
111
+ try {
112
+ const ds = m.datasetSlug ? readDatasetManifest(m.datasetSlug) : null;
113
+ if (!ds) return syncedAbout(m.slug);
114
+ let example;
115
+ try {
116
+ example = readRows(m.datasetSlug, { split: 'train', limit: 1 })[0]?.features;
117
+ } catch {
118
+ /* no rows to show */
119
+ }
120
+ return { labelNames: ds.labelNames ?? undefined, learnedFrom: ds.description || undefined, example };
121
+ } catch {
122
+ return {};
123
+ }
124
+ }
125
+
126
+ /** On a machine without the dataset: what came with the synced model. */
127
+ function syncedAbout(slug) {
128
+ try {
129
+ const about = JSON.parse(readFileSync(join(getModelDir(slug), ABOUT_FILE), 'utf-8'));
130
+ return about && typeof about === 'object' ? about : {};
131
+ } catch {
132
+ return {};
133
+ }
134
+ }
135
+
136
+ /** One model as an agent should see it (see describeLocalModel). */
137
+ export function describeAgentModel(slug) {
138
+ const m = readModelManifest(slug);
139
+ if (!m) return `- "${slug}": not on this computer yet.`;
140
+ return describeLocalModel(m, modelDatasetExtras(m));
141
+ }
@@ -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
  /**