@myvillage/cli 1.66.0 → 1.68.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.68.0",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
+ }
package/src/index.js CHANGED
@@ -118,6 +118,13 @@ 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';
121
128
 
122
129
  const require = createRequire(import.meta.url);
123
130
  const pkg = require('../package.json');
@@ -833,5 +840,58 @@ export function run() {
833
840
  .option('--public', 'Make the model publicly available')
834
841
  .action(soulprintPublishCommand);
835
842
 
843
+ // ── Studio Lab: local training datasets ────────────────
844
+ // Raw data stays under ~/.myvillage/datasets/ (local-only). appendRows()
845
+ // in dataset-workspace.js is the single execution engine the dataset_append
846
+ // MCP tool will also wrap.
847
+
848
+ const datasetCmd = program
849
+ .command('dataset')
850
+ .description('Manage local training datasets for the Studio Lab (data stays on your machine)');
851
+
852
+ datasetCmd
853
+ .command('create')
854
+ .description('Create a new local dataset')
855
+ .requiredOption('--name <name>', 'Human-readable dataset name')
856
+ .requiredOption('--modality <modality>', 'text | image | audio | tabular')
857
+ .option('--task <task>', 'classification | regression', 'classification')
858
+ .option('--labels <list>', 'Comma-separated class labels (classification)')
859
+ .option('--description <text>', 'Optional description')
860
+ .option('--json', 'Output raw JSON')
861
+ .action(datasetCreateCommand);
862
+
863
+ datasetCmd
864
+ .command('list')
865
+ .description('List local datasets')
866
+ .option('--json', 'Output raw JSON')
867
+ .action(datasetListCommand);
868
+
869
+ datasetCmd
870
+ .command('append')
871
+ .description('Append labeled example(s) to a dataset')
872
+ .requiredOption('--dataset <slug>', 'Target dataset slug')
873
+ .option('--file <path>', 'JSONL file of rows ({features,label|target,split})')
874
+ .option('--text <text>', 'Single-row text features')
875
+ .option('--features <value>', 'Single-row features (string, or numeric vector for tabular)')
876
+ .option('--label <label>', 'Class label (classification)')
877
+ .option('--target <number>', 'Numeric target (regression)')
878
+ .option('--split <split>', 'train | val', 'train')
879
+ .option('--json', 'Output raw JSON')
880
+ .action(datasetAppendCommand);
881
+
882
+ datasetCmd
883
+ .command('show <slug>')
884
+ .description('Show a dataset manifest and a sample of rows')
885
+ .option('--limit <n>', 'Number of sample rows to show', '5')
886
+ .option('--json', 'Output raw JSON')
887
+ .action(datasetShowCommand);
888
+
889
+ datasetCmd
890
+ .command('freeze <slug>')
891
+ .description('Freeze the working set as the next immutable version')
892
+ .option('--changelog <text>', 'What changed in this version')
893
+ .option('--json', 'Output raw JSON')
894
+ .action(datasetFreezeCommand);
895
+
836
896
  program.parse();
837
897
  }
@@ -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
+ }