@myvillage/cli 1.66.0 → 1.69.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": "1.66.0",
3
+ "version": "1.69.0",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,6 +30,7 @@
30
30
  "@ai-sdk/anthropic": "^3.0.78",
31
31
  "@ai-sdk/mcp": "^1.0.42",
32
32
  "@ai-sdk/openai": "^3.0.33",
33
+ "@tensorflow/tfjs": "^4.22.0",
33
34
  "ai": "^6.0.0",
34
35
  "axios": "^1.6.2",
35
36
  "chalk": "^5.3.0",
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Native built-in tools for the agent loop.
3
+ *
4
+ * Unlike MCP tools (loaded from tools.yaml via mcp-client.js), these run
5
+ * IN-PROCESS in the local daemon — no server, no subprocess, no socket.
6
+ * They're merged into the tool set passed to the LLM alongside MCP tools.
7
+ *
8
+ * `dataset_append` is the agent-facing "collect" input to the Studio Lab
9
+ * flywheel. It wraps appendRows() — the SAME execution engine the
10
+ * `myvillage dataset append` command uses — so agents and humans write
11
+ * identically. Raw data stays local; nothing leaves the machine.
12
+ *
13
+ * Tool contract mirrors @myvillage-studio/dataset-schema DATASET_APPEND_TOOL.
14
+ */
15
+ import { tool } from 'ai';
16
+ import { z } from 'zod';
17
+ import { appendRows, datasetExists } from '../utils/dataset-workspace.js';
18
+
19
+ /**
20
+ * Build the native built-in tools for an agent.
21
+ * @param {string} agentName
22
+ * @param {object} config parsed agent.config.yaml
23
+ * @returns {Record<string, object>} AI SDK tools keyed by tool name
24
+ */
25
+ export function getBuiltinTools(agentName, config) {
26
+ return {
27
+ dataset_append: tool({
28
+ description:
29
+ 'Append labeled training example(s) to a LOCAL dataset on this machine. ' +
30
+ 'The dataset must already exist (created via the Studio Lab or ' +
31
+ '`myvillage dataset create`). For classification provide `label`; for ' +
32
+ 'regression provide a numeric `target`. Data stays local; provenance ' +
33
+ 'is recorded automatically.',
34
+ inputSchema: z.object({
35
+ datasetId: z.string().describe('Slug of the local dataset to append to'),
36
+ rows: z
37
+ .array(
38
+ z.object({
39
+ features: z
40
+ .union([z.string(), z.array(z.number())])
41
+ .describe('Text content, or a numeric vector for tabular data'),
42
+ label: z.string().optional().describe('Class label (classification datasets)'),
43
+ target: z.number().optional().describe('Numeric target (regression datasets)'),
44
+ split: z.enum(['train', 'val']).optional(),
45
+ }),
46
+ )
47
+ .min(1)
48
+ .describe('One or more labeled examples to append'),
49
+ }),
50
+ execute: async ({ datasetId, rows }) => {
51
+ if (!datasetExists(datasetId)) {
52
+ return {
53
+ error:
54
+ `Dataset "${datasetId}" not found locally. Create it first ` +
55
+ '(Studio Lab or `myvillage dataset create`).',
56
+ };
57
+ }
58
+ try {
59
+ const provenance = { source: `agent:${agentName}` };
60
+ const agentId = config?.man?.village_agent_id;
61
+ if (agentId) provenance.agentId = agentId;
62
+ const result = appendRows(datasetId, rows, provenance);
63
+ return {
64
+ ok: true,
65
+ datasetId: result.datasetId,
66
+ appended: result.appended,
67
+ totalRows: result.stats.rowCount,
68
+ };
69
+ } catch (e) {
70
+ // Return the error to the model so it can self-correct rather than
71
+ // crashing the step.
72
+ return { error: e.message };
73
+ }
74
+ },
75
+ }),
76
+ };
77
+ }
@@ -9,6 +9,7 @@ import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync } fr
9
9
  import { join } from 'path';
10
10
  import { homedir } from 'os';
11
11
  import { getMCPTools, cleanupMCPClients } from './mcp-client.js';
12
+ import { getBuiltinTools } from './builtin-tools.js';
12
13
  import { gatherContext } from './context.js';
13
14
  import { isWithinActiveHours, getNextCheckInMs } from './scheduler.js';
14
15
  import { parse as parseYaml } from 'yaml';
@@ -78,6 +79,8 @@ export async function agentLoop(agentName, { signal }) {
78
79
  try {
79
80
  const result = await getMCPTools(agentDir, config);
80
81
  tools = result.tools;
82
+ // Merge native built-in tools (in-process; no MCP server). e.g. dataset_append.
83
+ Object.assign(tools, getBuiltinTools(agentName, config));
81
84
  const servers = result.servers;
82
85
  const totalTools = Object.keys(tools).length;
83
86
  const failed = servers.filter(s => s.status === 'failed');
@@ -142,6 +145,7 @@ export async function agentLoop(agentName, { signal }) {
142
145
  await cleanupMCPClients();
143
146
  const refreshed = await getMCPTools(agentDir, config);
144
147
  tools = refreshed.tools;
148
+ Object.assign(tools, getBuiltinTools(agentName, config));
145
149
  logActivity(agentDir, { type: 'mcp_reconnected', reason: 'token rotated' });
146
150
  }
147
151
  } catch (err) {
@@ -571,6 +575,7 @@ const ACTION_TOOLS = new Set([
571
575
  'community_event_cancel',
572
576
  'moment_create',
573
577
  'pulse_create',
578
+ 'dataset_append',
574
579
  'agent_join_community',
575
580
  'agent_leave_community',
576
581
  'wallet_send',
@@ -0,0 +1,198 @@
1
+ /**
2
+ * `myvillage dataset *` — manage local training datasets for the Studio Lab.
3
+ *
4
+ * All data stays under ~/.myvillage/datasets/ (local-only). These commands
5
+ * are the human-facing surface over src/utils/dataset-workspace.js; the same
6
+ * appendRows() execution function will back the dataset_append MCP tool so
7
+ * agents and the CLI write identically.
8
+ */
9
+ import { existsSync, readFileSync } from 'fs';
10
+ import { brand, success, error, info, header, villageSpinner } from '../utils/brand.js';
11
+ import {
12
+ MODALITIES,
13
+ TASK_TYPES,
14
+ createDataset,
15
+ listDatasets,
16
+ readManifest,
17
+ datasetExists,
18
+ appendRows,
19
+ readRows,
20
+ freezeVersion,
21
+ } from '../utils/dataset-workspace.js';
22
+
23
+ // ── create ─────────────────────────────────────────────
24
+
25
+ export async function datasetCreateCommand(options) {
26
+ try {
27
+ const labels = options.labels
28
+ ? options.labels.split(',').map((s) => s.trim()).filter(Boolean)
29
+ : [];
30
+ const manifest = createDataset({
31
+ name: options.name,
32
+ modality: options.modality,
33
+ taskType: options.task || 'classification',
34
+ labels,
35
+ description: options.description,
36
+ });
37
+ if (options.json) {
38
+ console.log(JSON.stringify(manifest, null, 2));
39
+ return;
40
+ }
41
+ success(`Created dataset "${manifest.name}" (${manifest.slug})`);
42
+ info(` modality: ${manifest.modality} · task: ${manifest.taskType}`);
43
+ if (manifest.labels.length) info(` labels: ${manifest.labels.join(', ')}`);
44
+ info(` append rows with: myvillage dataset append --dataset ${manifest.slug} ...`);
45
+ } catch (e) {
46
+ error(e.message);
47
+ process.exitCode = 1;
48
+ }
49
+ }
50
+
51
+ // ── list ───────────────────────────────────────────────
52
+
53
+ export async function datasetListCommand(options) {
54
+ try {
55
+ const datasets = listDatasets();
56
+ if (options.json) {
57
+ console.log(JSON.stringify(datasets, null, 2));
58
+ return;
59
+ }
60
+ if (datasets.length === 0) {
61
+ info('No local datasets yet. Create one with: myvillage dataset create --name "..." --modality text');
62
+ return;
63
+ }
64
+ header('Local datasets');
65
+ for (const d of datasets) {
66
+ const ver = d.currentVersion ? `v${d.currentVersion}` : 'unfrozen';
67
+ console.log(
68
+ ` ${brand.gold(d.slug.padEnd(28))} ${String(d.modality).padEnd(8)} ${String(d.taskType).padEnd(15)} ${String(d.stats.rowCount).padStart(6)} rows ${ver}`,
69
+ );
70
+ }
71
+ console.log('');
72
+ } catch (e) {
73
+ error(e.message);
74
+ process.exitCode = 1;
75
+ }
76
+ }
77
+
78
+ // ── append ─────────────────────────────────────────────
79
+
80
+ export async function datasetAppendCommand(options) {
81
+ try {
82
+ const slug = options.dataset;
83
+ if (!slug) throw new Error('--dataset <slug> is required.');
84
+ if (!datasetExists(slug)) throw new Error(`Dataset "${slug}" not found.`);
85
+
86
+ let rows;
87
+ if (options.file) {
88
+ rows = readRowsFromFile(options.file);
89
+ } else if (options.text !== undefined || options.features !== undefined) {
90
+ const row = { split: options.split || 'train' };
91
+ row.features = options.features !== undefined ? parseFeatures(options.features) : options.text;
92
+ if (options.label !== undefined) row.label = options.label;
93
+ if (options.target !== undefined) row.target = Number(options.target);
94
+ rows = [row];
95
+ } else {
96
+ throw new Error('Provide either --file <jsonl> or --text/--features (with --label or --target).');
97
+ }
98
+
99
+ const spinner = options.json ? null : villageSpinner('Appending rows...').start();
100
+ const result = appendRows(slug, rows, { source: 'cli' });
101
+ if (spinner) spinner.stop();
102
+
103
+ if (options.json) {
104
+ console.log(JSON.stringify(result, null, 2));
105
+ return;
106
+ }
107
+ success(`Appended ${result.appended} row(s) to ${slug}`);
108
+ info(` total: ${result.stats.rowCount} (train ${result.stats.bySplit.train} / val ${result.stats.bySplit.val})`);
109
+ } catch (e) {
110
+ error(e.message);
111
+ process.exitCode = 1;
112
+ }
113
+ }
114
+
115
+ // ── show ───────────────────────────────────────────────
116
+
117
+ export async function datasetShowCommand(slug, options) {
118
+ try {
119
+ const manifest = readManifest(slug);
120
+ if (!manifest) throw new Error(`Dataset "${slug}" not found.`);
121
+ const limit = options.limit ? parseInt(options.limit, 10) : 5;
122
+ const sample = readRows(slug, { limit });
123
+ if (options.json) {
124
+ console.log(JSON.stringify({ manifest, sample }, null, 2));
125
+ return;
126
+ }
127
+ header(`${manifest.name} (${manifest.slug})`);
128
+ info(` modality: ${manifest.modality} · task: ${manifest.taskType}`);
129
+ if (manifest.labels.length) info(` labels: ${manifest.labels.join(', ')}`);
130
+ info(` rows: ${manifest.stats.rowCount} (train ${manifest.stats.bySplit.train} / val ${manifest.stats.bySplit.val})`);
131
+ info(` versions: ${manifest.versions.length ? manifest.versions.map((v) => `v${v.version}`).join(', ') : '(none frozen)'}`);
132
+ if (sample.length) {
133
+ console.log(brand.cream(`\n sample (${sample.length}):`));
134
+ for (const r of sample) {
135
+ const feat = typeof r.features === 'string' ? truncate(r.features, 50) : JSON.stringify(r.features);
136
+ const tgt = r.label != null ? `label=${r.label}` : `target=${r.target}`;
137
+ console.log(` [${r.split}] ${tgt} ${brand.teal(feat)}`);
138
+ }
139
+ }
140
+ console.log('');
141
+ } catch (e) {
142
+ error(e.message);
143
+ process.exitCode = 1;
144
+ }
145
+ }
146
+
147
+ // ── freeze ─────────────────────────────────────────────
148
+
149
+ export async function datasetFreezeCommand(slug, options) {
150
+ try {
151
+ const meta = freezeVersion(slug, options.changelog);
152
+ if (options.json) {
153
+ console.log(JSON.stringify(meta, null, 2));
154
+ return;
155
+ }
156
+ success(`Froze ${slug} as v${meta.version} (${meta.rowCount} rows)`);
157
+ } catch (e) {
158
+ error(e.message);
159
+ process.exitCode = 1;
160
+ }
161
+ }
162
+
163
+ // ── helpers ────────────────────────────────────────────
164
+
165
+ function readRowsFromFile(path) {
166
+ if (!existsSync(path)) throw new Error(`File not found: ${path}`);
167
+ const rows = [];
168
+ for (const line of readFileSync(path, 'utf-8').split('\n')) {
169
+ if (!line.trim()) continue;
170
+ try {
171
+ rows.push(JSON.parse(line));
172
+ } catch {
173
+ throw new Error(`Invalid JSONL line in ${path}: ${truncate(line, 60)}`);
174
+ }
175
+ }
176
+ if (rows.length === 0) throw new Error(`No rows found in ${path}`);
177
+ return rows;
178
+ }
179
+
180
+ function parseFeatures(raw) {
181
+ // tabular features may be passed as a comma/JSON numeric vector
182
+ const trimmed = raw.trim();
183
+ if (trimmed.startsWith('[')) {
184
+ try {
185
+ return JSON.parse(trimmed);
186
+ } catch {
187
+ /* fall through */
188
+ }
189
+ }
190
+ if (/^-?\d.*,/.test(trimmed)) {
191
+ return trimmed.split(',').map((n) => Number(n.trim()));
192
+ }
193
+ return raw;
194
+ }
195
+
196
+ function truncate(s, n) {
197
+ return s.length > n ? s.slice(0, n - 1) + '…' : s;
198
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * `myvillage model *` — train and use local models for the Studio Lab.
3
+ *
4
+ * Training runs in-process on TF.js (CPU). This is the single training engine;
5
+ * the Studio drives these same commands and streams the progress they emit
6
+ * (with --json, one progress line per epoch, then a final result object).
7
+ */
8
+ import { brand, success, error, info, header, villageSpinner } from '../utils/brand.js';
9
+ import { readManifest as readDatasetManifest, datasetExists } from '../utils/dataset-workspace.js';
10
+ import { trainModel, predictWithModel, TRAINABLE_MODALITIES } from '../utils/model-trainer.js';
11
+ import { saveModel, listModels, readModelManifest, modelExists, loadModel } from '../utils/model-workspace.js';
12
+
13
+ // ── train ──────────────────────────────────────────────
14
+
15
+ export async function modelTrainCommand(options) {
16
+ try {
17
+ const datasetSlug = options.dataset;
18
+ if (!datasetSlug) throw new Error('--dataset <slug> is required.');
19
+ if (!datasetExists(datasetSlug)) throw new Error(`Dataset "${datasetSlug}" not found.`);
20
+ const ds = readDatasetManifest(datasetSlug);
21
+ if (!TRAINABLE_MODALITIES.includes(ds.modality)) {
22
+ throw new Error(
23
+ `Training "${ds.modality}" datasets is coming soon — v1 supports ${TRAINABLE_MODALITIES.join(' and ')}.`,
24
+ );
25
+ }
26
+ const name = options.name || `${ds.name} model`;
27
+ const epochs = options.epochs ? parseInt(options.epochs, 10) : 40;
28
+
29
+ // Progress: stream JSON lines when --json (Studio parses these); else spinner.
30
+ const spinner = options.json ? null : villageSpinner('Training…').start();
31
+ const onProgress = (p) => {
32
+ if (options.json) {
33
+ process.stdout.write(JSON.stringify({ type: 'progress', ...p }) + '\n');
34
+ } else {
35
+ const metric =
36
+ p.valAccuracy != null ? `val acc ${(p.valAccuracy * 100).toFixed(1)}%`
37
+ : p.valMae != null ? `val MAE ${p.valMae}`
38
+ : `loss ${p.loss}`;
39
+ spinner.text = `Training… epoch ${p.epoch}/${p.totalEpochs} · ${metric}`;
40
+ }
41
+ };
42
+
43
+ const { model, record } = await trainModel(datasetSlug, ds, { epochs, onProgress });
44
+ const manifest = await saveModel(name, {
45
+ model,
46
+ record,
47
+ datasetSlug,
48
+ datasetVersion: ds.currentVersion || 0,
49
+ });
50
+ if (spinner) spinner.stop();
51
+
52
+ if (options.json) {
53
+ process.stdout.write(JSON.stringify({ type: 'result', model: manifest }) + '\n');
54
+ return;
55
+ }
56
+ success(`Trained "${manifest.name}" (${manifest.slug}) from ${record.examplesUsed} examples`);
57
+ const m = record.metrics;
58
+ if (m.accuracy != null) info(` accuracy: ${(m.accuracy * 100).toFixed(1)}% over ${m.classes} classes`);
59
+ if (m.mae != null) info(` MAE: ${m.mae} · R²: ${m.r2}`);
60
+ info(` test it: myvillage model predict ${manifest.slug} --input "..."`);
61
+ } catch (e) {
62
+ if (options.json) process.stdout.write(JSON.stringify({ type: 'error', error: e.message }) + '\n');
63
+ else error(e.message);
64
+ process.exitCode = 1;
65
+ }
66
+ }
67
+
68
+ // ── list ───────────────────────────────────────────────
69
+
70
+ export async function modelListCommand(options) {
71
+ try {
72
+ const models = listModels();
73
+ if (options.json) {
74
+ console.log(JSON.stringify(models, null, 2));
75
+ return;
76
+ }
77
+ if (models.length === 0) {
78
+ info('No models yet. Train one with: myvillage model train --dataset <slug>');
79
+ return;
80
+ }
81
+ header('Local models');
82
+ for (const m of models) {
83
+ const score =
84
+ m.metrics?.accuracy != null ? `${(m.metrics.accuracy * 100).toFixed(0)}% acc`
85
+ : m.metrics?.mae != null ? `MAE ${m.metrics.mae}`
86
+ : '—';
87
+ console.log(` ${brand.gold(m.slug.padEnd(28))} ${String(m.taskType).padEnd(15)} ${String(score).padStart(10)} ← ${m.datasetSlug}`);
88
+ }
89
+ console.log('');
90
+ } catch (e) {
91
+ error(e.message);
92
+ process.exitCode = 1;
93
+ }
94
+ }
95
+
96
+ // ── show ───────────────────────────────────────────────
97
+
98
+ export async function modelShowCommand(slug, options) {
99
+ try {
100
+ const m = readModelManifest(slug);
101
+ if (!m) throw new Error(`Model "${slug}" not found.`);
102
+ if (options.json) {
103
+ console.log(JSON.stringify(m, null, 2));
104
+ return;
105
+ }
106
+ header(`${m.name} (${m.slug})`);
107
+ info(` task: ${m.taskType} · modality: ${m.modality} · trained on: ${m.datasetSlug} (v${m.datasetVersion})`);
108
+ info(` examples: ${m.examplesUsed} · format: ${m.format}`);
109
+ if (m.metrics?.accuracy != null) info(` accuracy: ${(m.metrics.accuracy * 100).toFixed(1)}% over ${m.metrics.classes} classes`);
110
+ if (m.metrics?.mae != null) info(` MAE: ${m.metrics.mae} · R²: ${m.metrics.r2}`);
111
+ if (m.labels?.length) info(` labels: ${m.labels.join(', ')}`);
112
+ console.log('');
113
+ } catch (e) {
114
+ error(e.message);
115
+ process.exitCode = 1;
116
+ }
117
+ }
118
+
119
+ // ── predict ────────────────────────────────────────────
120
+
121
+ export async function modelPredictCommand(slug, options) {
122
+ try {
123
+ if (!modelExists(slug)) throw new Error(`Model "${slug}" not found.`);
124
+ if (options.input === undefined) throw new Error('--input <value> is required.');
125
+ const record = readModelManifest(slug);
126
+ const model = await loadModel(slug);
127
+ const result = predictWithModel(model, record, options.input);
128
+ if (options.json) {
129
+ console.log(JSON.stringify(result, null, 2));
130
+ return;
131
+ }
132
+ if (result.label != null) {
133
+ success(`Prediction: ${result.label}`);
134
+ const ranked = Object.entries(result.scores).sort((a, b) => b[1] - a[1]);
135
+ for (const [label, score] of ranked) info(` ${label}: ${(score * 100).toFixed(1)}%`);
136
+ } else {
137
+ success(`Prediction: ${result.score}`);
138
+ }
139
+ } catch (e) {
140
+ error(e.message);
141
+ process.exitCode = 1;
142
+ }
143
+ }
package/src/index.js CHANGED
@@ -118,6 +118,19 @@ import {
118
118
  soulprintPublishCommand,
119
119
  soulprintModelsCommand,
120
120
  } from './commands/soulprint.js';
121
+ import {
122
+ datasetCreateCommand,
123
+ datasetListCommand,
124
+ datasetAppendCommand,
125
+ datasetShowCommand,
126
+ datasetFreezeCommand,
127
+ } from './commands/dataset.js';
128
+ import {
129
+ modelTrainCommand,
130
+ modelListCommand,
131
+ modelShowCommand,
132
+ modelPredictCommand,
133
+ } from './commands/model.js';
121
134
 
122
135
  const require = createRequire(import.meta.url);
123
136
  const pkg = require('../package.json');
@@ -833,5 +846,91 @@ export function run() {
833
846
  .option('--public', 'Make the model publicly available')
834
847
  .action(soulprintPublishCommand);
835
848
 
849
+ // ── Studio Lab: local training datasets ────────────────
850
+ // Raw data stays under ~/.myvillage/datasets/ (local-only). appendRows()
851
+ // in dataset-workspace.js is the single execution engine the dataset_append
852
+ // MCP tool will also wrap.
853
+
854
+ const datasetCmd = program
855
+ .command('dataset')
856
+ .description('Manage local training datasets for the Studio Lab (data stays on your machine)');
857
+
858
+ datasetCmd
859
+ .command('create')
860
+ .description('Create a new local dataset')
861
+ .requiredOption('--name <name>', 'Human-readable dataset name')
862
+ .requiredOption('--modality <modality>', 'text | image | audio | tabular')
863
+ .option('--task <task>', 'classification | regression', 'classification')
864
+ .option('--labels <list>', 'Comma-separated class labels (classification)')
865
+ .option('--description <text>', 'Optional description')
866
+ .option('--json', 'Output raw JSON')
867
+ .action(datasetCreateCommand);
868
+
869
+ datasetCmd
870
+ .command('list')
871
+ .description('List local datasets')
872
+ .option('--json', 'Output raw JSON')
873
+ .action(datasetListCommand);
874
+
875
+ datasetCmd
876
+ .command('append')
877
+ .description('Append labeled example(s) to a dataset')
878
+ .requiredOption('--dataset <slug>', 'Target dataset slug')
879
+ .option('--file <path>', 'JSONL file of rows ({features,label|target,split})')
880
+ .option('--text <text>', 'Single-row text features')
881
+ .option('--features <value>', 'Single-row features (string, or numeric vector for tabular)')
882
+ .option('--label <label>', 'Class label (classification)')
883
+ .option('--target <number>', 'Numeric target (regression)')
884
+ .option('--split <split>', 'train | val', 'train')
885
+ .option('--json', 'Output raw JSON')
886
+ .action(datasetAppendCommand);
887
+
888
+ datasetCmd
889
+ .command('show <slug>')
890
+ .description('Show a dataset manifest and a sample of rows')
891
+ .option('--limit <n>', 'Number of sample rows to show', '5')
892
+ .option('--json', 'Output raw JSON')
893
+ .action(datasetShowCommand);
894
+
895
+ datasetCmd
896
+ .command('freeze <slug>')
897
+ .description('Freeze the working set as the next immutable version')
898
+ .option('--changelog <text>', 'What changed in this version')
899
+ .option('--json', 'Output raw JSON')
900
+ .action(datasetFreezeCommand);
901
+
902
+ // ── Studio Lab: model training (local, TF.js CPU engine) ───────────────
903
+ const modelCmd = program
904
+ .command('model')
905
+ .description('Train and use local models for the Studio Lab (runs on your machine)');
906
+
907
+ modelCmd
908
+ .command('train')
909
+ .description('Train a model from a local dataset (tabular or text in v1)')
910
+ .requiredOption('--dataset <slug>', 'Dataset slug to train on')
911
+ .option('--name <name>', 'Name for the resulting model')
912
+ .option('--epochs <n>', 'Training epochs (default 40)')
913
+ .option('--json', 'Stream progress as JSON lines, then a final result object')
914
+ .action(modelTrainCommand);
915
+
916
+ modelCmd
917
+ .command('list')
918
+ .description('List local trained models')
919
+ .option('--json', 'Output raw JSON')
920
+ .action(modelListCommand);
921
+
922
+ modelCmd
923
+ .command('show <slug>')
924
+ .description('Show a model: task, metrics, training dataset')
925
+ .option('--json', 'Output raw JSON')
926
+ .action(modelShowCommand);
927
+
928
+ modelCmd
929
+ .command('predict <slug>')
930
+ .description('Run a prediction with a trained model')
931
+ .requiredOption('--input <value>', 'Input: text, or a comma-separated numeric vector for tabular')
932
+ .option('--json', 'Output raw JSON')
933
+ .action(modelPredictCommand);
934
+
836
935
  program.parse();
837
936
  }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Local dataset store for the Studio "Lab" (Data & Models) pillar.
3
+ *
4
+ * Raw training data is LOCAL-ONLY — it lives under ~/.myvillage/datasets/
5
+ * and never leaves the machine. Only metadata syncs to the backend (later).
6
+ *
7
+ * This module is the single execution engine for dataset writes: both the
8
+ * `myvillage dataset *` commands and the future `dataset_append` MCP server
9
+ * call `appendRows()` here, so the behavior can never fork.
10
+ *
11
+ * Shapes mirror @myvillage-studio/dataset-schema (the canonical source).
12
+ *
13
+ * Layout:
14
+ * ~/.myvillage/datasets/<slug>/
15
+ * manifest.json # name, modality, task, labels, versions, stats
16
+ * current.jsonl # working set (mutable until frozen)
17
+ * v1.jsonl, v2... # frozen snapshots
18
+ * assets/ # local image/audio files (referenced by rows)
19
+ */
20
+ import {
21
+ existsSync,
22
+ mkdirSync,
23
+ readFileSync,
24
+ writeFileSync,
25
+ appendFileSync,
26
+ readdirSync,
27
+ copyFileSync,
28
+ } from 'fs';
29
+ import { join } from 'path';
30
+ import { randomUUID } from 'crypto';
31
+ import { getConfigDir } from './config.js';
32
+
33
+ const DATASETS_DIR_NAME = 'datasets';
34
+ export const DATASET_SCHEMA_VERSION = '1.0.0';
35
+ export const MODALITIES = ['text', 'image', 'audio', 'tabular'];
36
+ export const TASK_TYPES = ['classification', 'regression'];
37
+ export const SPLITS = ['train', 'val'];
38
+
39
+ // ── Directory helpers ──────────────────────────────────
40
+
41
+ export function getDatasetsRootDir() {
42
+ const dir = join(getConfigDir(), DATASETS_DIR_NAME);
43
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
44
+ return dir;
45
+ }
46
+
47
+ export function getDatasetDir(slug) {
48
+ return join(getDatasetsRootDir(), slug);
49
+ }
50
+
51
+ export function getAssetsDir(slug) {
52
+ const dir = join(getDatasetDir(slug), 'assets');
53
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
54
+ return dir;
55
+ }
56
+
57
+ function getManifestPath(slug) {
58
+ return join(getDatasetDir(slug), 'manifest.json');
59
+ }
60
+
61
+ export function getCurrentDataFile(slug) {
62
+ return join(getDatasetDir(slug), 'current.jsonl');
63
+ }
64
+
65
+ export function getVersionFile(slug, version) {
66
+ return join(getDatasetDir(slug), `v${version}.jsonl`);
67
+ }
68
+
69
+ export function datasetExists(slug) {
70
+ return existsSync(getManifestPath(slug));
71
+ }
72
+
73
+ // ── Manifest read/write ────────────────────────────────
74
+
75
+ export function readManifest(slug) {
76
+ const p = getManifestPath(slug);
77
+ if (!existsSync(p)) return null;
78
+ try {
79
+ return JSON.parse(readFileSync(p, 'utf-8'));
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ function writeManifest(slug, manifest) {
86
+ manifest.updatedAt = nowISO();
87
+ writeFileSync(getManifestPath(slug), JSON.stringify(manifest, null, 2));
88
+ return manifest;
89
+ }
90
+
91
+ export function listDatasets() {
92
+ const root = getDatasetsRootDir();
93
+ return readdirSync(root, { withFileTypes: true })
94
+ .filter((e) => e.isDirectory())
95
+ .map((e) => readManifest(e.name))
96
+ .filter(Boolean);
97
+ }
98
+
99
+ // ── Create ─────────────────────────────────────────────
100
+
101
+ export function slugify(name) {
102
+ return String(name)
103
+ .toLowerCase()
104
+ .trim()
105
+ .replace(/[^a-z0-9]+/g, '-')
106
+ .replace(/^-+|-+$/g, '')
107
+ .slice(0, 64);
108
+ }
109
+
110
+ function nowISO() {
111
+ return new Date().toISOString();
112
+ }
113
+
114
+ function emptyStats() {
115
+ return { rowCount: 0, bySplit: { train: 0, val: 0 }, byLabel: {} };
116
+ }
117
+
118
+ /**
119
+ * Create a new local dataset. Returns the manifest.
120
+ * @param {{name:string, modality:string, taskType?:string, labels?:string[], description?:string}} opts
121
+ */
122
+ export function createDataset({ name, modality, taskType = 'classification', labels = [], description }) {
123
+ if (!name) throw new Error('A dataset name is required.');
124
+ if (!MODALITIES.includes(modality)) {
125
+ throw new Error(`modality must be one of: ${MODALITIES.join(', ')}`);
126
+ }
127
+ if (!TASK_TYPES.includes(taskType)) {
128
+ throw new Error(`taskType must be one of: ${TASK_TYPES.join(', ')}`);
129
+ }
130
+ const slug = slugify(name);
131
+ if (!slug) throw new Error('Could not derive a slug from the name.');
132
+ if (datasetExists(slug)) {
133
+ throw new Error(`A dataset with slug "${slug}" already exists.`);
134
+ }
135
+ mkdirSync(getDatasetDir(slug), { recursive: true });
136
+ // touch the working set
137
+ writeFileSync(getCurrentDataFile(slug), '');
138
+ const ts = nowISO();
139
+ const manifest = {
140
+ schemaVersion: DATASET_SCHEMA_VERSION,
141
+ id: randomUUID(),
142
+ slug,
143
+ name,
144
+ description: description || undefined,
145
+ modality,
146
+ taskType,
147
+ labels: [...labels],
148
+ splitConfig: { trainRatio: 0.8 },
149
+ currentVersion: 0,
150
+ versions: [],
151
+ stats: emptyStats(),
152
+ createdAt: ts,
153
+ updatedAt: ts,
154
+ };
155
+ return writeManifest(slug, manifest);
156
+ }
157
+
158
+ // ── Append (THE execution engine — CLI + future MCP both call this) ─────
159
+
160
+ /**
161
+ * Append rows to a dataset's working set, stamping provenance, validating
162
+ * against the dataset's modality/task, and updating manifest stats.
163
+ *
164
+ * @param {string} slug
165
+ * @param {Array<{features:any, label?:string, target?:number, split?:string}>} rows
166
+ * @param {object} provenance partial provenance ({ source, agentId, villagerId, villageId })
167
+ * @returns {{datasetId:string, appended:number, stats:object}}
168
+ */
169
+ export function appendRows(slug, rows, provenance = {}) {
170
+ const manifest = readManifest(slug);
171
+ if (!manifest) throw new Error(`Dataset "${slug}" not found.`);
172
+ if (!Array.isArray(rows) || rows.length === 0) {
173
+ throw new Error('No rows to append.');
174
+ }
175
+ const source = provenance.source || 'cli';
176
+ const lines = [];
177
+ let appended = 0;
178
+
179
+ for (const raw of rows) {
180
+ const row = normalizeRow(raw, manifest);
181
+ row.id = randomUUID();
182
+ row.provenance = {
183
+ source,
184
+ ...(provenance.agentId ? { agentId: provenance.agentId } : {}),
185
+ ...(provenance.villagerId ? { villagerId: provenance.villagerId } : {}),
186
+ ...(provenance.villageId ? { villageId: provenance.villageId } : {}),
187
+ ts: nowISO(),
188
+ };
189
+ lines.push(JSON.stringify(row));
190
+
191
+ // stats
192
+ manifest.stats.rowCount += 1;
193
+ manifest.stats.bySplit[row.split] += 1;
194
+ if (row.label != null) {
195
+ manifest.stats.byLabel[row.label] = (manifest.stats.byLabel[row.label] || 0) + 1;
196
+ if (!manifest.labels.includes(row.label)) manifest.labels.push(row.label);
197
+ }
198
+ appended += 1;
199
+ }
200
+
201
+ appendFileSync(getCurrentDataFile(slug), lines.join('\n') + '\n');
202
+ writeManifest(slug, manifest);
203
+ return { datasetId: slug, appended, stats: manifest.stats };
204
+ }
205
+
206
+ function normalizeRow(raw, manifest) {
207
+ if (raw == null || typeof raw !== 'object') {
208
+ throw new Error('Each row must be an object.');
209
+ }
210
+ const split = raw.split || 'train';
211
+ if (!SPLITS.includes(split)) {
212
+ throw new Error(`split must be one of: ${SPLITS.join(', ')}`);
213
+ }
214
+ if (raw.features === undefined || raw.features === null) {
215
+ throw new Error('Each row needs a "features" value.');
216
+ }
217
+ const row = { features: raw.features, split };
218
+ if (manifest.taskType === 'classification') {
219
+ if (raw.label == null || String(raw.label).length === 0) {
220
+ throw new Error('Classification rows require a "label".');
221
+ }
222
+ row.label = String(raw.label);
223
+ } else {
224
+ if (raw.target == null || Number.isNaN(Number(raw.target))) {
225
+ throw new Error('Regression rows require a numeric "target".');
226
+ }
227
+ row.target = Number(raw.target);
228
+ }
229
+ return row;
230
+ }
231
+
232
+ // ── Read ───────────────────────────────────────────────
233
+
234
+ /**
235
+ * Read rows from a dataset. Defaults to the working set (current.jsonl).
236
+ * @param {string} slug
237
+ * @param {{version?:number, split?:string, limit?:number}} opts
238
+ */
239
+ export function readRows(slug, { version, split, limit } = {}) {
240
+ if (!datasetExists(slug)) throw new Error(`Dataset "${slug}" not found.`);
241
+ const file = version ? getVersionFile(slug, version) : getCurrentDataFile(slug);
242
+ if (!existsSync(file)) return [];
243
+ const out = [];
244
+ for (const line of readFileSync(file, 'utf-8').split('\n')) {
245
+ if (!line.trim()) continue;
246
+ let row;
247
+ try {
248
+ row = JSON.parse(line);
249
+ } catch {
250
+ continue;
251
+ }
252
+ if (split && row.split !== split) continue;
253
+ out.push(row);
254
+ if (limit && out.length >= limit) break;
255
+ }
256
+ return out;
257
+ }
258
+
259
+ // ── Freeze a version ───────────────────────────────────
260
+
261
+ /**
262
+ * Snapshot the working set as the next immutable version.
263
+ * @returns {object} the new DatasetVersionMeta
264
+ */
265
+ export function freezeVersion(slug, changelog) {
266
+ const manifest = readManifest(slug);
267
+ if (!manifest) throw new Error(`Dataset "${slug}" not found.`);
268
+ if (manifest.stats.rowCount === 0) {
269
+ throw new Error('Cannot freeze an empty dataset.');
270
+ }
271
+ const version = (manifest.currentVersion || 0) + 1;
272
+ copyFileSync(getCurrentDataFile(slug), getVersionFile(slug, version));
273
+ const meta = {
274
+ version,
275
+ file: `v${version}.jsonl`,
276
+ rowCount: manifest.stats.rowCount,
277
+ frozenAt: nowISO(),
278
+ ...(changelog ? { changelog } : {}),
279
+ };
280
+ manifest.currentVersion = version;
281
+ manifest.versions.push(meta);
282
+ writeManifest(slug, manifest);
283
+ return meta;
284
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Local model trainer for the Studio "Lab" pillar.
3
+ *
4
+ * Runs IN NODE on TensorFlow.js (CPU backend) — no GPU, no native binary, no
5
+ * downloads — so it works headless from `myvillage model train` AND when the
6
+ * Studio shells out to that same command. This is the single training engine;
7
+ * the Studio is just a front-door that streams the progress it emits.
8
+ *
9
+ * v1 modalities (zero downloaded backbones):
10
+ * - tabular: numeric feature vectors → standardized → dense head
11
+ * - text: hashing vectorizer (pure JS) → dense head
12
+ * image/audio need a bundled vision/audio backbone and land in a later slice.
13
+ *
14
+ * Transfer-learning-style split: featurize once, then train a small head. The
15
+ * featurization params travel in the model record so predict() needs no data.
16
+ */
17
+ import * as tf from '@tensorflow/tfjs';
18
+ import { readRows } from './dataset-workspace.js';
19
+
20
+ const TEXT_DIM = 1024;
21
+ export const TRAINABLE_MODALITIES = ['tabular', 'text'];
22
+
23
+ // ── Featurization ──────────────────────────────────────
24
+
25
+ function tokenize(s) {
26
+ return String(s).toLowerCase().match(/[a-z0-9]+/g) || [];
27
+ }
28
+
29
+ // FNV-1a → bucket. Stable across runs/machines (no Math.random).
30
+ function hashToken(t) {
31
+ let h = 2166136261 >>> 0;
32
+ for (let i = 0; i < t.length; i++) {
33
+ h ^= t.charCodeAt(i);
34
+ h = Math.imul(h, 16777619) >>> 0;
35
+ }
36
+ return h >>> 0;
37
+ }
38
+
39
+ function vectorizeText(s) {
40
+ const v = new Array(TEXT_DIM).fill(0);
41
+ for (const tok of tokenize(s)) v[hashToken(tok) % TEXT_DIM] += 1;
42
+ let norm = 0;
43
+ for (let i = 0; i < TEXT_DIM; i++) norm += v[i] * v[i];
44
+ norm = Math.sqrt(norm);
45
+ if (norm > 0) for (let i = 0; i < TEXT_DIM; i++) v[i] /= norm;
46
+ return v;
47
+ }
48
+
49
+ function toNumericVector(features) {
50
+ if (Array.isArray(features)) return features.map(Number);
51
+ if (typeof features === 'string') {
52
+ return features.split(',').map((n) => Number(n.trim()));
53
+ }
54
+ throw new Error('Tabular features must be a numeric vector.');
55
+ }
56
+
57
+ /** Build the feature matrix + the featurization params to persist. */
58
+ function featurize(rows, modality) {
59
+ if (modality === 'text') {
60
+ return { X: rows.map((r) => vectorizeText(r.features)), featurization: { modality, dim: TEXT_DIM } };
61
+ }
62
+ // tabular: standardize each column (store mean/std for predict)
63
+ const raw = rows.map((r) => toNumericVector(r.features));
64
+ const dim = raw[0].length;
65
+ if (raw.some((v) => v.length !== dim)) {
66
+ throw new Error('All tabular rows must have the same number of features.');
67
+ }
68
+ const mean = new Array(dim).fill(0);
69
+ const std = new Array(dim).fill(0);
70
+ for (const v of raw) for (let j = 0; j < dim; j++) mean[j] += v[j];
71
+ for (let j = 0; j < dim; j++) mean[j] /= raw.length;
72
+ for (const v of raw) for (let j = 0; j < dim; j++) std[j] += (v[j] - mean[j]) ** 2;
73
+ for (let j = 0; j < dim; j++) std[j] = Math.sqrt(std[j] / raw.length) || 1;
74
+ const X = raw.map((v) => v.map((x, j) => (x - mean[j]) / std[j]));
75
+ return { X, featurization: { modality, dim, mean, std } };
76
+ }
77
+
78
+ /** Featurize a single input the same way (for predict). */
79
+ export function featurizeOne(input, featurization) {
80
+ if (featurization.modality === 'text') return vectorizeText(input);
81
+ const v = toNumericVector(input);
82
+ return v.map((x, j) => (x - featurization.mean[j]) / (featurization.std[j] || 1));
83
+ }
84
+
85
+ // ── Model ──────────────────────────────────────────────
86
+
87
+ function buildModel(inputDim, task, numClasses) {
88
+ const model = tf.sequential();
89
+ model.add(tf.layers.dense({ inputShape: [inputDim], units: 32, activation: 'relu' }));
90
+ model.add(tf.layers.dropout({ rate: 0.2 }));
91
+ if (task === 'classification') {
92
+ model.add(tf.layers.dense({ units: numClasses, activation: 'softmax' }));
93
+ // one-hot targets + categoricalCrossentropy (avoids int32/sparse dtype traps)
94
+ model.compile({ optimizer: tf.train.adam(0.01), loss: 'categoricalCrossentropy', metrics: ['accuracy'] });
95
+ } else {
96
+ model.add(tf.layers.dense({ units: 1 }));
97
+ model.compile({ optimizer: tf.train.adam(0.01), loss: 'meanSquaredError', metrics: ['mae'] });
98
+ }
99
+ return model;
100
+ }
101
+
102
+ // ── Train ──────────────────────────────────────────────
103
+
104
+ /**
105
+ * Train a model from a local dataset's working set.
106
+ * @returns {{ model: tf.LayersModel, record: object }} record holds everything
107
+ * predict() needs (featurization, labels/target stats, metrics).
108
+ */
109
+ export async function trainModel(slug, manifest, { epochs = 40, onProgress } = {}) {
110
+ await tf.setBackend('cpu');
111
+ await tf.ready();
112
+
113
+ if (!TRAINABLE_MODALITIES.includes(manifest.modality)) {
114
+ throw new Error(
115
+ `Training for "${manifest.modality}" datasets is coming soon — v1 supports ${TRAINABLE_MODALITIES.join(' and ')}.`,
116
+ );
117
+ }
118
+ const rows = readRows(slug, {});
119
+ if (rows.length < 4) throw new Error('Need at least 4 examples to train (more is better).');
120
+
121
+ const task = manifest.taskType;
122
+ const { X, featurization } = featurize(rows, manifest.modality);
123
+ const inputDim = X[0].length;
124
+
125
+ // Build targets: one-hot rows (classification) or standardized scalars (regression)
126
+ let labels = [], targetMean = null, targetStd = null, numClasses = 0;
127
+ let Y;
128
+ if (task === 'classification') {
129
+ labels = [...new Set(rows.map((r) => String(r.label)))].sort();
130
+ if (labels.length < 2) throw new Error('Classification needs at least 2 distinct labels.');
131
+ const idx = new Map(labels.map((l, i) => [l, i]));
132
+ numClasses = labels.length;
133
+ Y = rows.map((r) => {
134
+ const o = new Array(numClasses).fill(0);
135
+ o[idx.get(String(r.label))] = 1;
136
+ return o;
137
+ });
138
+ } else {
139
+ const t = rows.map((r) => Number(r.target));
140
+ targetMean = t.reduce((a, b) => a + b, 0) / t.length;
141
+ targetStd = Math.sqrt(t.reduce((a, b) => a + (b - targetMean) ** 2, 0) / t.length) || 1;
142
+ Y = t.map((v) => (v - targetMean) / targetStd);
143
+ }
144
+
145
+ // train/val split in plain JS (explicit val rows, else hold out the last ~20%)
146
+ const explicitVal = rows.map((r, i) => (r.split === 'val' ? i : -1)).filter((i) => i >= 0);
147
+ let valSet;
148
+ if (explicitVal.length >= 2 && explicitVal.length < rows.length) {
149
+ valSet = new Set(explicitVal);
150
+ } else {
151
+ const n = Math.max(1, Math.floor(rows.length * 0.2));
152
+ valSet = new Set(rows.map((_, i) => i).slice(rows.length - n));
153
+ }
154
+ const trI = [], vaI = [];
155
+ rows.forEach((_, i) => (valSet.has(i) ? vaI : trI).push(i));
156
+ if (trI.length === 0) { trI.push(...vaI); vaI.length = 0; }
157
+
158
+ const pick = (arr, idxs) => idxs.map((i) => arr[i]);
159
+ const mkY = (vals) => (task === 'classification' ? tf.tensor2d(vals) : tf.tensor1d(vals));
160
+ const trainXs = tf.tensor2d(pick(X, trI));
161
+ const trainYs = mkY(pick(Y, trI));
162
+ const valXs = vaI.length ? tf.tensor2d(pick(X, vaI)) : null;
163
+ const valYs = vaI.length ? mkY(pick(Y, vaI)) : null;
164
+
165
+ const model = buildModel(inputDim, task, numClasses);
166
+ const fitConfig = {
167
+ epochs,
168
+ shuffle: true,
169
+ callbacks: {
170
+ onEpochEnd: (epoch, logs) => {
171
+ onProgress?.({
172
+ epoch: epoch + 1,
173
+ totalEpochs: epochs,
174
+ loss: round(logs.loss),
175
+ valLoss: round(logs.val_loss),
176
+ accuracy: round(logs.acc ?? logs.accuracy),
177
+ valAccuracy: round(logs.val_acc ?? logs.val_accuracy),
178
+ mae: round(logs.mae),
179
+ valMae: round(logs.val_mae),
180
+ });
181
+ },
182
+ },
183
+ };
184
+ if (valXs) fitConfig.validationData = [valXs, valYs];
185
+ await model.fit(trainXs, trainYs, fitConfig);
186
+
187
+ // Metrics over the eval set (val if we have one, else the training set).
188
+ const evalXs = valXs || trainXs;
189
+ const evalYs = valYs || trainYs;
190
+ const metrics = computeMetrics(model, evalXs, evalYs, task, labels, targetMean, targetStd);
191
+ trainXs.dispose(); trainYs.dispose();
192
+ if (valXs) { valXs.dispose(); valYs.dispose(); }
193
+
194
+ const record = {
195
+ modality: manifest.modality,
196
+ taskType: task,
197
+ inputDim,
198
+ featurization,
199
+ labels: labels ?? [],
200
+ targetMean: targetMean ?? null,
201
+ targetStd: targetStd ?? null,
202
+ metrics,
203
+ hyperparams: { epochs, optimizer: 'adam', learningRate: 0.01 },
204
+ examplesUsed: rows.length,
205
+ };
206
+ return { model, record };
207
+ }
208
+
209
+ function computeMetrics(model, xs, ys, task, labels, targetMean, targetStd) {
210
+ return tf.tidy(() => {
211
+ const pred = model.predict(xs);
212
+ if (task === 'classification') {
213
+ const yhat = pred.argMax(1);
214
+ const ytrue = ys.argMax(1); // ys is one-hot
215
+ const acc = yhat.equal(ytrue).mean().arraySync();
216
+ return { accuracy: round(acc), classes: labels.length };
217
+ }
218
+ // regression: de-standardize, compute MAE + R²
219
+ const yhat = pred.flatten().mul(targetStd).add(targetMean);
220
+ const ytrue = ys.mul(targetStd).add(targetMean);
221
+ const mae = yhat.sub(ytrue).abs().mean().arraySync();
222
+ const ssRes = yhat.sub(ytrue).square().sum().arraySync();
223
+ const yMean = ytrue.mean().arraySync();
224
+ const ssTot = ytrue.sub(yMean).square().sum().arraySync();
225
+ return { mae: round(mae), r2: round(ssTot > 0 ? 1 - ssRes / ssTot : 0) };
226
+ });
227
+ }
228
+
229
+ /** Run a prediction with an already-loaded model + its record. */
230
+ export function predictWithModel(model, record, input) {
231
+ return tf.tidy(() => {
232
+ const feats = featurizeOne(input, record.featurization);
233
+ const out = model.predict(tf.tensor2d([feats]));
234
+ if (record.taskType === 'classification') {
235
+ const scores = Array.from(out.dataSync());
236
+ let best = 0;
237
+ for (let i = 1; i < scores.length; i++) if (scores[i] > scores[best]) best = i;
238
+ const byLabel = {};
239
+ record.labels.forEach((l, i) => (byLabel[l] = round(scores[i])));
240
+ return { label: record.labels[best], scores: byLabel };
241
+ }
242
+ const z = out.dataSync()[0];
243
+ return { score: round(z * record.targetStd + record.targetMean) };
244
+ });
245
+ }
246
+
247
+ function round(n) {
248
+ return n == null || Number.isNaN(n) ? undefined : Math.round(n * 1e4) / 1e4;
249
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Local trained-model store for the Studio "Lab" pillar.
3
+ *
4
+ * Models live under ~/.myvillage/models/<slug>/ — local-only, like datasets.
5
+ * manifest.json # TrainedModelRecord (modality, task, metrics, featurization…)
6
+ * model.json # tfjs topology + weight specs
7
+ * weights.b64 # base64 weight data
8
+ *
9
+ * Save/load go through TF.js's in-memory IO handlers (no tfjs-node needed),
10
+ * so this stays pure-JS and matches the trainer's CPU runtime.
11
+ */
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'fs';
13
+ import { join } from 'path';
14
+ import { randomUUID } from 'crypto';
15
+ import * as tf from '@tensorflow/tfjs';
16
+ import { getConfigDir } from './config.js';
17
+ import { slugify } from './dataset-workspace.js';
18
+
19
+ const MODELS_DIR_NAME = 'models';
20
+ export const MODEL_SCHEMA_VERSION = '1.0.0';
21
+
22
+ export function getModelsRootDir() {
23
+ const dir = join(getConfigDir(), MODELS_DIR_NAME);
24
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
25
+ return dir;
26
+ }
27
+
28
+ export function getModelDir(slug) {
29
+ return join(getModelsRootDir(), slug);
30
+ }
31
+
32
+ function manifestPath(slug) {
33
+ return join(getModelDir(slug), 'manifest.json');
34
+ }
35
+
36
+ export function modelExists(slug) {
37
+ return existsSync(manifestPath(slug));
38
+ }
39
+
40
+ export function readModelManifest(slug) {
41
+ const p = manifestPath(slug);
42
+ if (!existsSync(p)) return null;
43
+ try {
44
+ return JSON.parse(readFileSync(p, 'utf-8'));
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ export function listModels() {
51
+ const root = getModelsRootDir();
52
+ return readdirSync(root, { withFileTypes: true })
53
+ .filter((e) => e.isDirectory())
54
+ .map((e) => readModelManifest(e.name))
55
+ .filter(Boolean);
56
+ }
57
+
58
+ export function deleteModel(slug) {
59
+ rmSync(getModelDir(slug), { recursive: true, force: true });
60
+ }
61
+
62
+ /**
63
+ * Persist a trained model + its record.
64
+ * @param {string} name human-readable model name (slug derived from it)
65
+ * @param {object} opts { model: tf.LayersModel, record, datasetSlug, datasetVersion }
66
+ * @returns {object} the saved manifest
67
+ */
68
+ export async function saveModel(name, { model, record, datasetSlug, datasetVersion }) {
69
+ const slug = slugify(name);
70
+ if (!slug) throw new Error('Could not derive a slug from the model name.');
71
+ const dir = getModelDir(slug);
72
+ mkdirSync(dir, { recursive: true });
73
+
74
+ let modelTopology, weightSpecs, weightB64;
75
+ await model.save(
76
+ tf.io.withSaveHandler(async (artifacts) => {
77
+ modelTopology = artifacts.modelTopology;
78
+ weightSpecs = artifacts.weightSpecs;
79
+ weightB64 = Buffer.from(artifacts.weightData).toString('base64');
80
+ return { modelArtifactsInfo: { dateSaved: new Date(), modelTopologyType: 'JSON' } };
81
+ }),
82
+ );
83
+ writeFileSync(join(dir, 'model.json'), JSON.stringify({ modelTopology, weightSpecs }));
84
+ writeFileSync(join(dir, 'weights.b64'), weightB64);
85
+
86
+ const ts = new Date().toISOString();
87
+ const manifest = {
88
+ schemaVersion: MODEL_SCHEMA_VERSION,
89
+ id: randomUUID(),
90
+ slug,
91
+ name,
92
+ datasetSlug,
93
+ datasetVersion: datasetVersion ?? 0,
94
+ format: 'tfjs',
95
+ artifacts: ['model.json', 'weights.b64'],
96
+ ...record,
97
+ status: 'ready',
98
+ createdAt: ts,
99
+ updatedAt: ts,
100
+ };
101
+ writeFileSync(manifestPath(slug), JSON.stringify(manifest, null, 2));
102
+ return manifest;
103
+ }
104
+
105
+ /** Load a saved tf model back into memory (for predict). */
106
+ export async function loadModel(slug) {
107
+ const dir = getModelDir(slug);
108
+ if (!existsSync(join(dir, 'model.json'))) throw new Error(`Model "${slug}" not found.`);
109
+ const { modelTopology, weightSpecs } = JSON.parse(readFileSync(join(dir, 'model.json'), 'utf-8'));
110
+ const buf = Buffer.from(readFileSync(join(dir, 'weights.b64'), 'utf-8'), 'base64');
111
+ const weightData = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
112
+ return tf.loadLayersModel(tf.io.fromMemory({ modelTopology, weightSpecs, weightData }));
113
+ }