@myvillage/cli 1.71.0 → 1.73.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.71.0",
3
+ "version": "1.73.0",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,6 +13,7 @@
13
13
  "bin/",
14
14
  "src/",
15
15
  "tools/",
16
+ "vendor/",
16
17
  "README.md"
17
18
  ],
18
19
  "keywords": [
@@ -37,9 +38,11 @@
37
38
  "commander": "^11.1.0",
38
39
  "conf": "^12.0.0",
39
40
  "inquirer": "^9.2.12",
41
+ "jpeg-js": "^0.4.4",
40
42
  "open": "^10.0.3",
41
43
  "ora": "^8.0.1",
42
44
  "p-limit": "^5.0.0",
45
+ "pngjs": "^7.0.0",
43
46
  "tar": "^7.5.16",
44
47
  "update-notifier": "^7.0.0",
45
48
  "yaml": "^2.3.0",
@@ -14,13 +14,16 @@
14
14
  export const DATASET_APPEND_DESCRIPTION =
15
15
  'Append labeled training example(s) to a LOCAL dataset on this machine. ' +
16
16
  'The dataset must already exist (created via the Studio Lab or ' +
17
- '`myvillage dataset create`). For classification provide `label`; for ' +
17
+ '`myvillage dataset create`). `features` is text, a numeric vector ' +
18
+ '(tabular), or a local image file path (image datasets — the file is ' +
19
+ 'copied into the dataset). For classification provide `label`; for ' +
18
20
  'regression provide a numeric `target`. Data stays local; provenance ' +
19
21
  'is recorded automatically.';
20
22
 
21
23
  export const MODEL_PREDICT_DESCRIPTION =
22
- 'Run a prediction with a LOCAL trained model on this machine. ' +
23
- 'Returns the predicted class with per-class confidence scores ' +
24
+ 'Run a prediction with a LOCAL trained model on this machine. `input` is ' +
25
+ 'text, a numeric vector (tabular), or a local image file path (image ' +
26
+ 'models). Returns the predicted class with per-class confidence scores ' +
24
27
  '(classification) or a numeric value (regression). The model must already ' +
25
28
  'exist (trained in the Studio Lab or via `myvillage model train`).';
26
29
 
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { tool } from 'ai';
16
16
  import { z } from 'zod';
17
- import { appendRows, datasetExists } from '../utils/dataset-workspace.js';
17
+ import { appendRows, datasetExists, readManifest, ingestAsset } from '../utils/dataset-workspace.js';
18
18
  import { DATASET_APPEND_DESCRIPTION, MODEL_PREDICT_DESCRIPTION } from './builtin-tools-registry.js';
19
19
 
20
20
  export { BUILTIN_TOOL_DEFS } from './builtin-tools-registry.js';
@@ -36,7 +36,10 @@ export function getBuiltinTools(agentName, config) {
36
36
  z.object({
37
37
  features: z
38
38
  .union([z.string(), z.array(z.number())])
39
- .describe('Text content, or a numeric vector for tabular data'),
39
+ .describe(
40
+ 'Text content; a numeric vector for tabular data; or, for image ' +
41
+ 'datasets, an absolute path to a local image file (.jpg/.png).',
42
+ ),
40
43
  label: z.string().optional().describe('Class label (classification datasets)'),
41
44
  target: z.number().optional().describe('Numeric target (regression datasets)'),
42
45
  split: z.enum(['train', 'val']).optional(),
@@ -57,7 +60,19 @@ export function getBuiltinTools(agentName, config) {
57
60
  const provenance = { source: `agent:${agentName}` };
58
61
  const agentId = config?.man?.village_agent_id;
59
62
  if (agentId) provenance.agentId = agentId;
60
- const result = appendRows(datasetId, rows, provenance);
63
+ // For image datasets, the agent passes a file path; ingest each into
64
+ // the dataset's assets/ and swap in the resulting AssetRef before the
65
+ // shared appendRows() runs (same path the CLI --image flow takes).
66
+ const manifest = readManifest(datasetId);
67
+ let toAppend = rows;
68
+ if (manifest?.modality === 'image') {
69
+ toAppend = rows.map((r) => {
70
+ const path = typeof r.features === 'string' ? r.features : r.features?.path;
71
+ if (!path) throw new Error('Image rows need a local image file path in "features".');
72
+ return { ...r, features: ingestAsset(datasetId, path, 'image') };
73
+ });
74
+ }
75
+ const result = appendRows(datasetId, toAppend, provenance);
61
76
  return {
62
77
  ok: true,
63
78
  datasetId: result.datasetId,
@@ -78,7 +93,10 @@ export function getBuiltinTools(agentName, config) {
78
93
  modelId: z.string().describe('Slug of the local trained model'),
79
94
  input: z
80
95
  .union([z.string(), z.array(z.number())])
81
- .describe('Text, or a numeric vector for tabular models'),
96
+ .describe(
97
+ 'Text; a numeric vector for tabular models; or, for image models, ' +
98
+ 'an absolute path to a local image file (.jpg/.png).',
99
+ ),
82
100
  }),
83
101
  execute: async ({ modelId, input }) => {
84
102
  try {
@@ -3,6 +3,8 @@
3
3
  // This becomes the user message sent to the LLM.
4
4
 
5
5
  import { getAgentFeed, getAgentMentions, getAgentEvents } from '../utils/api.js';
6
+ import { listDatasets } from '../utils/dataset-workspace.js';
7
+ import { listModels } from '../utils/model-workspace.js';
6
8
 
7
9
  export async function gatherContext(agentConfig, lastCheckIn, recentActions = []) {
8
10
  const parts = [];
@@ -110,6 +112,33 @@ export async function gatherContext(agentConfig, lastCheckIn, recentActions = []
110
112
  }
111
113
  }
112
114
 
115
+ // Local Lab datasets & models, so the agent knows what it can append to /
116
+ // predict with (and the exact slugs to pass to dataset_append / model_predict).
117
+ // Local-only, synchronous file reads; tfjs is NOT loaded by listing models.
118
+ try {
119
+ const datasets = listDatasets();
120
+ if (datasets.length > 0) {
121
+ parts.push('\nLocal datasets you can add to with dataset_append (use the slug as datasetId):');
122
+ for (const d of datasets.slice(0, 25)) {
123
+ parts.push(`- ${d.slug} — ${d.modality}/${d.taskType}, ${d.stats?.rowCount ?? 0} rows`);
124
+ }
125
+ }
126
+ } catch {
127
+ /* no local datasets / store unavailable — skip */
128
+ }
129
+ try {
130
+ const models = listModels();
131
+ if (models.length > 0) {
132
+ parts.push('\nLocal models you can run with model_predict (use the slug as modelId):');
133
+ for (const m of models.slice(0, 25)) {
134
+ const labels = m.labels?.length ? ` → ${m.labels.join('/')}` : '';
135
+ parts.push(`- ${m.slug} — ${m.modality}/${m.taskType}${labels}`);
136
+ }
137
+ }
138
+ } catch {
139
+ /* no local models / store unavailable — skip */
140
+ }
141
+
113
142
  parts.push('\nDecide what actions to take based on your personality and the current context.');
114
143
  parts.push('IMPORTANT: If you already commented on a post, only comment again if someone ELSE has replied since your last comment. Do not leave multiple unanswered comments on the same post.');
115
144
  parts.push('If there is no new activity and no one has responded to your previous comments, do nothing — that is a valid choice.');
@@ -16,6 +16,7 @@ import {
16
16
  readManifest,
17
17
  datasetExists,
18
18
  appendRows,
19
+ ingestAsset,
19
20
  readRows,
20
21
  freezeVersion,
21
22
  } from '../utils/dataset-workspace.js';
@@ -83,8 +84,20 @@ export async function datasetAppendCommand(options) {
83
84
  if (!slug) throw new Error('--dataset <slug> is required.');
84
85
  if (!datasetExists(slug)) throw new Error(`Dataset "${slug}" not found.`);
85
86
 
87
+ const manifest = readManifest(slug);
88
+
86
89
  let rows;
87
- if (options.file) {
90
+ if (options.image) {
91
+ // Copy the image into the dataset's assets/ and reference it by AssetRef.
92
+ if (manifest.modality !== 'image') {
93
+ throw new Error(`--image is only valid for image datasets (this one is "${manifest.modality}").`);
94
+ }
95
+ const asset = ingestAsset(slug, options.image, 'image');
96
+ const row = { split: options.split || 'train', features: asset };
97
+ if (options.label !== undefined) row.label = options.label;
98
+ if (options.target !== undefined) row.target = Number(options.target);
99
+ rows = [row];
100
+ } else if (options.file) {
88
101
  rows = readRowsFromFile(options.file);
89
102
  } else if (options.text !== undefined || options.features !== undefined) {
90
103
  const row = { split: options.split || 'train' };
@@ -93,7 +106,7 @@ export async function datasetAppendCommand(options) {
93
106
  if (options.target !== undefined) row.target = Number(options.target);
94
107
  rows = [row];
95
108
  } else {
96
- throw new Error('Provide either --file <jsonl> or --text/--features (with --label or --target).');
109
+ throw new Error('Provide --image <path> (image datasets), --file <jsonl>, or --text/--features (with --label or --target).');
97
110
  }
98
111
 
99
112
  const spinner = options.json ? null : villageSpinner('Appending rows...').start();
@@ -132,7 +145,10 @@ export async function datasetShowCommand(slug, options) {
132
145
  if (sample.length) {
133
146
  console.log(brand.cream(`\n sample (${sample.length}):`));
134
147
  for (const r of sample) {
135
- const feat = typeof r.features === 'string' ? truncate(r.features, 50) : JSON.stringify(r.features);
148
+ const feat =
149
+ typeof r.features === 'string' ? truncate(r.features, 50)
150
+ : r.features?.path ? `📎 ${r.features.path}`
151
+ : JSON.stringify(r.features);
136
152
  const tgt = r.label != null ? `label=${r.label}` : `target=${r.target}`;
137
153
  console.log(` [${r.split}] ${tgt} ${brand.teal(feat)}`);
138
154
  }
@@ -57,7 +57,8 @@ export async function modelTrainCommand(options) {
57
57
  const m = record.metrics;
58
58
  if (m.accuracy != null) info(` accuracy: ${(m.accuracy * 100).toFixed(1)}% over ${m.classes} classes`);
59
59
  if (m.mae != null) info(` MAE: ${m.mae} · R²: ${m.r2}`);
60
- info(` test it: myvillage model predict ${manifest.slug} --input "..."`);
60
+ const testFlag = manifest.modality === 'image' ? '--image <path>' : '--input "..."';
61
+ info(` test it: myvillage model predict ${manifest.slug} ${testFlag}`);
61
62
  } catch (e) {
62
63
  if (options.json) process.stdout.write(JSON.stringify({ type: 'error', error: e.message }) + '\n');
63
64
  else error(e.message);
@@ -121,10 +122,15 @@ export async function modelShowCommand(slug, options) {
121
122
  export async function modelPredictCommand(slug, options) {
122
123
  try {
123
124
  if (!modelExists(slug)) throw new Error(`Model "${slug}" not found.`);
124
- if (options.input === undefined) throw new Error('--input <value> is required.');
125
125
  const record = readModelManifest(slug);
126
+ // Image models take a file path (--image); text/tabular take --input.
127
+ const isImage = record.modality === 'image';
128
+ const input = isImage ? options.image : options.input;
129
+ if (input === undefined) {
130
+ throw new Error(isImage ? '--image <path> is required for image models.' : '--input <value> is required.');
131
+ }
126
132
  const model = await loadModel(slug);
127
- const result = predictWithModel(model, record, options.input);
133
+ const result = await predictWithModel(model, record, input);
128
134
  if (options.json) {
129
135
  console.log(JSON.stringify(result, null, 2));
130
136
  return;
package/src/index.js CHANGED
@@ -877,6 +877,7 @@ export function run() {
877
877
  .description('Append labeled example(s) to a dataset')
878
878
  .requiredOption('--dataset <slug>', 'Target dataset slug')
879
879
  .option('--file <path>', 'JSONL file of rows ({features,label|target,split})')
880
+ .option('--image <path>', 'Image file to add (image datasets); copied into the dataset')
880
881
  .option('--text <text>', 'Single-row text features')
881
882
  .option('--features <value>', 'Single-row features (string, or numeric vector for tabular)')
882
883
  .option('--label <label>', 'Class label (classification)')
@@ -928,7 +929,8 @@ export function run() {
928
929
  modelCmd
929
930
  .command('predict <slug>')
930
931
  .description('Run a prediction with a trained model')
931
- .requiredOption('--input <value>', 'Input: text, or a comma-separated numeric vector for tabular')
932
+ .option('--input <value>', 'Input: text, or a comma-separated numeric vector for tabular')
933
+ .option('--image <path>', 'Image file to classify (image models)')
932
934
  .option('--json', 'Output raw JSON')
933
935
  .action(modelPredictCommand);
934
936
 
@@ -25,8 +25,9 @@ import {
25
25
  appendFileSync,
26
26
  readdirSync,
27
27
  copyFileSync,
28
+ statSync,
28
29
  } from 'fs';
29
- import { join } from 'path';
30
+ import { join, extname, basename } from 'path';
30
31
  import { randomUUID } from 'crypto';
31
32
  import { getConfigDir } from './config.js';
32
33
 
@@ -54,6 +55,46 @@ export function getAssetsDir(slug) {
54
55
  return dir;
55
56
  }
56
57
 
58
+ /** Resolve an AssetRef's dataset-relative path to an absolute path on disk. */
59
+ export function getAssetPath(slug, relPath) {
60
+ return join(getAssetsDir(slug), relPath);
61
+ }
62
+
63
+ const IMAGE_MIME = {
64
+ '.jpg': 'image/jpeg',
65
+ '.jpeg': 'image/jpeg',
66
+ '.png': 'image/png',
67
+ };
68
+ const AUDIO_MIME = { '.wav': 'audio/wav' };
69
+
70
+ /**
71
+ * Copy a media file into a dataset's assets/ dir and return an AssetRef.
72
+ * Raw media stays local (never leaves the machine) — this is the on-disk side
73
+ * of image/audio collection. The stored path is relative to assets/ so the
74
+ * dataset dir stays portable.
75
+ *
76
+ * @param {string} slug
77
+ * @param {string} srcPath absolute path to the source media file
78
+ * @param {'image'|'audio'} kind
79
+ * @returns {{kind:string, path:string, mime?:string, bytes:number}}
80
+ */
81
+ export function ingestAsset(slug, srcPath, kind) {
82
+ if (!existsSync(srcPath)) throw new Error(`File not found: ${srcPath}`);
83
+ const ext = extname(srcPath).toLowerCase();
84
+ const mimeTable = kind === 'audio' ? AUDIO_MIME : IMAGE_MIME;
85
+ const mime = mimeTable[ext];
86
+ if (!mime) {
87
+ const ok = Object.keys(mimeTable).join(', ');
88
+ throw new Error(`Unsupported ${kind} type "${ext}". Supported: ${ok}.`);
89
+ }
90
+ // Unique, collision-proof filename; keep a hint of the original name.
91
+ const stem = basename(srcPath, ext).replace(/[^a-z0-9_-]+/gi, '-').slice(0, 32) || kind;
92
+ const fileName = `${stem}-${randomUUID().slice(0, 8)}${ext}`;
93
+ const destAbs = join(getAssetsDir(slug), fileName);
94
+ copyFileSync(srcPath, destAbs);
95
+ return { kind, path: fileName, mime, bytes: statSync(destAbs).size };
96
+ }
97
+
57
98
  function getManifestPath(slug) {
58
99
  return join(getDatasetDir(slug), 'manifest.json');
59
100
  }
@@ -214,6 +255,17 @@ function normalizeRow(raw, manifest) {
214
255
  if (raw.features === undefined || raw.features === null) {
215
256
  throw new Error('Each row needs a "features" value.');
216
257
  }
258
+ // image/audio rows carry an AssetRef {kind, path}; the caller ingests the
259
+ // file (see ingestAsset) before appending, so by here features is the ref.
260
+ if (manifest.modality === 'image' || manifest.modality === 'audio') {
261
+ const f = raw.features;
262
+ if (typeof f !== 'object' || !f.path) {
263
+ throw new Error(
264
+ `${manifest.modality} rows need an ingested asset reference {kind, path}. ` +
265
+ 'Append with --image <path> (CLI) or pass an image path to the tool.',
266
+ );
267
+ }
268
+ }
217
269
  const row = { features: raw.features, split };
218
270
  if (manifest.taskType === 'classification') {
219
271
  if (raw.label == null || String(raw.label).length === 0) {
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Image feature-extractor for the Studio "Lab" pillar.
3
+ *
4
+ * Turns an image file into a fixed-length embedding using a MobileNetV3-Small
5
+ * backbone that is **bundled inside this package** (vendor/backbones/) and
6
+ * loaded from disk — never downloaded — so the Studio stays "the only thing you
7
+ * install" (see ed-platform docs/architecture/studio-models-pillar.md §0b, §13).
8
+ *
9
+ * Pipeline: decode (pure-JS jpeg-js / pngjs, no native binary) → RGB tensor →
10
+ * resize 224×224 → normalize to [0,1] → MobileNetV3-Small → 1024-d embedding.
11
+ *
12
+ * The embedding then feeds the SAME dense head the text/tabular trainer uses;
13
+ * image only adds this featurization step. tfjs is imported lazily so listing
14
+ * datasets/models never pulls the heavy runtime in.
15
+ */
16
+ import { readFileSync, existsSync } from 'fs';
17
+ import { join, dirname, extname } from 'path';
18
+ import { fileURLToPath } from 'url';
19
+ import { createRequire } from 'module';
20
+
21
+ // CommonJS interop for the pure-JS decoders (this module is ESM).
22
+ const _require = createRequire(import.meta.url);
23
+ function requirePkg(name) {
24
+ return _require(name);
25
+ }
26
+
27
+ export const BACKBONE_ID = 'mobilenet-v3-small';
28
+ export const INPUT_SIZE = 224;
29
+ export const EMBED_DIM = 1024;
30
+
31
+ const BACKBONE_DIR = join(
32
+ dirname(fileURLToPath(import.meta.url)),
33
+ '..',
34
+ '..',
35
+ 'vendor',
36
+ 'backbones',
37
+ BACKBONE_ID,
38
+ );
39
+
40
+ let _backbonePromise = null;
41
+
42
+ /**
43
+ * A tf.io.IOHandler that reads the vendored GraphModel (model.json + weight
44
+ * shards) straight off disk. We can't use tf.loadGraphModel('file://…') because
45
+ * the bundled pure-JS @tensorflow/tfjs has no file:// handler (that lives in
46
+ * tfjs-node); reading the artifacts ourselves keeps it native-binary-free —
47
+ * the same trick model-workspace.js uses for saved models.
48
+ */
49
+ function diskGraphHandler(dir) {
50
+ return {
51
+ load: async () => {
52
+ const manifestPath = join(dir, 'model.json');
53
+ if (!existsSync(manifestPath)) {
54
+ throw new Error(
55
+ `Bundled image backbone not found at ${dir}. The MobileNet weights ship ` +
56
+ 'inside @myvillage/cli (vendor/backbones/); reinstall the CLI if this is missing.',
57
+ );
58
+ }
59
+ const j = JSON.parse(readFileSync(manifestPath, 'utf-8'));
60
+ const specs = [];
61
+ const buffers = [];
62
+ for (const grp of j.weightsManifest) {
63
+ for (const p of grp.paths) buffers.push(readFileSync(join(dir, p)));
64
+ specs.push(...grp.weights);
65
+ }
66
+ const concat = Buffer.concat(buffers);
67
+ // Slice to the exact backing range so tfjs sees a clean ArrayBuffer.
68
+ const weightData = concat.buffer.slice(
69
+ concat.byteOffset,
70
+ concat.byteOffset + concat.byteLength,
71
+ );
72
+ return {
73
+ modelTopology: j.modelTopology,
74
+ weightSpecs: specs,
75
+ weightData,
76
+ format: j.format,
77
+ generatedBy: j.generatedBy,
78
+ convertedBy: j.convertedBy,
79
+ };
80
+ },
81
+ };
82
+ }
83
+
84
+ /** Load (once) and cache the bundled backbone GraphModel. */
85
+ async function getBackbone() {
86
+ if (!_backbonePromise) {
87
+ _backbonePromise = (async () => {
88
+ const tf = await import('@tensorflow/tfjs');
89
+ await tf.setBackend('cpu');
90
+ await tf.ready();
91
+ return tf.loadGraphModel(diskGraphHandler(BACKBONE_DIR));
92
+ })();
93
+ }
94
+ return _backbonePromise;
95
+ }
96
+
97
+ /** Decode an image file to {width, height, data:RGBA Uint8Array} (pure JS). */
98
+ function decodeImage(path) {
99
+ if (!existsSync(path)) throw new Error(`Image not found: ${path}`);
100
+ const buf = readFileSync(path);
101
+ const ext = extname(path).toLowerCase();
102
+ if (ext === '.png') {
103
+ // Lazy require keeps these decoders out of the hot path for text/tabular.
104
+ const { PNG } = requirePkg('pngjs');
105
+ const png = PNG.sync.read(buf);
106
+ return { width: png.width, height: png.height, data: png.data };
107
+ }
108
+ if (ext === '.jpg' || ext === '.jpeg') {
109
+ const jpeg = requirePkg('jpeg-js');
110
+ const img = jpeg.decode(buf, { useTArray: true, maxMemoryUsageInMB: 512 });
111
+ return { width: img.width, height: img.height, data: img.data };
112
+ }
113
+ throw new Error(`Unsupported image type "${ext}". Use .jpg, .jpeg, or .png.`);
114
+ }
115
+
116
+ /**
117
+ * Embed a single image file → Float array of length EMBED_DIM (1024).
118
+ * Used by both training (featurize) and prediction (featurizeOne).
119
+ */
120
+ export async function embedImage(path) {
121
+ const tf = await import('@tensorflow/tfjs');
122
+ const model = await getBackbone();
123
+ const { width, height, data } = decodeImage(path);
124
+
125
+ return tf.tidy(() => {
126
+ // RGBA → RGB float [0,1], shaped [h, w, 3], resized to the backbone input.
127
+ const rgb = new Float32Array(width * height * 3);
128
+ for (let i = 0, j = 0; i < data.length; i += 4, j += 3) {
129
+ rgb[j] = data[i] / 255;
130
+ rgb[j + 1] = data[i + 1] / 255;
131
+ rgb[j + 2] = data[i + 2] / 255;
132
+ }
133
+ const img = tf.tensor3d(rgb, [height, width, 3]);
134
+ const resized = tf.image.resizeBilinear(img, [INPUT_SIZE, INPUT_SIZE]);
135
+ const batched = resized.expandDims(0); // [1, 224, 224, 3]
136
+ const out = model.predict(batched); // [1, 1024]
137
+ return Array.from(out.dataSync());
138
+ });
139
+ }
140
+
141
+ /** Featurization params persisted in the model record for image models. */
142
+ export function imageFeaturization() {
143
+ return { modality: 'image', dim: EMBED_DIM, backbone: BACKBONE_ID, inputSize: INPUT_SIZE };
144
+ }
@@ -6,19 +6,23 @@
6
6
  * Studio shells out to that same command. This is the single training engine;
7
7
  * the Studio is just a front-door that streams the progress it emits.
8
8
  *
9
- * v1 modalities (zero downloaded backbones):
9
+ * v1 modalities:
10
10
  * - tabular: numeric feature vectors → standardized → dense head
11
11
  * - text: hashing vectorizer (pure JS) → dense head
12
- * image/audio need a bundled vision/audio backbone and land in a later slice.
12
+ * - image: bundled MobileNetV3-Small backbone → 1024-d embedding → dense head
13
+ * audio needs a bundled audio backbone and lands in a later slice.
13
14
  *
14
15
  * Transfer-learning-style split: featurize once, then train a small head. The
15
16
  * featurization params travel in the model record so predict() needs no data.
17
+ * (For image, the backbone itself is bundled — see image-backbone.js — so the
18
+ * embedding can always be recomputed at predict time from the same weights.)
16
19
  */
17
20
  import * as tf from '@tensorflow/tfjs';
18
- import { readRows } from './dataset-workspace.js';
21
+ import { readRows, getAssetPath } from './dataset-workspace.js';
22
+ import { embedImage, imageFeaturization } from './image-backbone.js';
19
23
 
20
24
  const TEXT_DIM = 1024;
21
- export const TRAINABLE_MODALITIES = ['tabular', 'text'];
25
+ export const TRAINABLE_MODALITIES = ['tabular', 'text', 'image'];
22
26
 
23
27
  // ── Featurization ──────────────────────────────────────
24
28
 
@@ -54,8 +58,22 @@ function toNumericVector(features) {
54
58
  throw new Error('Tabular features must be a numeric vector.');
55
59
  }
56
60
 
57
- /** Build the feature matrix + the featurization params to persist. */
58
- function featurize(rows, modality) {
61
+ /** Resolve an image row's features to an absolute file path on disk. */
62
+ function imagePathOf(features, slug) {
63
+ // Rows store an AssetRef {kind,path}; path is relative to the dataset assets/.
64
+ const rel = typeof features === 'string' ? features : features?.path;
65
+ if (!rel) throw new Error('Image rows must reference an image asset.');
66
+ return getAssetPath(slug, rel);
67
+ }
68
+
69
+ /** Build the feature matrix + the featurization params to persist. Async to
70
+ * allow the image backbone to embed each example off disk. */
71
+ async function featurize(rows, modality, slug) {
72
+ if (modality === 'image') {
73
+ const X = [];
74
+ for (const r of rows) X.push(await embedImage(imagePathOf(r.features, slug)));
75
+ return { X, featurization: imageFeaturization() };
76
+ }
59
77
  if (modality === 'text') {
60
78
  return { X: rows.map((r) => vectorizeText(r.features)), featurization: { modality, dim: TEXT_DIM } };
61
79
  }
@@ -75,8 +93,15 @@ function featurize(rows, modality) {
75
93
  return { X, featurization: { modality, dim, mean, std } };
76
94
  }
77
95
 
78
- /** Featurize a single input the same way (for predict). */
79
- export function featurizeOne(input, featurization) {
96
+ /** Featurize a single input the same way (for predict). Async because the
97
+ * image path runs the backbone; text/tabular resolve synchronously. */
98
+ export async function featurizeOne(input, featurization) {
99
+ if (featurization.modality === 'image') {
100
+ // input is an absolute image file path (CLI --image / model_predict tool).
101
+ const path = typeof input === 'string' ? input : input?.path;
102
+ if (!path) throw new Error('Image prediction needs an image file path.');
103
+ return embedImage(path);
104
+ }
80
105
  if (featurization.modality === 'text') return vectorizeText(input);
81
106
  const v = toNumericVector(input);
82
107
  return v.map((x, j) => (x - featurization.mean[j]) / (featurization.std[j] || 1));
@@ -119,7 +144,7 @@ export async function trainModel(slug, manifest, { epochs = 40, onProgress } = {
119
144
  if (rows.length < 4) throw new Error('Need at least 4 examples to train (more is better).');
120
145
 
121
146
  const task = manifest.taskType;
122
- const { X, featurization } = featurize(rows, manifest.modality);
147
+ const { X, featurization } = await featurize(rows, manifest.modality, slug);
123
148
  const inputDim = X[0].length;
124
149
 
125
150
  // Build targets: one-hot rows (classification) or standardized scalars (regression)
@@ -226,10 +251,12 @@ function computeMetrics(model, xs, ys, task, labels, targetMean, targetStd) {
226
251
  });
227
252
  }
228
253
 
229
- /** Run a prediction with an already-loaded model + its record. */
230
- export function predictWithModel(model, record, input) {
254
+ /** Run a prediction with an already-loaded model + its record. Async because
255
+ * image featurization runs the backbone; the head forward pass stays in a
256
+ * tidy() so intermediate tensors are freed. */
257
+ export async function predictWithModel(model, record, input) {
258
+ const feats = await featurizeOne(input, record.featurization);
231
259
  return tf.tidy(() => {
232
- const feats = featurizeOne(input, record.featurization);
233
260
  const out = model.predict(tf.tensor2d([feats]));
234
261
  if (record.taskType === 'classification') {
235
262
  const scores = Array.from(out.dataSync());
@@ -12,10 +12,13 @@
12
12
  import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'fs';
13
13
  import { join } from 'path';
14
14
  import { randomUUID } from 'crypto';
15
- import * as tf from '@tensorflow/tfjs';
16
15
  import { getConfigDir } from './config.js';
17
16
  import { slugify } from './dataset-workspace.js';
18
17
 
18
+ // tfjs is loaded lazily (only in save/load) so listing/reading model manifests —
19
+ // e.g. when injecting "available models" into the agent loop's context — never
20
+ // pulls the heavy tfjs runtime into agent startup.
21
+
19
22
  const MODELS_DIR_NAME = 'models';
20
23
  export const MODEL_SCHEMA_VERSION = '1.0.0';
21
24
 
@@ -71,6 +74,7 @@ export async function saveModel(name, { model, record, datasetSlug, datasetVersi
71
74
  const dir = getModelDir(slug);
72
75
  mkdirSync(dir, { recursive: true });
73
76
 
77
+ const tf = await import('@tensorflow/tfjs');
74
78
  let modelTopology, weightSpecs, weightB64;
75
79
  await model.save(
76
80
  tf.io.withSaveHandler(async (artifacts) => {
@@ -104,6 +108,7 @@ export async function saveModel(name, { model, record, datasetSlug, datasetVersi
104
108
 
105
109
  /** Load a saved tf model back into memory (for predict). */
106
110
  export async function loadModel(slug) {
111
+ const tf = await import('@tensorflow/tfjs');
107
112
  const dir = getModelDir(slug);
108
113
  if (!existsSync(join(dir, 'model.json'))) throw new Error(`Model "${slug}" not found.`);
109
114
  const { modelTopology, weightSpecs } = JSON.parse(readFileSync(join(dir, 'model.json'), 'utf-8'));
@@ -0,0 +1,13 @@
1
+ # MobileNetV3-Small (TF.js GraphModel) — vendored backbone
2
+
3
+ Bundled feature-extractor for the Studio "Lab" pillar image classification.
4
+ **Never downloaded at runtime** — shipped inside `@myvillage/cli` so the Studio
5
+ stays "the only thing you install" (see ed-platform docs/architecture/studio-models-pillar.md §0b, §13).
6
+
7
+ - **Model:** MobileNetV3-Small, 1.0 depth, 224×224, ImageNet feature-vector head removed
8
+ - **Input:** `[-1, 224, 224, 3]` float32, pixels normalized to `[0, 1]`
9
+ - **Output:** `[-1, 1024]` embedding (fed to the trained dense head)
10
+ - **Format:** TensorFlow.js `graph-model` (model.json + 2 weight shards, ~5.85 MB)
11
+ - **Source:** Google MobileNetV3 TfJs feature-vector (`small-100-224`), Kaggle Models
12
+ (`google/mobilenet-v3`), converted by TensorFlow.js Converter v3.0.0
13
+ - **License:** Apache-2.0 (TensorFlow Hub / Google)