@mcptoolshop/style-dataset-lab 3.1.0 → 3.2.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.es.md +84 -62
  3. package/README.fr.md +85 -63
  4. package/README.hi.md +97 -77
  5. package/README.it.md +86 -64
  6. package/README.ja.md +94 -72
  7. package/README.md +22 -0
  8. package/README.pt-BR.md +83 -61
  9. package/README.zh.md +88 -66
  10. package/bin/sdlab.js +71 -0
  11. package/lib/adapters/ai-toolkit.js +209 -0
  12. package/lib/adapters/comfyui-workflows.js +133 -2
  13. package/lib/adapters/diffusers-lora.js +46 -66
  14. package/lib/adapters/generic-image-caption.js +22 -50
  15. package/lib/canon-build/build.js +450 -0
  16. package/lib/canon-build/emitters/base.js +122 -0
  17. package/lib/canon-build/emitters/character.js +167 -0
  18. package/lib/canon-build/emitters/deity.js +165 -0
  19. package/lib/canon-build/emitters/faction.js +161 -0
  20. package/lib/canon-build/emitters/index.js +38 -0
  21. package/lib/canon-build/emitters/location.js +153 -0
  22. package/lib/canon-build/emitters/monster.js +131 -0
  23. package/lib/canon-build/emitters/relic.js +142 -0
  24. package/lib/canon-build/emitters/ship.js +154 -0
  25. package/lib/canon-build/emitters/species.js +165 -0
  26. package/lib/canon-build/frontmatter.js +76 -0
  27. package/lib/canon-build/lane-resolve.js +64 -0
  28. package/lib/canon-build/load-config.js +117 -0
  29. package/lib/canon-build/load-entry.js +62 -0
  30. package/lib/canon-build/load-schema.js +46 -0
  31. package/lib/captions.js +197 -0
  32. package/lib/config.js +19 -0
  33. package/lib/freeze-events.js +109 -0
  34. package/lib/freeze-gate.js +165 -0
  35. package/lib/freeze-stamp.js +161 -0
  36. package/lib/rows.js +228 -0
  37. package/lib/training-adapters.js +5 -0
  38. package/lib/training-packages.js +24 -18
  39. package/lib/training-profiles.js +33 -0
  40. package/package.json +12 -3
  41. package/scripts/_batch_cut.py +71 -0
  42. package/scripts/_seam_cutout.py +74 -0
  43. package/scripts/canon-build.js +88 -0
  44. package/scripts/canon-drift.js +193 -0
  45. package/scripts/canon-freeze-status.js +85 -0
  46. package/scripts/canon-freeze.js +211 -0
  47. package/scripts/canon-unfreeze.js +137 -0
  48. package/scripts/curate.js +12 -0
  49. package/scripts/generate-controlnet.js +12 -0
  50. package/scripts/generate-identity.js +20 -4
  51. package/scripts/generate-ipadapter.js +11 -0
  52. package/scripts/generate.js +238 -102
  53. package/scripts/qwen_generate.py +124 -0
@@ -1,49 +1,26 @@
1
1
  /**
2
2
  * Generic image+caption adapter.
3
3
  *
4
- * Simple, inspectable, durable baseline:
5
- * - dataset/<partition>/ with image files
6
- * - metadata/<partition>.jsonl with one row per image
4
+ * Consumes canonical rows (per D6) and emits:
5
+ * dataset/<partition>/<entity_id>.<ext> — image (copied or symlinked)
6
+ * metadata/<partition>.jsonl — per-row audit (caption inline)
7
7
  *
8
- * Does not mutate inclusion or split truth.
8
+ * No caption sidecar files — this adapter keeps metadata flat and
9
+ * machine-readable. Use diffusers-lora or ai-toolkit for trainer-specific
10
+ * caption-file conventions.
9
11
  */
10
12
 
11
13
  import { writeFile, mkdir, symlink, copyFile } from 'node:fs/promises';
12
14
  import { join, relative } from 'node:path';
13
15
  import { existsSync } from 'node:fs';
16
+ import { groupRowsByPartition } from '../rows.js';
14
17
 
15
- /**
16
- * Build a caption string from a record and profile.
17
- */
18
- function buildCaption(record, lane, group, profile) {
19
- const parts = [];
20
-
21
- if (profile.prompt_strategy === 'trigger-word' && profile.profile_id) {
22
- parts.push(profile.profile_id.replace(/-/g, '_'));
23
- }
24
-
25
- if (group) parts.push(group);
26
- if (lane) parts.push(lane);
27
-
28
- // Add key provenance if available
29
- const prompt = record.provenance?.prompt;
30
- if (prompt) {
31
- // Use first 200 chars of prompt as caption body
32
- parts.push(prompt.slice(0, 200));
33
- } else {
34
- parts.push(record.id.replace(/_/g, ' '));
35
- }
36
-
37
- return parts.join(', ');
38
- }
18
+ export async function buildPackage({ packageDir, rows, profile, manifest, config, projectRoot, copy }) {
19
+ const partitioned = groupRowsByPartition(rows || []);
39
20
 
40
- /**
41
- * Build training package in generic image+caption format.
42
- */
43
- export async function buildPackage({ packageDir, partitions, profile, manifest, config, projectRoot, copy }) {
44
21
  let imageCount = 0;
45
22
 
46
- for (const [partition, records] of Object.entries(partitions)) {
23
+ for (const [partition, partitionRows] of Object.entries(partitioned)) {
47
24
  const datasetDir = join(packageDir, 'dataset', partition);
48
25
  const metadataDir = join(packageDir, 'metadata');
49
26
  await mkdir(datasetDir, { recursive: true });
@@ -51,18 +28,16 @@ export async function buildPackage({ packageDir, partitions, profile, manifest,
51
28
 
52
29
  const metadataRows = [];
53
30
 
54
- for (const { record, lane, group } of records) {
55
- if (!record.asset_path) continue;
31
+ for (const row of partitionRows) {
32
+ if (!row.asset_path) continue;
56
33
 
57
- const sourcePath = join(projectRoot, record.asset_path);
34
+ const sourcePath = join(projectRoot, row.asset_path);
58
35
  if (!existsSync(sourcePath)) continue;
59
36
 
60
- // D-009: Name output files by record.id (not source basename) to
61
- // avoid silent collisions when two records share a filename stem.
62
- const sourceBasename = record.asset_path.split(/[\\/]/).pop();
37
+ const sourceBasename = row.asset_path.split(/[\\/]/).pop();
63
38
  const extMatch = sourceBasename.match(/\.[^.]+$/);
64
39
  const ext = extMatch ? extMatch[0] : '';
65
- const filename = `${record.id}${ext}`;
40
+ const filename = `${row.entity_id}${ext}`;
66
41
  const destPath = join(datasetDir, filename);
67
42
 
68
43
  try {
@@ -73,25 +48,22 @@ export async function buildPackage({ packageDir, partitions, profile, manifest,
73
48
  }
74
49
  imageCount++;
75
50
  } catch {
76
- continue; // skip duplicates or broken links
51
+ continue;
77
52
  }
78
53
 
79
- const caption = buildCaption(record, lane, group, profile);
80
-
81
54
  metadataRows.push({
82
55
  file: `dataset/${partition}/${filename}`,
83
- record_id: record.id,
84
- caption,
85
- lane,
86
- group,
87
- pass_ratio: record.canon?.assertion_count > 0
88
- ? +(record.canon.pass_count / record.canon.assertion_count).toFixed(3) : null,
56
+ record_id: row.entity_id,
57
+ caption: row.caption,
58
+ lane: row.lane,
59
+ group: row.group ?? null,
60
+ pass_ratio: row.pass_ratio ?? null,
89
61
  });
90
62
  }
91
63
 
92
64
  await writeFile(
93
65
  join(metadataDir, `${partition}.jsonl`),
94
- metadataRows.map(r => JSON.stringify(r)).join('\n') + '\n'
66
+ metadataRows.map(r => JSON.stringify(r)).join('\n') + (metadataRows.length ? '\n' : '')
95
67
  );
96
68
  }
97
69
 
@@ -0,0 +1,450 @@
1
+ /**
2
+ * Three-projection build orchestrator.
3
+ *
4
+ * `sdlab canon build` entry point (scripts/canon-build.js calls runBuild).
5
+ *
6
+ * Flow:
7
+ * 1. Load canon-build/config.json (D9 lane map lives here).
8
+ * 2. Load all referenced schemas + resolve schema versions (D7).
9
+ * 3. Discover entries under `canon_root/<entity_dir>/`.
10
+ * 4. Resolve each entry's lane (D3).
11
+ * 5. Compute entry_hash = sha256(body || schema_version || config || project_fingerprint).
12
+ * Cache-hit = bypass emitter; cache-miss = run emitter + write cache (D4).
13
+ * 6. Write outputs under `<project>/canon-build/<canon_sha>.tmp/` atomically,
14
+ * rename to `<canon_sha>/` on full completion.
15
+ * 7. Manifest.json records per-entity hashes + stats for audit.
16
+ */
17
+
18
+ import { readFile, writeFile, mkdir, rename, rm } from 'node:fs/promises';
19
+ import { join, relative, dirname } from 'node:path';
20
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
21
+ import { createHash } from 'node:crypto';
22
+ import { execSync } from 'node:child_process';
23
+ import { loadBuildConfig } from './load-config.js';
24
+ import { loadSchema } from './load-schema.js';
25
+ import { loadCanonEntriesInDir, entryId } from './load-entry.js';
26
+ import { resolveLane } from './lane-resolve.js';
27
+ import { getEmitter } from './emitters/index.js';
28
+ import { resolveContextMaxLines, checkContextLength } from './emitters/base.js';
29
+ import { canonEntryToRow, serializeRow, ROW_SCHEMA_VERSION } from '../rows.js';
30
+ import { computeConfigFingerprint } from '../snapshot.js';
31
+ import { loadTrainingProfile } from '../training-profiles.js';
32
+ import { deriveStyleTrigger } from '../captions.js';
33
+ import { inputError, runtimeError } from '../errors.js';
34
+ import { readFreezeStatus, resolveWatchFields, computeWatchHash } from '../freeze-stamp.js';
35
+
36
+ const BUILD_SCHEMA_VERSION = 'canon-build-manifest-1.0';
37
+
38
+ /**
39
+ * Main entry point.
40
+ *
41
+ * @param {Object} opts
42
+ * @param {string} opts.projectRoot — absolute path to the sdlab project
43
+ * @param {boolean} [opts.full] — force full rebuild (ignore cache hits)
44
+ * @param {boolean} [opts.noCache] — neither read nor write the cache
45
+ * @param {boolean} [opts.dryRun] — walk + resolve + plan; write nothing
46
+ * @param {Array<string>} [opts.only] — limit to specific entity ids
47
+ * @returns {Promise<Object>} result summary
48
+ */
49
+ export async function runBuild(opts) {
50
+ const { projectRoot, full = false, noCache = false, dryRun = false, only = null } = opts;
51
+
52
+ const configPath = join(projectRoot, 'canon-build', 'config.json');
53
+ const config = await loadBuildConfig(configPath, projectRoot);
54
+
55
+ // Load schemas
56
+ const schemas = {};
57
+ for (const schemaName of Object.keys(config.entity_dirs)) {
58
+ const schemaPath = join(config.schema_dir, schemaName);
59
+ if (!existsSync(schemaPath)) {
60
+ throw inputError(
61
+ 'CANON_SCHEMA_NOT_FOUND',
62
+ `Schema file ${schemaName} not found at ${schemaPath}`,
63
+ `Check canon-build/config.json schema_dir and the schema filename.`,
64
+ );
65
+ }
66
+ schemas[schemaName] = await loadSchema(schemaPath);
67
+ }
68
+
69
+ // Resolve the training profile (for trigger + caption strategy)
70
+ let profile = null;
71
+ let trigger = null;
72
+ if (config.profile_id) {
73
+ profile = await loadTrainingProfile(projectRoot, config.profile_id);
74
+ trigger = deriveStyleTrigger(profile);
75
+ }
76
+
77
+ // Compute the generated_from stamp (git SHA if available, else content-sha)
78
+ const generatedFrom = resolveGeneratedFrom(config.canon_root);
79
+ // Path-safe variant for filesystem use — Windows cannot contain ":" in a
80
+ // directory name, so "content-sha:abc" becomes "content-sha-abc" on disk.
81
+ // The manifest fields (generated_from in rows, manifest.json, context
82
+ // frontmatter) carry the original unsanitized value so audit tooling can
83
+ // reason about it directly.
84
+ const generatedFromDir = generatedFrom.replace(/:/g, '-');
85
+
86
+ // Compute cache inputs that apply to every entry
87
+ const projectFingerprint = computeConfigFingerprint(projectRoot);
88
+ const buildConfigHash = sha256(config.raw_text || '');
89
+
90
+ // Discover all entries
91
+ const entries = [];
92
+ const schemaChangeTriggersFullRebuild = await detectSchemaChange(projectRoot, schemas, full);
93
+
94
+ for (const [schemaName, subdir] of Object.entries(config.entity_dirs)) {
95
+ const dir = join(config.canon_root, subdir);
96
+ const loaded = await loadCanonEntriesInDir(dir);
97
+ for (const entry of loaded) {
98
+ const id = entryId(entry);
99
+ if (only && !only.includes(id)) continue;
100
+ entries.push({ entry, schemaName, id });
101
+ }
102
+ }
103
+
104
+ // Emit per-entity
105
+ const outputDir = join(projectRoot, 'canon-build', generatedFromDir);
106
+ const stagingDir = join(projectRoot, 'canon-build', `${generatedFromDir}.tmp-${process.pid}`);
107
+ const cacheDir = join(projectRoot, 'canon-build', '.cache');
108
+
109
+ const effectiveFull = full || schemaChangeTriggersFullRebuild;
110
+ const contextLimitFailures = [];
111
+ const rows = [];
112
+ let cached = 0;
113
+ let rebuilt = 0;
114
+
115
+ for (const { entry, schemaName, id } of entries) {
116
+ const schemaInfo = schemas[schemaName];
117
+ const entryHashInputs = {
118
+ entryBody: entry.rawText,
119
+ schemaVersion: schemaInfo.version,
120
+ buildConfigJson: config.raw_text,
121
+ projectFingerprint,
122
+ };
123
+ const entryHash = sha256(
124
+ `${entryHashInputs.entryBody}\0${entryHashInputs.schemaVersion}\0${entryHashInputs.buildConfigJson}\0${entryHashInputs.projectFingerprint}`
125
+ );
126
+
127
+ const emitter = getEmitter(schemaName);
128
+ if (!emitter) {
129
+ throw inputError(
130
+ 'CANON_EMITTER_NOT_FOUND',
131
+ `No emitter registered for schema "${schemaName}"`,
132
+ `Register an emitter in lib/canon-build/emitters/index.js.`,
133
+ );
134
+ }
135
+
136
+ const lane = resolveLane(entry, schemaName, config.schema_to_lane);
137
+ const emitCtx = {
138
+ trigger,
139
+ lane,
140
+ generatedFrom,
141
+ entryHash,
142
+ };
143
+
144
+ let rendered;
145
+ const cacheKeyDir = join(cacheDir, entryHash);
146
+ const cacheHit = !effectiveFull && !noCache && existsSync(cacheKeyDir);
147
+
148
+ if (cacheHit) {
149
+ rendered = await readCachedRender(cacheKeyDir);
150
+ cached++;
151
+ } else {
152
+ rendered = emitter(entry, emitCtx);
153
+ rebuilt++;
154
+ if (!noCache && !dryRun) {
155
+ await writeCachedRender(cacheKeyDir, rendered);
156
+ }
157
+ }
158
+
159
+ // Enforce context length
160
+ const maxLines = resolveContextMaxLines(rendered.schemaKind, config.context_limits);
161
+ const failure = checkContextLength(rendered.context, maxLines, id, rendered.schemaKind);
162
+ if (failure) contextLimitFailures.push(failure);
163
+
164
+ // Resolve asset_path — look up visual.reference_plate_uri, then top-level
165
+ const refPath = entry.frontmatter.visual?.reference_plate_uri
166
+ || entry.frontmatter.reference_plate_uri
167
+ || null;
168
+
169
+ // Build the row
170
+ const row = canonEntryToRow({
171
+ entry: entry.frontmatter,
172
+ schemaKind: rendered.schemaKind,
173
+ assetPath: refPath,
174
+ lane,
175
+ caption: rendered.caption,
176
+ trigger,
177
+ partition: 'train',
178
+ entryHash,
179
+ generatedFrom,
180
+ });
181
+ rows.push(row);
182
+
183
+ // Stash the rendered prompt + context on the entry for later writing
184
+ entry._rendered = rendered;
185
+ entry._row = row;
186
+ entry._lane = lane;
187
+ entry._id = id;
188
+ entry._entryHash = entryHash;
189
+ }
190
+
191
+ if (contextLimitFailures.length) {
192
+ const summary = contextLimitFailures
193
+ .map((f) => ` ${f.schemaKind}:${f.entityId} → ${f.lineCount} lines (cap ${f.maxLines})`)
194
+ .join('\n');
195
+ throw runtimeError(
196
+ 'CANON_CONTEXT_LENGTH_EXCEEDED',
197
+ `${contextLimitFailures.length} context file(s) exceeded their line cap:\n${summary}`,
198
+ 'Trim narrative fields or raise the per-schema context_limits.<schema>.max_lines in canon-build/config.json.',
199
+ );
200
+ }
201
+
202
+ if (dryRun) {
203
+ return {
204
+ dryRun: true,
205
+ generated_from: generatedFrom,
206
+ entities_total: entries.length,
207
+ entities_cached: cached,
208
+ entities_rebuilt: rebuilt,
209
+ rows: rows.length,
210
+ output_dir: outputDir,
211
+ };
212
+ }
213
+
214
+ // Write to staging, then atomic rename.
215
+ await rm(stagingDir, { recursive: true, force: true });
216
+ await mkdir(join(stagingDir, 'dataset'), { recursive: true });
217
+ await mkdir(join(stagingDir, 'prompts'), { recursive: true });
218
+ await mkdir(join(stagingDir, 'context'), { recursive: true });
219
+
220
+ // Write prompts + context
221
+ for (const { entry } of entries) {
222
+ const r = entry._rendered;
223
+ const id = entry._id;
224
+ await writeFile(join(stagingDir, 'prompts', `${id}.j2`), r.prompt, 'utf-8');
225
+ await writeFile(join(stagingDir, 'context', `${id}.md`), r.context, 'utf-8');
226
+ }
227
+
228
+ // Write dataset.jsonl (all + per-lane-per-partition)
229
+ const allLines = rows.map((r) => serializeRow(r)).join('\n') + (rows.length ? '\n' : '');
230
+ await writeFile(join(stagingDir, 'dataset', 'all.jsonl'), allLines, 'utf-8');
231
+
232
+ // Group by (lane × partition)
233
+ const byLaneAndPartition = {};
234
+ for (const row of rows) {
235
+ const key = `${row.lane}-${row.partition}`;
236
+ if (!byLaneAndPartition[key]) byLaneAndPartition[key] = [];
237
+ byLaneAndPartition[key].push(row);
238
+ }
239
+ for (const [key, laneRows] of Object.entries(byLaneAndPartition)) {
240
+ const content = laneRows.map((r) => serializeRow(r)).join('\n') + '\n';
241
+ await writeFile(join(stagingDir, 'dataset', `${key}.jsonl`), content, 'utf-8');
242
+ }
243
+
244
+ // Freeze drift pass — stamp frozen_entries_hashes (D1 witness chain).
245
+ // For every entry with a non-auto freeze status, compute the watch-hash
246
+ // of its currently-watched fields. Drift is computed by later builds or
247
+ // the `sdlab canon drift` command by diffing their hash against this.
248
+ const frozenEntriesHashes = {};
249
+ const drift = [];
250
+ const priorManifest = await loadPreviousManifest(projectRoot, outputDir);
251
+ for (const { entry, schemaName, id } of entries) {
252
+ const status = readFreezeStatus(entry.frontmatter);
253
+ if (status === 'auto') continue;
254
+ const watchFields = resolveWatchFields(entry.frontmatter, schemaName, config);
255
+ const watchHash = computeWatchHash(
256
+ entry.frontmatter,
257
+ watchFields,
258
+ schemas[schemaName].version,
259
+ );
260
+ frozenEntriesHashes[id] = {
261
+ status,
262
+ watch_hash: watchHash,
263
+ watch_fields: watchFields,
264
+ locked_at_build: entry.frontmatter.freeze?.locked_at_build || null,
265
+ };
266
+ // Compute drift against prior build's stamp, if any.
267
+ const priorHash = priorManifest?.frozen_entries_hashes?.[id]?.watch_hash;
268
+ if (priorHash && priorHash !== watchHash) {
269
+ drift.push({
270
+ entity_id: id,
271
+ schema_kind: schemaName.replace('.schema.json', ''),
272
+ status,
273
+ prior_hash: priorHash,
274
+ current_hash: watchHash,
275
+ });
276
+ }
277
+ }
278
+
279
+ // Write manifest
280
+ const manifest = {
281
+ schema_version: BUILD_SCHEMA_VERSION,
282
+ row_schema_version: ROW_SCHEMA_VERSION,
283
+ generated_from: generatedFrom,
284
+ built_at: new Date().toISOString(),
285
+ project_id: config.project_id,
286
+ schema_versions: Object.fromEntries(
287
+ Object.entries(schemas).map(([k, v]) => [k, v.version])
288
+ ),
289
+ build_config_hash: buildConfigHash,
290
+ project_config_fingerprint: projectFingerprint,
291
+ per_entity_hashes: Object.fromEntries(entries.map(({ entry, id }) => [id, entry._entryHash])),
292
+ frozen_entries_hashes: frozenEntriesHashes,
293
+ drift_against_prior: drift,
294
+ stats: {
295
+ entities_total: entries.length,
296
+ entities_cached: cached,
297
+ entities_rebuilt: rebuilt,
298
+ dataset_rows: rows.length,
299
+ frozen_entries: Object.keys(frozenEntriesHashes).length,
300
+ drifted_entries: drift.length,
301
+ schema_change_full_rebuild: schemaChangeTriggersFullRebuild,
302
+ },
303
+ };
304
+ await writeFile(
305
+ join(stagingDir, 'manifest.json'),
306
+ JSON.stringify(manifest, null, 2) + '\n',
307
+ 'utf-8'
308
+ );
309
+
310
+ // Atomic swap: remove old output, rename staging → output.
311
+ await rm(outputDir, { recursive: true, force: true });
312
+ await rename(stagingDir, outputDir);
313
+
314
+ // Update the "latest" schema-version snapshot for change detection next run.
315
+ await writeLatestSchemaVersions(projectRoot, manifest.schema_versions);
316
+
317
+ return {
318
+ dryRun: false,
319
+ generated_from: generatedFrom,
320
+ entities_total: entries.length,
321
+ entities_cached: cached,
322
+ entities_rebuilt: rebuilt,
323
+ rows: rows.length,
324
+ output_dir: outputDir,
325
+ frozen_entries: Object.keys(frozenEntriesHashes).length,
326
+ drift: drift,
327
+ };
328
+ }
329
+
330
+ // ─── Helpers ──────────────────────────────────────────────────────────
331
+
332
+ function sha256(s) {
333
+ return createHash('sha256').update(String(s)).digest('hex');
334
+ }
335
+
336
+ async function writeCachedRender(cacheKeyDir, rendered) {
337
+ await mkdir(cacheKeyDir, { recursive: true });
338
+ await writeFile(join(cacheKeyDir, 'meta.json'),
339
+ JSON.stringify({ schemaKind: rendered.schemaKind }, null, 2) + '\n', 'utf-8');
340
+ await writeFile(join(cacheKeyDir, 'caption.txt'), rendered.caption, 'utf-8');
341
+ await writeFile(join(cacheKeyDir, 'prompt.j2'), rendered.prompt, 'utf-8');
342
+ await writeFile(join(cacheKeyDir, 'context.md'), rendered.context, 'utf-8');
343
+ }
344
+
345
+ async function readCachedRender(cacheKeyDir) {
346
+ const meta = JSON.parse(await readFile(join(cacheKeyDir, 'meta.json'), 'utf-8'));
347
+ const [caption, prompt, context] = await Promise.all([
348
+ readFile(join(cacheKeyDir, 'caption.txt'), 'utf-8'),
349
+ readFile(join(cacheKeyDir, 'prompt.j2'), 'utf-8'),
350
+ readFile(join(cacheKeyDir, 'context.md'), 'utf-8'),
351
+ ]);
352
+ return { schemaKind: meta.schemaKind, caption, prompt, context };
353
+ }
354
+
355
+ /**
356
+ * Detect if any schema file's version has changed since the last build.
357
+ * Returns true when a full rebuild is warranted.
358
+ */
359
+ async function detectSchemaChange(projectRoot, schemas, explicitFull) {
360
+ if (explicitFull) return false; // --full already forces rebuild; no need to also flag change
361
+ const snapshotPath = join(projectRoot, 'canon-build', '.cache', '_schema-versions.json');
362
+ if (!existsSync(snapshotPath)) return true; // first run = full rebuild
363
+ try {
364
+ const prev = JSON.parse(await readFile(snapshotPath, 'utf-8'));
365
+ for (const [name, info] of Object.entries(schemas)) {
366
+ if (prev[name] !== info.version) return true;
367
+ }
368
+ return false;
369
+ } catch {
370
+ return true; // corrupted snapshot = full rebuild
371
+ }
372
+ }
373
+
374
+ async function writeLatestSchemaVersions(projectRoot, versions) {
375
+ const path = join(projectRoot, 'canon-build', '.cache', '_schema-versions.json');
376
+ await mkdir(dirname(path), { recursive: true });
377
+ await writeFile(path, JSON.stringify(versions, null, 2) + '\n', 'utf-8');
378
+ }
379
+
380
+ /**
381
+ * Load the previous build's manifest (if any) so drift can be computed
382
+ * against the most-recent frozen_entries_hashes stamp. Returns null if
383
+ * no prior build exists.
384
+ */
385
+ async function loadPreviousManifest(projectRoot, currentOutputDir) {
386
+ const canonBuildDir = join(projectRoot, 'canon-build');
387
+ if (!existsSync(canonBuildDir)) return null;
388
+ let candidates;
389
+ try { candidates = readdirSync(canonBuildDir); } catch { return null; }
390
+ let latest = null;
391
+ let latestTime = 0;
392
+ for (const name of candidates) {
393
+ const full = join(canonBuildDir, name);
394
+ if (full === currentOutputDir) continue;
395
+ if (name.startsWith('.') || name.includes('.tmp')) continue;
396
+ const manifestPath = join(full, 'manifest.json');
397
+ if (!existsSync(manifestPath)) continue;
398
+ try {
399
+ const m = JSON.parse(readFileSync(manifestPath, 'utf-8'));
400
+ const t = new Date(m.built_at || 0).getTime();
401
+ if (t > latestTime) { latestTime = t; latest = m; }
402
+ } catch { /* skip */ }
403
+ }
404
+ return latest;
405
+ }
406
+
407
+ /**
408
+ * Resolve generated_from: git HEAD SHA for the canon_root's repo, or a
409
+ * content-sha fallback computed over all entry contents when canon_root is
410
+ * not under git control (greek-rpg today).
411
+ */
412
+ function resolveGeneratedFrom(canonRoot) {
413
+ try {
414
+ const out = execSync('git rev-parse HEAD', {
415
+ cwd: canonRoot,
416
+ stdio: ['ignore', 'pipe', 'ignore'],
417
+ encoding: 'utf-8',
418
+ }).trim();
419
+ if (out && /^[0-9a-f]{7,}$/.test(out)) return out;
420
+ } catch {
421
+ // not a git repo — fall through
422
+ }
423
+ // Fallback: content-sha over the whole canon tree (stable, determinstic).
424
+ return `content-sha:${contentShaOfTree(canonRoot).slice(0, 16)}`;
425
+ }
426
+
427
+ function contentShaOfTree(root) {
428
+ const hash = createHash('sha256');
429
+ if (!existsSync(root)) return hash.digest('hex');
430
+ walkFiles(root, (absPath) => {
431
+ hash.update(relative(root, absPath).replace(/\\/g, '/'));
432
+ hash.update('\0');
433
+ try { hash.update(readFileSync(absPath)); } catch {}
434
+ hash.update('\0');
435
+ });
436
+ return hash.digest('hex');
437
+ }
438
+
439
+ function walkFiles(dir, cb) {
440
+ let entries;
441
+ try { entries = readdirSync(dir); } catch { return; }
442
+ entries.sort();
443
+ for (const name of entries) {
444
+ const full = join(dir, name);
445
+ let st;
446
+ try { st = statSync(full); } catch { continue; }
447
+ if (st.isDirectory()) walkFiles(full, cb);
448
+ else if (st.isFile()) cb(full);
449
+ }
450
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Shared helpers for per-schema emitters.
3
+ *
4
+ * Emitters produce three outputs per canon entry:
5
+ * - caption: the dataset.jsonl caption string (reads frontmatter fields ONLY)
6
+ * - prompt: the prompts/<id>.j2 template source (reads frontmatter ONLY)
7
+ * - context: the context/<id>.md body (reads frontmatter + body — body is the
8
+ * one place canon prose is allowed to land)
9
+ *
10
+ * The strict separation between caption/prompt (frontmatter-only) and context
11
+ * (frontmatter + body) enforces the captions-are-load-bearing invariant: MD
12
+ * body prose can never splice into a training caption. See
13
+ * memory/feedback_captions_are_load_bearing.md.
14
+ */
15
+
16
+ /**
17
+ * Default per-schema context length cap (lines).
18
+ */
19
+ export const DEFAULT_CONTEXT_MAX_LINES = 300;
20
+
21
+ /**
22
+ * Resolve the max_lines cap for a schema.
23
+ * Looks up context_limits[schemaKind].max_lines, falling back to default.
24
+ */
25
+ export function resolveContextMaxLines(schemaKind, contextLimits) {
26
+ const specific = contextLimits?.[schemaKind]?.max_lines;
27
+ if (typeof specific === 'number' && specific > 0) return specific;
28
+ const def = contextLimits?.default?.max_lines;
29
+ if (typeof def === 'number' && def > 0) return def;
30
+ return DEFAULT_CONTEXT_MAX_LINES;
31
+ }
32
+
33
+ /**
34
+ * Build the frontmatter block for a context/<id>.md file.
35
+ *
36
+ * Stamps provenance for audit: entity_id, schema_kind, generated_from, entry_hash.
37
+ */
38
+ export function contextFrontmatter({ entityId, schemaKind, generatedFrom, entryHash }) {
39
+ const lines = [
40
+ '---',
41
+ `entity_id: ${entityId}`,
42
+ `schema_kind: ${schemaKind}`,
43
+ `generated_from: ${generatedFrom}`,
44
+ `entry_hash: ${entryHash}`,
45
+ '---',
46
+ '',
47
+ ];
48
+ return lines.join('\n');
49
+ }
50
+
51
+ /**
52
+ * Render an H2 section with a heading + bullet items or free text.
53
+ *
54
+ * Skips rendering entirely when value is null/undefined/empty.
55
+ */
56
+ export function renderSection(heading, content) {
57
+ if (content == null) return '';
58
+ if (Array.isArray(content)) {
59
+ if (content.length === 0) return '';
60
+ const bullets = content
61
+ .map((item) => (typeof item === 'string' ? `- ${item}` : `- ${JSON.stringify(item)}`))
62
+ .join('\n');
63
+ return `## ${heading}\n${bullets}\n\n`;
64
+ }
65
+ if (typeof content === 'object') {
66
+ const entries = Object.entries(content).filter(([, v]) => v != null && v !== '');
67
+ if (entries.length === 0) return '';
68
+ const bullets = entries
69
+ .map(([k, v]) => `- ${k}: ${Array.isArray(v) ? v.join(', ') : v}`)
70
+ .join('\n');
71
+ return `## ${heading}\n${bullets}\n\n`;
72
+ }
73
+ const s = String(content).trim();
74
+ if (!s) return '';
75
+ return `## ${heading}\n${s}\n\n`;
76
+ }
77
+
78
+ /**
79
+ * Render an array of pairs as a key-value bullet list under an H2.
80
+ * Skips the whole section if every value is empty.
81
+ */
82
+ export function renderKeyValueSection(heading, pairs) {
83
+ const kept = pairs.filter(([, v]) => v != null && v !== '' && !(Array.isArray(v) && v.length === 0));
84
+ if (kept.length === 0) return '';
85
+ const bullets = kept
86
+ .map(([k, v]) => `- ${k}: ${Array.isArray(v) ? v.join(', ') : v}`)
87
+ .join('\n');
88
+ return `## ${heading}\n${bullets}\n\n`;
89
+ }
90
+
91
+ /**
92
+ * Compute the negative-prompt base string for an entry.
93
+ *
94
+ * Joins forbidden_inputs with ", " — the exact shape consumed by the Jinja
95
+ * template at render time. Project-level negatives are injected at render
96
+ * time by the ComfyUI adapter, not baked into the emitted template.
97
+ */
98
+ export function renderNegativeBase(entry) {
99
+ const tokens = Array.isArray(entry.forbidden_inputs) ? entry.forbidden_inputs : [];
100
+ return tokens.join(', ');
101
+ }
102
+
103
+ /**
104
+ * Enforce the context length cap. Throws a structured error if the rendered
105
+ * body exceeds max_lines.
106
+ *
107
+ * The error is returned via a callback rather than thrown directly so the
108
+ * orchestrator can batch all failures and surface them together instead of
109
+ * failing on the first offender.
110
+ */
111
+ export function checkContextLength(body, maxLines, entityId, schemaKind) {
112
+ const lineCount = body.split('\n').length;
113
+ if (lineCount > maxLines) {
114
+ return {
115
+ entityId,
116
+ schemaKind,
117
+ lineCount,
118
+ maxLines,
119
+ };
120
+ }
121
+ return null;
122
+ }