@mcptoolshop/style-dataset-lab 3.1.0 → 3.3.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/CHANGELOG.md +61 -0
- package/README.es.md +96 -63
- package/README.fr.md +97 -64
- package/README.hi.md +108 -77
- package/README.it.md +97 -64
- package/README.ja.md +105 -72
- package/README.md +33 -0
- package/README.pt-BR.md +94 -61
- package/README.zh.md +98 -65
- package/bin/sdlab.js +71 -0
- package/lib/adapters/ai-toolkit.js +209 -0
- package/lib/adapters/comfyui-runner.js +36 -2
- package/lib/adapters/comfyui-workflows.js +133 -2
- package/lib/adapters/diffusers-lora.js +46 -66
- package/lib/adapters/generic-image-caption.js +22 -50
- package/lib/canon-build/build.js +450 -0
- package/lib/canon-build/emitters/base.js +122 -0
- package/lib/canon-build/emitters/character.js +167 -0
- package/lib/canon-build/emitters/deity.js +165 -0
- package/lib/canon-build/emitters/faction.js +161 -0
- package/lib/canon-build/emitters/index.js +38 -0
- package/lib/canon-build/emitters/location.js +153 -0
- package/lib/canon-build/emitters/monster.js +131 -0
- package/lib/canon-build/emitters/relic.js +142 -0
- package/lib/canon-build/emitters/ship.js +154 -0
- package/lib/canon-build/emitters/species.js +165 -0
- package/lib/canon-build/frontmatter.js +76 -0
- package/lib/canon-build/lane-resolve.js +64 -0
- package/lib/canon-build/load-config.js +117 -0
- package/lib/canon-build/load-entry.js +62 -0
- package/lib/canon-build/load-schema.js +46 -0
- package/lib/captions.js +197 -0
- package/lib/config.js +19 -0
- package/lib/critique-engine.js +46 -1
- package/lib/critique-render.js +45 -0
- package/lib/freeze-events.js +109 -0
- package/lib/freeze-gate.js +208 -0
- package/lib/freeze-stamp.js +161 -0
- package/lib/rows.js +228 -0
- package/lib/run-manifest.js +308 -0
- package/lib/snapshot.js +7 -1
- package/lib/training-adapters.js +5 -0
- package/lib/training-packages.js +24 -18
- package/lib/training-profiles.js +33 -0
- package/lib/verifier.js +67 -0
- package/package.json +12 -3
- package/scripts/_batch_cut.py +71 -0
- package/scripts/_seam_cutout.py +74 -0
- package/scripts/canon-build.js +88 -0
- package/scripts/canon-drift.js +193 -0
- package/scripts/canon-freeze-status.js +85 -0
- package/scripts/canon-freeze.js +211 -0
- package/scripts/canon-unfreeze.js +137 -0
- package/scripts/critique.js +32 -7
- package/scripts/curate.js +33 -0
- package/scripts/generate-controlnet.js +12 -0
- package/scripts/generate-identity.js +20 -4
- package/scripts/generate-ipadapter.js +11 -0
- package/scripts/generate.js +262 -102
- package/scripts/qwen_generate.py +327 -0
- package/scripts/run-generate.js +3 -0
|
@@ -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
|
+
}
|