@yojahny/wp-design-library 0.1.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 (74) hide show
  1. package/.dockerignore +11 -0
  2. package/.env.example +7 -0
  3. package/Dockerfile +27 -0
  4. package/LICENSE +21 -0
  5. package/README.md +382 -0
  6. package/bin/library.mjs +19 -0
  7. package/docker/entrypoint.sh +11 -0
  8. package/docker-compose.yml +18 -0
  9. package/entries/.gitkeep +0 -0
  10. package/entries/ais-community-dark-depth/entry.md +76 -0
  11. package/entries/ais-community-dark-depth/strip.png +0 -0
  12. package/package.json +26 -0
  13. package/src/cli/add.mjs +163 -0
  14. package/src/cli/check.mjs +26 -0
  15. package/src/cli/export.mjs +41 -0
  16. package/src/cli/index.mjs +12 -0
  17. package/src/cli/refresh.mjs +63 -0
  18. package/src/cli/save.mjs +16 -0
  19. package/src/cli/serve.mjs +64 -0
  20. package/src/cli/ui.mjs +18 -0
  21. package/src/entry.mjs +69 -0
  22. package/src/index/build.mjs +78 -0
  23. package/src/index/embed.mjs +69 -0
  24. package/src/index/query.mjs +103 -0
  25. package/src/index/schema-vec.sql +4 -0
  26. package/src/index/schema.sql +10 -0
  27. package/src/ingest/draft.mjs +32 -0
  28. package/src/ingest/frames.mjs +93 -0
  29. package/src/ingest/measure.mjs +158 -0
  30. package/src/ingest/save.mjs +11 -0
  31. package/src/ingest/url-guard.mjs +76 -0
  32. package/src/mcp/http.mjs +72 -0
  33. package/src/mcp/prompts.mjs +147 -0
  34. package/src/mcp/resources.mjs +24 -0
  35. package/src/mcp/server.mjs +28 -0
  36. package/src/mcp/tools.mjs +152 -0
  37. package/src/paths.mjs +21 -0
  38. package/src/ui/app.js +34 -0
  39. package/src/ui/build.mjs +122 -0
  40. package/src/ui/serve.mjs +38 -0
  41. package/src/ui/templates/entry.html +31 -0
  42. package/src/ui/templates/index.html +32 -0
  43. package/src/vocab.mjs +34 -0
  44. package/tests/README.md +21 -0
  45. package/tests/checks/cli-check.sh +13 -0
  46. package/tests/checks/docker.sh +10 -0
  47. package/tests/checks/entry.sh +55 -0
  48. package/tests/checks/export.sh +16 -0
  49. package/tests/checks/fetch-on-start.sh +26 -0
  50. package/tests/checks/frames-dense.sh +26 -0
  51. package/tests/checks/http.sh +22 -0
  52. package/tests/checks/hygiene.sh +29 -0
  53. package/tests/checks/inbox.sh +27 -0
  54. package/tests/checks/index-degrade.sh +32 -0
  55. package/tests/checks/index.sh +36 -0
  56. package/tests/checks/ingest-mp4.sh +47 -0
  57. package/tests/checks/ingest.sh +39 -0
  58. package/tests/checks/licence-gate.sh +24 -0
  59. package/tests/checks/mcp-stdout.sh +31 -0
  60. package/tests/checks/measure.sh +75 -0
  61. package/tests/checks/minors.sh +84 -0
  62. package/tests/checks/prompt-add-entry.sh +28 -0
  63. package/tests/checks/resources.sh +44 -0
  64. package/tests/checks/rrf.sh +120 -0
  65. package/tests/checks/seed-sync.sh +22 -0
  66. package/tests/checks/similar.sh +62 -0
  67. package/tests/checks/ui-build.sh +59 -0
  68. package/tests/checks/vocab.sh +30 -0
  69. package/tests/fixtures/entry-ok/entry.md +41 -0
  70. package/tests/fixtures/entry-ok/strip.png +0 -0
  71. package/tests/fixtures/page/index.html +47 -0
  72. package/tests/fixtures/three-frame.webp +0 -0
  73. package/tests/run.sh +10 -0
  74. package/vocab.yaml +16 -0
@@ -0,0 +1,163 @@
1
+ import path from 'node:path';
2
+ import sharp from 'sharp';
3
+ import { paths } from '../paths.mjs';
4
+ import { sampleFrames, compositeStrip } from '../ingest/frames.mjs';
5
+ import { writeDraft } from '../ingest/draft.mjs';
6
+ import { measure } from '../ingest/measure.mjs';
7
+ import { loadVocab, resolveTerm } from '../vocab.mjs';
8
+
9
+ function opt(args, name) { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }
10
+
11
+ const MEDIA = /\.(webp|gif|mp4|mov|webm|mkv)$/i;
12
+ const URL_RE = /^(https?:\/\/|file:\/\/)/;
13
+ function slugify(name) { return name.replace(/\.[^.]+$/, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); }
14
+
15
+ // Composites recorded scroll-stop screenshots into a strip.png the same way sampleFrames
16
+ // does for video/animated-image input.
17
+ async function stripFromFrames(framePaths, outPng) {
18
+ const tiles = await Promise.all(framePaths.map((f) => sharp(f).png().toBuffer()));
19
+ const first = await sharp(framePaths[0]).metadata();
20
+ await compositeStrip(tiles, first.width, first.height, outPng);
21
+ }
22
+
23
+ // URL/file:// input: measure the live page with system Chrome instead of sampling
24
+ // frames from a recording. Scratch-dir-first, same as the frame path: a total
25
+ // failure (nothing captured at all) must leave nothing under entries/.
26
+ async function addFromUrl({ p, input, slug, title, source, dir, record }) {
27
+ const fs = await import('node:fs');
28
+ const os = await import('node:os');
29
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'wpdl-add-'));
30
+ let result;
31
+ try {
32
+ result = await measure(input, scratch, { record });
33
+ } catch (err) {
34
+ fs.rmSync(scratch, { recursive: true, force: true });
35
+ throw err;
36
+ }
37
+ if (!result.ok && !result.frames?.length) {
38
+ fs.rmSync(scratch, { recursive: true, force: true });
39
+ return { refused: result.error };
40
+ }
41
+
42
+ const today = new Date().toISOString().slice(0, 10);
43
+ const tool = `wp-design-library@${p.version}`;
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ const strip = path.join(dir, 'strip.png');
46
+ let captured, palette, type;
47
+ if (result.ok) {
48
+ captured = { method: record ? 'both' : 'measured', at: today, tool };
49
+ const t = result.measure.tokens;
50
+ palette = { canvas: t.canvas || '', ink: t.ink || '', accent: t.accent || '' };
51
+ type = { display: t.fontDisplay || '', body: t.fontBody || '' };
52
+ if (record && result.frames?.length) await stripFromFrames(result.frames, strip);
53
+ else fs.copyFileSync(result.screenshots[1440], strip);
54
+ for (const f of ['measure.json', 'screen-1440.png', 'screen-390.png']) {
55
+ fs.copyFileSync(path.join(scratch, f), path.join(dir, f));
56
+ }
57
+ } else {
58
+ // record succeeded but the token/section measurement itself failed: keep the
59
+ // frames (spec §11), label the method accordingly, and record why.
60
+ captured = { method: 'frames', at: today, tool, error: result.error };
61
+ await stripFromFrames(result.frames, strip);
62
+ }
63
+ fs.rmSync(scratch, { recursive: true, force: true });
64
+
65
+ const d = writeDraft({ entriesDir: p.entries, slug, title, source: { ...source, url: source.url ?? input }, media: {}, captured, palette, type });
66
+ return { dir: d.dir, strip, blanks: d.blanks, measured: result.ok };
67
+ }
68
+
69
+ export async function addEntry({ input, slug, title, source, force = false, frames, record = false }) {
70
+ const p = paths();
71
+ if (!input || !slug || !title || !source?.kind || !source?.license) throw new Error('add: input, slug, title, --source and --license are required');
72
+ // Refused here (draft time), not first at save: resolveTerm is the single vocab gate
73
+ // both `add` and `--inbox` route through, since addInbox calls addEntry per file.
74
+ if (resolveTerm(loadVocab(p.vocab), 'source', source.kind) === null) {
75
+ return { refused: `source.kind: unknown source term "${source.kind}"` };
76
+ }
77
+ const dir = path.join(p.entries, slug);
78
+ const { mkdirSync, mkdtempSync, existsSync, renameSync, copyFileSync, unlinkSync, rmSync } = await import('node:fs');
79
+ const os = await import('node:os');
80
+ if (existsSync(path.join(dir, 'entry.md')) && !force) {
81
+ return { refused: `entry ${slug} exists; pass force to overwrite` };
82
+ }
83
+ if (URL_RE.test(input)) {
84
+ return addFromUrl({ p, input, slug, title, source, dir, record });
85
+ }
86
+ // Sample into a scratch dir first: a refusal must leave nothing under entries/.
87
+ const scratch = mkdtempSync(path.join(os.tmpdir(), 'wpdl-add-'));
88
+ const scratchStrip = path.join(scratch, 'strip.png');
89
+ let media;
90
+ try {
91
+ media = await sampleFrames(input, scratchStrip, { frames });
92
+ } catch (err) {
93
+ rmSync(scratch, { recursive: true, force: true });
94
+ if (err.message?.startsWith('ffmpeg')) return { refused: err.message };
95
+ throw err;
96
+ }
97
+ mkdirSync(dir, { recursive: true });
98
+ const strip = path.join(dir, 'strip.png');
99
+ try {
100
+ renameSync(scratchStrip, strip);
101
+ } catch {
102
+ copyFileSync(scratchStrip, strip);
103
+ unlinkSync(scratchStrip);
104
+ }
105
+ rmSync(scratch, { recursive: true, force: true });
106
+ const captured = { method: 'frames', at: new Date().toISOString().slice(0, 10), tool: `wp-design-library@${p.version}` };
107
+ const d = writeDraft({ entriesDir: p.entries, slug, title, source, media: { frames: media.frames, duration_ms: media.durationMs }, captured });
108
+ return { dir: d.dir, strip, blanks: d.blanks, frames: media.frames };
109
+ }
110
+
111
+ export async function addInbox({ source }) {
112
+ const p = paths();
113
+ const fs = await import('node:fs');
114
+ const inbox = path.join(p.data, 'inbox');
115
+ const out = [];
116
+ if (!fs.existsSync(inbox)) return out;
117
+ const seenSlugs = new Map(); // ponytail: slug -> first file this run, names the collision
118
+ for (const file of fs.readdirSync(inbox).filter((f) => MEDIA.test(f)).sort()) {
119
+ const slug = slugify(file);
120
+ if (!slug) { out.push({ file, slug, status: 'refused', reason: 'cannot derive a slug from the file name; pass --slug' }); continue; }
121
+ if (seenSlugs.has(slug)) { out.push({ file, slug, status: 'refused', reason: `slug ${slug} collides with ${seenSlugs.get(slug)} in this run` }); continue; }
122
+ seenSlugs.set(slug, file);
123
+ const full = path.join(inbox, file);
124
+ if (fs.existsSync(path.join(p.entries, slug, 'entry.md'))) { out.push({ file, slug, status: 'skipped', reason: 'entry exists' }); continue; }
125
+ let meta = {};
126
+ const side = full + '.json';
127
+ if (fs.existsSync(side)) { try { meta = JSON.parse(fs.readFileSync(side, 'utf8')); } catch (e) { out.push({ file, slug, status: 'refused', reason: 'sidecar is not JSON' }); continue; } }
128
+ if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) { out.push({ file, slug, status: 'refused', reason: 'sidecar must be a JSON object' }); continue; }
129
+ const kind = meta.kind ?? source?.kind, license = meta.license ?? source?.license;
130
+ if (!kind || !license) { out.push({ file, slug, status: 'refused', reason: 'no source kind/license: add a <file>.json sidecar or pass --source and --license' }); continue; }
131
+ try {
132
+ const r = await addEntry({ input: full, slug, title: meta.title ?? slug.replace(/-/g, ' '), source: { kind, url: meta.url ?? source?.url, license } });
133
+ out.push(r.refused ? { file, slug, status: 'refused', reason: r.refused } : { file, slug, status: 'drafted', frames: r.frames });
134
+ } catch (e) { out.push({ file, slug, status: 'refused', reason: e.message }); }
135
+ }
136
+ return out;
137
+ }
138
+
139
+ export async function run(args) {
140
+ if (args.includes('--inbox')) {
141
+ const out = await addInbox({ source: { kind: opt(args, '--source'), url: opt(args, '--url'), license: opt(args, '--license') } });
142
+ for (const line of out) process.stdout.write(JSON.stringify(line) + '\n');
143
+ return out.some((l) => l.status === 'refused') ? 1 : 0;
144
+ }
145
+ const framesRaw = opt(args, '--frames');
146
+ let frames;
147
+ if (framesRaw !== undefined) {
148
+ frames = Number(framesRaw);
149
+ if (!Number.isInteger(frames) || frames < 6 || frames > 36) {
150
+ process.stdout.write(JSON.stringify({ refused: '--frames must be an integer between 6 and 36' }) + '\n');
151
+ return 1;
152
+ }
153
+ }
154
+ const out = await addEntry({
155
+ input: args[0], slug: opt(args, '--slug'), title: opt(args, '--title'),
156
+ source: { kind: opt(args, '--source'), url: opt(args, '--url'), license: opt(args, '--license') },
157
+ force: args.includes('--force'),
158
+ frames,
159
+ record: args.includes('--record'),
160
+ });
161
+ process.stdout.write(JSON.stringify(out) + '\n');
162
+ return out.refused ? 1 : 0;
163
+ }
@@ -0,0 +1,26 @@
1
+ import path from 'node:path';
2
+ import { paths } from '../paths.mjs';
3
+ import { loadVocab } from '../vocab.mjs';
4
+ import { listEntries, parseEntry, validateEntry } from '../entry.mjs';
5
+ import { fetchModels, modelDirs } from '../index/embed.mjs';
6
+
7
+ export async function run(args = []) {
8
+ const p = paths();
9
+ if (args.includes('--fetch-models')) {
10
+ await fetchModels(p.models);
11
+ const d = modelDirs(p.models);
12
+ process.stdout.write(`model: ${d.text}\nmodel: ${d.image}\n`);
13
+ return 0;
14
+ }
15
+ const vocab = loadVocab(p.vocab);
16
+ let bad = 0, n = 0;
17
+ for (const dir of listEntries(p.entries)) {
18
+ n++;
19
+ let e;
20
+ try { e = parseEntry(dir); } catch (err) { bad++; process.stderr.write(`${path.basename(dir)}: ${err.message}\n`); continue; }
21
+ const r = validateEntry(e, vocab);
22
+ if (!r.ok) { bad++; for (const m of r.errors) process.stderr.write(`${e.slug}: ${m}\n`); }
23
+ }
24
+ process.stdout.write(`${n - bad}/${n} entries ok\n`);
25
+ return bad ? 1 : 0;
26
+ }
@@ -0,0 +1,41 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { paths } from '../paths.mjs';
5
+ import { listEntries, parseEntry, validateEntry } from '../entry.mjs';
6
+ import { loadVocab } from '../vocab.mjs';
7
+
8
+ export async function run(args = []) {
9
+ const p = paths();
10
+ if (!fs.existsSync(p.entries)) { process.stderr.write('export: no entries directory\n'); return 1; }
11
+ const base = path.basename(p.entries);
12
+ let dirs = listEntries(p.entries);
13
+ let targets = [base];
14
+ if (args.includes('--saved-only')) {
15
+ const vocab = loadVocab(p.vocab);
16
+ let skipped = 0;
17
+ dirs = dirs.filter((dir) => {
18
+ let ok;
19
+ try { ok = validateEntry(parseEntry(dir), vocab).ok; } catch { ok = false; }
20
+ if (!ok) skipped++;
21
+ return ok;
22
+ });
23
+ targets = dirs.map((dir) => path.join(base, path.basename(dir)));
24
+ process.stderr.write(`skipped ${skipped} drafts\n`);
25
+ if (targets.length === 0) { process.stderr.write('export: no saved entries to export\n'); return 1; }
26
+ }
27
+ const n = dirs.length;
28
+ let tar;
29
+ try {
30
+ // ponytail: whole tar buffered in memory, 1 GiB ceiling; switch to spawn + pipe if the corpus ever approaches it.
31
+ tar = execFileSync('tar', ['-C', p.data, '-cf', '-', ...targets], { maxBuffer: 1 << 30 });
32
+ } catch (e) {
33
+ if (e.code === 'ENOENT') { process.stderr.write('export: tar is required and was not found on PATH\n'); return 1; }
34
+ const firstLine = e.stderr ? e.stderr.toString().split('\n').find((l) => l.length > 0) : undefined;
35
+ process.stderr.write(`export: tar failed: ${firstLine ?? e.message}\n`);
36
+ return 1;
37
+ }
38
+ process.stdout.write(tar);
39
+ process.stderr.write(`export: ${n} entries\n`);
40
+ return 0;
41
+ }
@@ -0,0 +1,12 @@
1
+ import { paths } from '../paths.mjs';
2
+ import { loadVocab } from '../vocab.mjs';
3
+ import { buildIndex } from '../index/build.mjs';
4
+
5
+ export async function run() {
6
+ const p = paths();
7
+ const r = await buildIndex({ entriesDir: p.entries, indexPath: p.index, vocab: loadVocab(p.vocab), models: p.models });
8
+ for (const m of r.refused) process.stderr.write(`refused ${m}\n`);
9
+ process.stdout.write(`indexed ${r.entries} entries, refused: ${r.refused.length}, arms: ${r.arms.join(',')}\n`);
10
+ for (const s of r.skipped) process.stderr.write(`embeddings: skipped (${s.reason})\n`);
11
+ return 0;
12
+ }
@@ -0,0 +1,63 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import yaml from 'js-yaml';
5
+ import { paths } from '../paths.mjs';
6
+ import { parseEntry } from '../entry.mjs';
7
+ import { loadVocab } from '../vocab.mjs';
8
+ import { buildIndex } from '../index/build.mjs';
9
+ import { measure } from '../ingest/measure.mjs';
10
+ import { refuseUnsafeUrl } from '../ingest/url-guard.mjs';
11
+
12
+ // Re-measures an entry that was drafted from a live URL: rewrites measure.json and
13
+ // the two screenshots, bumps captured.at, and reindexes. Every judgement field
14
+ // (roles, feel, palette, type, motion, body…) is untouched — only measure.json and
15
+ // captured.at change. `transport` is undefined for the CLI, 'http' for the hosted
16
+ // MCP server; the SSRF guard only consults DNS in the latter case.
17
+ export async function refreshEntry({ slug, transport }) {
18
+ const p = paths();
19
+ const dir = path.join(p.entries, slug);
20
+ let e;
21
+ try { e = parseEntry(dir); } catch { return { refused: `no entry with slug ${slug}` }; }
22
+ const url = e.fm.source?.url;
23
+ const method = e.fm.captured?.method;
24
+ if (!url) return { refused: `${slug}: refresh needs source.url` };
25
+ if (method === 'frames') return { refused: `${slug}: refresh needs captured.method measured or both, not frames` };
26
+ const refusal = await refuseUnsafeUrl(url, { transport });
27
+ if (refusal) return { refused: refusal };
28
+
29
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'wpdl-refresh-'));
30
+ let result;
31
+ try {
32
+ result = await measure(url, scratch, {});
33
+ } catch (err) {
34
+ fs.rmSync(scratch, { recursive: true, force: true });
35
+ return { refused: `measure failed: ${err.message}` };
36
+ }
37
+ if (!result.ok) {
38
+ fs.rmSync(scratch, { recursive: true, force: true });
39
+ return { refused: result.error };
40
+ }
41
+ for (const f of ['measure.json', 'screen-1440.png', 'screen-390.png']) {
42
+ fs.copyFileSync(path.join(scratch, f), path.join(dir, f));
43
+ }
44
+ fs.rmSync(scratch, { recursive: true, force: true });
45
+
46
+ e.fm.captured.at = new Date().toISOString().slice(0, 10);
47
+ const body = e.body ? `\n${e.body}\n` : '';
48
+ fs.writeFileSync(path.join(dir, 'entry.md'), `---\n${yaml.dump(e.fm, { lineWidth: 120 })}---\n${body}`);
49
+
50
+ await buildIndex({ entriesDir: p.entries, indexPath: p.index, vocab: loadVocab(p.vocab), models: p.models });
51
+ return { ok: true, slug, captured: e.fm.captured };
52
+ }
53
+
54
+ export async function run(args) {
55
+ const slug = args[0];
56
+ if (!slug) {
57
+ process.stdout.write(JSON.stringify({ refused: 'refresh: slug is required' }) + '\n');
58
+ return 1;
59
+ }
60
+ const out = await refreshEntry({ slug });
61
+ process.stdout.write(JSON.stringify(out) + '\n');
62
+ return out.refused ? 1 : 0;
63
+ }
@@ -0,0 +1,16 @@
1
+ import path from 'node:path';
2
+ import { paths } from '../paths.mjs';
3
+ import { loadVocab } from '../vocab.mjs';
4
+ import { saveEntry } from '../ingest/save.mjs';
5
+
6
+ export async function run(args) {
7
+ const p = paths();
8
+ const r = await saveEntry({ dir: path.join(p.entries, args[0]), vocab: loadVocab(p.vocab), indexPath: p.index, entriesDir: p.entries, models: p.models });
9
+ if (!r.ok) {
10
+ for (const m of r.errors) process.stderr.write(`${r.slug}: ${m}\n`);
11
+ process.stdout.write(JSON.stringify(r) + '\n');
12
+ return 1;
13
+ }
14
+ process.stdout.write(JSON.stringify(r) + '\n');
15
+ return 0;
16
+ }
@@ -0,0 +1,64 @@
1
+ async function syncSeed(p) {
2
+ const fs = await import('node:fs');
3
+ // Every start: copy seed entries the volume does not have yet. Never overwrite —
4
+ // the volume may hold hosted ingests and edits the image knows nothing about.
5
+ if (p.seed !== p.entries && fs.existsSync(p.seed)) {
6
+ const path = await import('node:path');
7
+ fs.mkdirSync(p.entries, { recursive: true });
8
+ let copied = 0;
9
+ for (const d of fs.readdirSync(p.seed, { withFileTypes: true })) {
10
+ if (!d.isDirectory() || !fs.existsSync(path.join(p.seed, d.name, 'entry.md'))) continue;
11
+ const dest = path.join(p.entries, d.name);
12
+ if (fs.existsSync(dest)) continue;
13
+ fs.cpSync(path.join(p.seed, d.name), dest, { recursive: true });
14
+ copied++;
15
+ }
16
+ process.stderr.write(`seed: copied ${copied} new entries into ${p.entries}\n`);
17
+ }
18
+ }
19
+
20
+ async function buildTheIndex(p) {
21
+ const { loadVocab } = await import('../vocab.mjs');
22
+ const { buildIndex } = await import('../index/build.mjs');
23
+ const r = await buildIndex({ entriesDir: p.entries, indexPath: p.index, vocab: loadVocab(p.vocab), models: p.models });
24
+ process.stderr.write(`index: ${r.entries} entries, refused ${r.refused.length}\n`);
25
+ for (const s of r.skipped) process.stderr.write(`embeddings: skipped (${s.reason})\n`);
26
+ }
27
+
28
+ async function fetchModelsOnStart(p) {
29
+ // The API must not depend on this: a failure (offline container, first deploy
30
+ // before the operator flips the flag back) is logged and swallowed, never thrown.
31
+ process.stderr.write('models: fetch start\n');
32
+ try {
33
+ const { fetchModels } = await import('../index/embed.mjs');
34
+ await fetchModels(p.models);
35
+ process.stderr.write('models: fetch finished\n');
36
+ } catch (err) {
37
+ process.stderr.write(`models: fetch failed, continuing: ${err.stack || err}\n`);
38
+ }
39
+ }
40
+
41
+ export async function run(args) {
42
+ const { paths } = await import('../paths.mjs');
43
+ const p = paths();
44
+ if (args.includes('--http')) {
45
+ const path = await import('node:path');
46
+ await syncSeed(p);
47
+ if (process.env.LIBRARY_FETCH_MODELS === '1') await fetchModelsOnStart(p);
48
+ await buildTheIndex(p);
49
+ try {
50
+ const { buildUi } = await import('../ui/build.mjs');
51
+ await buildUi({ indexPath: p.index, entriesDir: p.entries, outDir: path.join(p.data, 'ui') });
52
+ } catch (err) {
53
+ // the gallery is a convenience for a human; the MCP API must not depend on it
54
+ process.stderr.write(`ui: build failed, serving without it: ${err.stack || err}\n`);
55
+ }
56
+ const { serveHttp } = await import('../mcp/http.mjs');
57
+ return serveHttp();
58
+ }
59
+ await syncSeed(p);
60
+ await buildTheIndex(p);
61
+ const { serveStdio } = await import('../mcp/server.mjs');
62
+ await serveStdio();
63
+ return new Promise(() => {}); // stay alive until the client closes stdin
64
+ }
package/src/cli/ui.mjs ADDED
@@ -0,0 +1,18 @@
1
+ import path from 'node:path';
2
+ import { paths } from '../paths.mjs';
3
+ import { buildUi } from '../ui/build.mjs';
4
+
5
+ function opt(args, name) { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }
6
+
7
+ export async function run(args = []) {
8
+ const p = paths();
9
+ const outDir = path.join(p.data, 'ui');
10
+ const r = await buildUi({ indexPath: p.index, entriesDir: p.entries, outDir });
11
+ process.stdout.write(`ui: ${r.entries} entries, ${r.drafts} drafts, built into ${outDir}\n`);
12
+ if (args.includes('--serve')) {
13
+ const port = Number(opt(args, '--port') ?? 4180);
14
+ const { serveStatic } = await import('../ui/serve.mjs');
15
+ return serveStatic(outDir, port);
16
+ }
17
+ return 0;
18
+ }
package/src/entry.mjs ADDED
@@ -0,0 +1,69 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import yaml from 'js-yaml';
4
+ import { resolveTerm } from './vocab.mjs';
5
+
6
+ export const REQUIRED_BLANKS = ['roles', 'feel', 'motion.devices', 'motion.notes', 'type.display', 'type.body', 'body',
7
+ 'palette.canvas', 'palette.ink', 'palette.accent'];
8
+ const REQUIRED = ['slug', 'title', 'tier', 'source.kind', 'source.license', 'captured.method', 'captured.at', 'captured.tool',
9
+ ...REQUIRED_BLANKS];
10
+
11
+ export function parseEntry(dir) {
12
+ const file = path.join(dir, 'entry.md');
13
+ const text = fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
14
+ const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(text);
15
+ if (!m) throw new Error(`${file}: no frontmatter`);
16
+ const fm = yaml.load(m[1]) ?? {};
17
+ return { slug: fm.slug, dir, fm, body: m[2].trim() };
18
+ }
19
+
20
+ function get(obj, dotted) {
21
+ return dotted.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
22
+ }
23
+ function blank(v) {
24
+ return v == null || v === '' || (Array.isArray(v) && v.length === 0);
25
+ }
26
+
27
+ export function validateEntry(entry, vocab) {
28
+ const errors = [];
29
+ const { fm } = entry;
30
+ if (fm.slug !== path.basename(entry.dir)) errors.push('slug: must equal the directory name');
31
+ const view = { ...fm, body: entry.body };
32
+ for (const k of REQUIRED) if (blank(get(view, k))) errors.push(`${k}: required, blank`);
33
+
34
+ const facetOf = { roles: 'role', feel: 'feel' };
35
+ for (const [key, facet] of Object.entries(facetOf)) {
36
+ if (!Array.isArray(fm[key])) continue;
37
+ fm[key] = fm[key].map((t) => {
38
+ const c = resolveTerm(vocab, facet, t);
39
+ if (c === null) errors.push(`${key}: unknown ${facet} term "${t}"`);
40
+ return c ?? t;
41
+ });
42
+ }
43
+ if (Array.isArray(fm.motion?.devices)) {
44
+ fm.motion.devices = fm.motion.devices.map((t) => {
45
+ const c = resolveTerm(vocab, 'motion', t);
46
+ if (c === null) errors.push(`motion.devices: unknown motion term "${t}"`);
47
+ return c ?? t;
48
+ });
49
+ }
50
+ if (fm.source?.kind && resolveTerm(vocab, 'source', fm.source.kind) === null)
51
+ errors.push(`source.kind: unknown source term "${fm.source.kind}"`);
52
+ if (fm.tier && resolveTerm(vocab, 'tier', fm.tier) === null)
53
+ errors.push(`tier: unknown tier term "${fm.tier}"`);
54
+
55
+ if (fm.tier === 'ported') {
56
+ if (['paid', 'public-site'].includes(fm.source?.kind))
57
+ errors.push(`licence gate: tier ported is not allowed with source.kind ${fm.source.kind}`);
58
+ if (blank(fm.ported_from)) errors.push('ported_from: required for tier ported');
59
+ }
60
+ if (!fs.existsSync(path.join(entry.dir, 'strip.png'))) errors.push('strip.png: missing');
61
+ return { ok: errors.length === 0, errors };
62
+ }
63
+
64
+ export function listEntries(entriesDir) {
65
+ if (!fs.existsSync(entriesDir)) return [];
66
+ return fs.readdirSync(entriesDir, { withFileTypes: true })
67
+ .filter((d) => d.isDirectory() && fs.existsSync(path.join(entriesDir, d.name, 'entry.md')))
68
+ .map((d) => path.join(entriesDir, d.name));
69
+ }
@@ -0,0 +1,78 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { listEntries, parseEntry, validateEntry } from '../entry.mjs';
5
+ import { openIndex } from './query.mjs';
6
+ import { getEmbedder, absentReason } from './embed.mjs';
7
+
8
+ const DIR = path.dirname(fileURLToPath(import.meta.url));
9
+ const SCHEMA = fs.readFileSync(path.join(DIR, 'schema.sql'), 'utf8');
10
+ const SCHEMA_VEC = fs.readFileSync(path.join(DIR, 'schema-vec.sql'), 'utf8');
11
+
12
+ function isoDate(v) {
13
+ return v instanceof Date ? v.toISOString().slice(0, 10) : String(v).slice(0, 10);
14
+ }
15
+
16
+ export async function buildIndex({ entriesDir, indexPath, vocab, models }) {
17
+ fs.mkdirSync(path.dirname(indexPath), { recursive: true });
18
+ const tmpPath = indexPath + '.tmp';
19
+ // a killed build can leave a half-written .tmp behind; clear it so the open below always starts fresh
20
+ fs.rmSync(tmpPath, { force: true });
21
+ let db;
22
+ try {
23
+ db = openIndex(tmpPath, { readonly: false });
24
+ db.exec(SCHEMA);
25
+ if (db.vec) db.exec(SCHEMA_VEC);
26
+ const emb = await getEmbedder({ models });
27
+ const refused = [];
28
+ const insEntry = db.prepare(`INSERT INTO entries VALUES (@slug,@title,@tier,@source_kind,@captured_at,@dir,@body,@fm)`);
29
+ const insTag = db.prepare(`INSERT INTO entry_tags VALUES (?,?,?)`);
30
+ const insFts = db.prepare(`INSERT INTO entries_fts(slug,title,body,tags) VALUES (?,?,?,?)`);
31
+ const insVecText = db.vec ? db.prepare(`INSERT INTO vec_text(slug, embedding) VALUES (?,?)`) : null;
32
+ const insVecImage = db.vec ? db.prepare(`INSERT INTO vec_image(slug, embedding) VALUES (?,?)`) : null;
33
+ const tx = db.transaction((e, tags) => {
34
+ insEntry.run({ slug: e.slug, title: e.fm.title, tier: e.fm.tier, source_kind: e.fm.source.kind,
35
+ captured_at: isoDate(e.fm.captured.at), dir: e.dir, body: e.body, fm: JSON.stringify(e.fm) });
36
+ for (const [f, t] of tags) insTag.run(e.slug, f, t);
37
+ insFts.run(e.slug, e.fm.title, e.body, tags.map(([f, t]) => `${f}/${t}`).join(' '));
38
+ });
39
+ let n = 0;
40
+ for (const dir of listEntries(entriesDir)) {
41
+ let e;
42
+ try { e = parseEntry(dir); } catch (err) { refused.push(`${path.basename(dir)}: ${err.message}`); continue; }
43
+ const r = validateEntry(e, vocab);
44
+ if (!r.ok) { refused.push(`${e.slug}: ${r.errors.join('; ')}`); continue; }
45
+ const tags = [
46
+ ...e.fm.roles.map((t) => ['role', t]),
47
+ ...e.fm.feel.map((t) => ['feel', t]),
48
+ ...e.fm.motion.devices.map((t) => ['motion', t]),
49
+ ['source', e.fm.source.kind], ['tier', e.fm.tier],
50
+ ];
51
+ tx(e, tags);
52
+ if (emb && db.vec) {
53
+ const tagsStr = tags.map(([f, t]) => `${f}/${t}`).join(' ');
54
+ const tv = await emb.text(e.fm.title + '\n' + e.body + '\n' + tagsStr);
55
+ insVecText.run(e.slug, Buffer.from(tv.buffer));
56
+ const iv = await emb.image(path.join(e.dir, 'strip.png'));
57
+ insVecImage.run(e.slug, Buffer.from(iv.buffer));
58
+ }
59
+ n++;
60
+ }
61
+ db.close();
62
+ fs.renameSync(tmpPath, indexPath);
63
+ return {
64
+ entries: n, refused,
65
+ arms: emb && db.vec ? ['fts', 'vec'] : ['fts'],
66
+ skipped: emb
67
+ ? (db.vec ? [] : [{ arm: 'vec', reason: 'sqlite-vec failed to load' }])
68
+ : [{ arm: 'vec', reason: absentReason(models) ?? 'LIBRARY_EMBED=off' }],
69
+ };
70
+ } catch (err) {
71
+ // buildIndex runs on every save_entry in the long-lived http server; an unclosed
72
+ // handle here leaks for the life of the process. A close failure must not mask
73
+ // the original error, so it's swallowed rather than rethrown.
74
+ try { db?.close(); } catch {}
75
+ fs.rmSync(tmpPath, { force: true });
76
+ throw err;
77
+ }
78
+ }
@@ -0,0 +1,69 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+
5
+ const TEXT_MODEL = 'Xenova/all-MiniLM-L6-v2'; // 384-d
6
+ const IMAGE_MODEL = 'Xenova/siglip-base-patch16-224'; // 768-d
7
+ export const DIMS = { text: 384, image: 768 };
8
+
9
+ function stubVec(seed, n) {
10
+ // ponytail: hash-derived unit vector; deterministic, offline, no semantics. Real arm needs models.
11
+ const out = new Float32Array(n);
12
+ let h = createHash('sha256').update(seed).digest();
13
+ for (let i = 0; i < n; i++) { if (i % 32 === 0 && i) h = createHash('sha256').update(h).digest(); out[i] = (h[i % 32] / 255) * 2 - 1; }
14
+ const norm = Math.hypot(...out) || 1;
15
+ return out.map((v) => v / norm);
16
+ }
17
+
18
+ export function modelDirs(models) {
19
+ return { text: path.join(models, TEXT_MODEL), image: path.join(models, IMAGE_MODEL) };
20
+ }
21
+
22
+ export async function getEmbedder({ models, mode = process.env.LIBRARY_EMBED } = {}) {
23
+ if (mode === 'off') return null;
24
+ if (mode === 'stub') {
25
+ return {
26
+ arm: 'vec', dims: DIMS,
27
+ text: async (s) => stubVec('t:' + s, DIMS.text),
28
+ // ponytail: seeded on byte length + basename, not pixel content. Every
29
+ // indexed strip shares the basename strip.png, so two strips of the same
30
+ // byte length collide on the same vector — fine for exercising the vec
31
+ // arm offline, not a stand-in for real image similarity.
32
+ image: async (p) => stubVec('i:' + fs.readFileSync(p).length + ':' + path.basename(p), DIMS.image),
33
+ };
34
+ }
35
+ const d = modelDirs(models);
36
+ if (!fs.existsSync(d.text) || !fs.existsSync(d.image)) return null; // caller reads absentReason()
37
+ const tf = await import('@huggingface/transformers');
38
+ tf.env.cacheDir = models; tf.env.allowRemoteModels = process.env.LIBRARY_FETCH_MODELS === '1';
39
+ const textPipe = await tf.pipeline('feature-extraction', TEXT_MODEL);
40
+ const proc = await tf.AutoProcessor.from_pretrained(IMAGE_MODEL);
41
+ const vision = await tf.SiglipVisionModel.from_pretrained(IMAGE_MODEL);
42
+ return {
43
+ arm: 'vec', dims: DIMS,
44
+ text: async (s) => Float32Array.from((await textPipe(s, { pooling: 'mean', normalize: true })).data),
45
+ image: async (p) => {
46
+ const img = await tf.RawImage.read(p);
47
+ const out = await vision(await proc(img));
48
+ const v = Float32Array.from(out.pooler_output.data); const n = Math.hypot(...v) || 1;
49
+ return v.map((x) => x / n);
50
+ },
51
+ };
52
+ }
53
+
54
+ export function absentReason(models, mode = process.env.LIBRARY_EMBED) {
55
+ // An explicit opt-out is the reason on its own; do not let a stale/present
56
+ // model dir on disk relabel it, and do not touch the filesystem to find out.
57
+ if (mode === 'off') return 'LIBRARY_EMBED=off';
58
+ const d = modelDirs(models);
59
+ const missing = Object.entries(d).filter(([, p]) => !fs.existsSync(p)).map(([k]) => k);
60
+ return missing.length ? `models absent: ${missing.join(', ')} under ${models}` : null;
61
+ }
62
+
63
+ export async function fetchModels(models) {
64
+ const tf = await import('@huggingface/transformers');
65
+ tf.env.cacheDir = models; tf.env.allowRemoteModels = true;
66
+ await tf.pipeline('feature-extraction', TEXT_MODEL);
67
+ await tf.AutoProcessor.from_pretrained(IMAGE_MODEL);
68
+ await tf.SiglipVisionModel.from_pretrained(IMAGE_MODEL);
69
+ }