@myvillage/cli 1.72.0 → 1.74.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.72.0",
3
+ "version": "1.74.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 {
@@ -16,8 +16,11 @@ import {
16
16
  readManifest,
17
17
  datasetExists,
18
18
  appendRows,
19
+ ingestAsset,
19
20
  readRows,
20
21
  freezeVersion,
22
+ deleteRow,
23
+ updateRow,
21
24
  } from '../utils/dataset-workspace.js';
22
25
 
23
26
  // ── create ─────────────────────────────────────────────
@@ -83,8 +86,20 @@ export async function datasetAppendCommand(options) {
83
86
  if (!slug) throw new Error('--dataset <slug> is required.');
84
87
  if (!datasetExists(slug)) throw new Error(`Dataset "${slug}" not found.`);
85
88
 
89
+ const manifest = readManifest(slug);
90
+
86
91
  let rows;
87
- if (options.file) {
92
+ if (options.image) {
93
+ // Copy the image into the dataset's assets/ and reference it by AssetRef.
94
+ if (manifest.modality !== 'image') {
95
+ throw new Error(`--image is only valid for image datasets (this one is "${manifest.modality}").`);
96
+ }
97
+ const asset = ingestAsset(slug, options.image, 'image');
98
+ const row = { split: options.split || 'train', features: asset };
99
+ if (options.label !== undefined) row.label = options.label;
100
+ if (options.target !== undefined) row.target = Number(options.target);
101
+ rows = [row];
102
+ } else if (options.file) {
88
103
  rows = readRowsFromFile(options.file);
89
104
  } else if (options.text !== undefined || options.features !== undefined) {
90
105
  const row = { split: options.split || 'train' };
@@ -93,7 +108,7 @@ export async function datasetAppendCommand(options) {
93
108
  if (options.target !== undefined) row.target = Number(options.target);
94
109
  rows = [row];
95
110
  } else {
96
- throw new Error('Provide either --file <jsonl> or --text/--features (with --label or --target).');
111
+ throw new Error('Provide --image <path> (image datasets), --file <jsonl>, or --text/--features (with --label or --target).');
97
112
  }
98
113
 
99
114
  const spinner = options.json ? null : villageSpinner('Appending rows...').start();
@@ -132,7 +147,10 @@ export async function datasetShowCommand(slug, options) {
132
147
  if (sample.length) {
133
148
  console.log(brand.cream(`\n sample (${sample.length}):`));
134
149
  for (const r of sample) {
135
- const feat = typeof r.features === 'string' ? truncate(r.features, 50) : JSON.stringify(r.features);
150
+ const feat =
151
+ typeof r.features === 'string' ? truncate(r.features, 50)
152
+ : r.features?.path ? `📎 ${r.features.path}`
153
+ : JSON.stringify(r.features);
136
154
  const tgt = r.label != null ? `label=${r.label}` : `target=${r.target}`;
137
155
  console.log(` [${r.split}] ${tgt} ${brand.teal(feat)}`);
138
156
  }
@@ -160,6 +178,55 @@ export async function datasetFreezeCommand(slug, options) {
160
178
  }
161
179
  }
162
180
 
181
+ // ── delete a row ───────────────────────────────────────
182
+
183
+ export async function datasetRowDeleteCommand(options) {
184
+ try {
185
+ const slug = options.dataset;
186
+ if (!slug) throw new Error('--dataset <slug> is required.');
187
+ if (!options.id) throw new Error('--id <rowId> is required.');
188
+ const result = deleteRow(slug, options.id);
189
+ if (options.json) {
190
+ console.log(JSON.stringify(result, null, 2));
191
+ return;
192
+ }
193
+ success(`Deleted row ${options.id} from ${slug}`);
194
+ info(` total: ${result.stats.rowCount} (train ${result.stats.bySplit.train} / val ${result.stats.bySplit.val})`);
195
+ } catch (e) {
196
+ error(e.message);
197
+ process.exitCode = 1;
198
+ }
199
+ }
200
+
201
+ // ── update a row ───────────────────────────────────────
202
+
203
+ export async function datasetRowUpdateCommand(options) {
204
+ try {
205
+ const slug = options.dataset;
206
+ if (!slug) throw new Error('--dataset <slug> is required.');
207
+ if (!options.id) throw new Error('--id <rowId> is required.');
208
+ const updates = {};
209
+ if (options.features !== undefined) updates.features = parseFeatures(options.features);
210
+ else if (options.text !== undefined) updates.features = options.text;
211
+ if (options.label !== undefined) updates.label = options.label;
212
+ if (options.target !== undefined) updates.target = Number(options.target);
213
+ if (options.split !== undefined) updates.split = options.split;
214
+ if (Object.keys(updates).length === 0) {
215
+ throw new Error('Nothing to update. Pass --text/--features, --label, --target, or --split.');
216
+ }
217
+ const result = updateRow(slug, options.id, updates);
218
+ if (options.json) {
219
+ console.log(JSON.stringify(result, null, 2));
220
+ return;
221
+ }
222
+ success(`Updated row ${options.id} in ${slug}`);
223
+ info(` total: ${result.stats.rowCount} (train ${result.stats.bySplit.train} / val ${result.stats.bySplit.val})`);
224
+ } catch (e) {
225
+ error(e.message);
226
+ process.exitCode = 1;
227
+ }
228
+ }
229
+
163
230
  // ── helpers ────────────────────────────────────────────
164
231
 
165
232
  function readRowsFromFile(path) {
@@ -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
@@ -124,6 +124,8 @@ import {
124
124
  datasetAppendCommand,
125
125
  datasetShowCommand,
126
126
  datasetFreezeCommand,
127
+ datasetRowDeleteCommand,
128
+ datasetRowUpdateCommand,
127
129
  } from './commands/dataset.js';
128
130
  import {
129
131
  modelTrainCommand,
@@ -877,6 +879,7 @@ export function run() {
877
879
  .description('Append labeled example(s) to a dataset')
878
880
  .requiredOption('--dataset <slug>', 'Target dataset slug')
879
881
  .option('--file <path>', 'JSONL file of rows ({features,label|target,split})')
882
+ .option('--image <path>', 'Image file to add (image datasets); copied into the dataset')
880
883
  .option('--text <text>', 'Single-row text features')
881
884
  .option('--features <value>', 'Single-row features (string, or numeric vector for tabular)')
882
885
  .option('--label <label>', 'Class label (classification)')
@@ -899,6 +902,27 @@ export function run() {
899
902
  .option('--json', 'Output raw JSON')
900
903
  .action(datasetFreezeCommand);
901
904
 
905
+ datasetCmd
906
+ .command('rm-row')
907
+ .description('Delete a row from the working set (by id)')
908
+ .requiredOption('--dataset <slug>', 'Target dataset slug')
909
+ .requiredOption('--id <rowId>', 'Row id to delete')
910
+ .option('--json', 'Output raw JSON')
911
+ .action(datasetRowDeleteCommand);
912
+
913
+ datasetCmd
914
+ .command('update-row')
915
+ .description('Update a working-set row (by id)')
916
+ .requiredOption('--dataset <slug>', 'Target dataset slug')
917
+ .requiredOption('--id <rowId>', 'Row id to update')
918
+ .option('--text <text>', 'New text features')
919
+ .option('--features <value>', 'New features (string, or numeric vector for tabular)')
920
+ .option('--label <label>', 'New class label (classification)')
921
+ .option('--target <number>', 'New numeric target (regression)')
922
+ .option('--split <split>', 'train | val')
923
+ .option('--json', 'Output raw JSON')
924
+ .action(datasetRowUpdateCommand);
925
+
902
926
  // ── Studio Lab: model training (local, TF.js CPU engine) ───────────────
903
927
  const modelCmd = program
904
928
  .command('model')
@@ -928,7 +952,8 @@ export function run() {
928
952
  modelCmd
929
953
  .command('predict <slug>')
930
954
  .description('Run a prediction with a trained model')
931
- .requiredOption('--input <value>', 'Input: text, or a comma-separated numeric vector for tabular')
955
+ .option('--input <value>', 'Input: text, or a comma-separated numeric vector for tabular')
956
+ .option('--image <path>', 'Image file to classify (image models)')
932
957
  .option('--json', 'Output raw JSON')
933
958
  .action(modelPredictCommand);
934
959
 
@@ -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) {
@@ -256,6 +308,93 @@ export function readRows(slug, { version, split, limit } = {}) {
256
308
  return out;
257
309
  }
258
310
 
311
+ // ── Edit / delete working-set rows ─────────────────────
312
+ //
313
+ // Only the working set (current.jsonl) is mutable; frozen versions (v*.jsonl)
314
+ // are immutable snapshots and are never touched here. Both ops rewrite the
315
+ // whole working-set file and recompute manifest stats from the result, so the
316
+ // manifest can never drift from the rows on disk.
317
+
318
+ /** Recompute manifest stats (rowCount / bySplit / byLabel) from a row array. */
319
+ function recomputeStats(rows) {
320
+ const stats = emptyStats();
321
+ for (const row of rows) {
322
+ stats.rowCount += 1;
323
+ if (stats.bySplit[row.split] != null) stats.bySplit[row.split] += 1;
324
+ if (row.label != null) {
325
+ stats.byLabel[row.label] = (stats.byLabel[row.label] || 0) + 1;
326
+ }
327
+ }
328
+ return stats;
329
+ }
330
+
331
+ /** Overwrite the working set with `rows` and refresh the manifest's stats. */
332
+ function writeWorkingSet(slug, manifest, rows) {
333
+ const body = rows.map((r) => JSON.stringify(r)).join('\n');
334
+ writeFileSync(getCurrentDataFile(slug), rows.length ? body + '\n' : '');
335
+ manifest.stats = recomputeStats(rows);
336
+ return writeManifest(slug, manifest);
337
+ }
338
+
339
+ /**
340
+ * Delete one working-set row by id. Throws if the id isn't in current.jsonl
341
+ * (e.g. it only exists in a frozen version, which can't be edited).
342
+ * @returns {{datasetId:string, deleted:string, stats:object}}
343
+ */
344
+ export function deleteRow(slug, rowId) {
345
+ const manifest = readManifest(slug);
346
+ if (!manifest) throw new Error(`Dataset "${slug}" not found.`);
347
+ if (!rowId) throw new Error('A row id is required.');
348
+ const rows = readRows(slug);
349
+ const idx = rows.findIndex((r) => r.id === rowId);
350
+ if (idx === -1) {
351
+ throw new Error(`Row "${rowId}" not found in the working set of "${slug}".`);
352
+ }
353
+ const [removed] = rows.splice(idx, 1);
354
+ writeWorkingSet(slug, manifest, rows);
355
+ return { datasetId: slug, deleted: removed.id, stats: manifest.stats };
356
+ }
357
+
358
+ /**
359
+ * Update one working-set row by id. `updates` is a partial
360
+ * {features, label, target, split}; the merged row is re-validated against the
361
+ * dataset's modality/task (same rules as append). The row id is preserved and
362
+ * provenance.editedAt is stamped. Image/audio asset refs can't be edited here —
363
+ * delete the row and re-add the asset instead.
364
+ * @returns {{datasetId:string, updated:string, stats:object}}
365
+ */
366
+ export function updateRow(slug, rowId, updates = {}) {
367
+ const manifest = readManifest(slug);
368
+ if (!manifest) throw new Error(`Dataset "${slug}" not found.`);
369
+ if (!rowId) throw new Error('A row id is required.');
370
+ const rows = readRows(slug);
371
+ const idx = rows.findIndex((r) => r.id === rowId);
372
+ if (idx === -1) {
373
+ throw new Error(`Row "${rowId}" not found in the working set of "${slug}".`);
374
+ }
375
+ const existing = rows[idx];
376
+ if ((manifest.modality === 'image' || manifest.modality === 'audio') && updates.features !== undefined) {
377
+ throw new Error(`Can't edit the asset of an ${manifest.modality} row — delete it and re-add.`);
378
+ }
379
+ const merged = normalizeRow(
380
+ {
381
+ features: updates.features !== undefined ? updates.features : existing.features,
382
+ split: updates.split !== undefined ? updates.split : existing.split,
383
+ label: updates.label !== undefined ? updates.label : existing.label,
384
+ target: updates.target !== undefined ? updates.target : existing.target,
385
+ },
386
+ manifest,
387
+ );
388
+ merged.id = existing.id;
389
+ merged.provenance = { ...(existing.provenance || {}), editedAt: nowISO() };
390
+ if (merged.label != null && !manifest.labels.includes(merged.label)) {
391
+ manifest.labels.push(merged.label);
392
+ }
393
+ rows[idx] = merged;
394
+ writeWorkingSet(slug, manifest, rows);
395
+ return { datasetId: slug, updated: merged.id, stats: manifest.stats };
396
+ }
397
+
259
398
  // ── Freeze a version ───────────────────────────────────
260
399
 
261
400
  /**
@@ -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
+ }