@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 +1 -1
- package/src/agent-runtime/builtin-tools.js +77 -0
- package/src/agent-runtime/loop.js +5 -0
- package/src/commands/dataset.js +198 -0
- package/src/index.js +60 -0
- package/src/utils/dataset-workspace.js +284 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|