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