@yojahny/wp-design-library 1.1.0 → 1.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.
package/README.md CHANGED
@@ -151,6 +151,20 @@ same as `add`, not first at `save`. Output is one JSON line per file:
151
151
  exits 1 if any file was refused. `--inbox` never saves — every draft still
152
152
  waits for a tagging session and `library save`.
153
153
 
154
+ For a hosted server, stage a local recording through its authenticated upload endpoint:
155
+
156
+ ```bash
157
+ LIBRARY_TOKEN=… node bin/library.mjs upload ~/Desktop/acme.webm \
158
+ --server https://wp-design-library.example \
159
+ --source public-site \
160
+ --url https://acme.example \
161
+ --license "description only, no redistribution of the source"
162
+ ```
163
+
164
+ The token is read only from the environment. The server writes the recording and an
165
+ optional metadata sidecar into `<data>/inbox`; duplicate filenames are refused. HTTP MCP
166
+ tools may then refer to the returned bare filename. Uploads default to a 512 MiB limit.
167
+
154
168
  ### From a Claude Code session: `/mcp__wp-design-library__add-entry`
155
169
 
156
170
  With the server registered in a session (the `claude-wp-builder` plugin registers it,
@@ -181,6 +195,11 @@ server's record of what it measured. `get_vocab` returns the controlled terms, a
181
195
  interrupted run stopped. Editing `entries/<slug>/entry.md` by hand remains available in
182
196
  a local checkout and does the same thing.
183
197
 
198
+ For a reviewed batch, use `get_draft` to inspect each unsaved strip, submit judgement
199
+ fields in chunks with `set_fields_batch`, then call `save_batch` once. Invalid entries are
200
+ reported independently, and the search index rebuilds once for all valid slugs. The MCP
201
+ prompt `/mcp__wp-design-library__add-batch` walks an agent through the complete workflow.
202
+
184
203
  What goes where, with the values checked against `vocab.yaml`:
185
204
 
186
205
  | Field | What to write |
@@ -260,11 +279,12 @@ node bin/library.mjs check # validate every entry, 1 line per problem
260
279
  node bin/library.mjs check --remote # + confirm every recorded artifact key resolves in R2
261
280
  ```
262
281
 
263
- Over MCP, seven tools: `search` (keyword + facet filters, aliases resolved),
264
- `get_entry` (pass `detail: "summary"` for just the body's first paragraph, instead of
265
- the default `"full"`), `similar`, `recommend` (turns a brief into a per-role reference
266
- set in one call), `add` (pass `attach` to add a second capture — a video beside a live
267
- URL — to an existing entry instead of refusing it), `refresh`, `save_entry`. `search` and `similar` carry
282
+ Over MCP, the read tools are `search` (keyword + facet filters, aliases resolved),
283
+ `get_entry` (pass `detail: "summary"` for the first prose paragraph), `get_draft`,
284
+ `get_motion`, `similar`, `recommend`, and `get_vocab`. Authoring uses `add`, `add_batch`,
285
+ `set_fields`, `set_fields_batch`, `save_entry`, `save_batch`, and `refresh`. Pass `attach`
286
+ to `add` to add a second capture — a video beside a live URL — without replacing the
287
+ existing entry. `search` and `similar` carry
268
288
  `arms` (which retrieval arms ran; `["fts"]` in Phase 1); any tool may instead respond
269
289
  with `refused` and the reason.
270
290
 
@@ -334,6 +354,7 @@ skipped to stderr.
334
354
  | `LIBRARY_DATA` | repo root | where `entries/` and the index live (the volume in Docker) |
335
355
  | `LIBRARY_SEED` | `<repo>/entries` | entries copied into `LIBRARY_DATA` on start when missing |
336
356
  | `LIBRARY_TOKEN` | none | required for `--http`; the server refuses to start without it |
357
+ | `LIBRARY_UPLOAD_MAX_BYTES` | `536870912` | maximum body accepted by authenticated `PUT /inbox/<filename>` |
337
358
  | `PORT` | `4180` | HTTP port |
338
359
  | `LIBRARY_EMBED` | none (real embedder if models present, else skipped) | `stub` for the offline deterministic embedder, `off` to skip the `vec` arm |
339
360
  | `LIBRARY_MODELS` | `<data>/models` | where the `@huggingface/transformers` model files live |
package/bin/library.mjs CHANGED
@@ -12,6 +12,7 @@ const cmds = {
12
12
  save: () => import('../src/cli/save.mjs'),
13
13
  export: () => import('../src/cli/export.mjs'),
14
14
  ui: () => import('../src/cli/ui.mjs'),
15
+ upload: () => import('../src/cli/upload.mjs'),
15
16
  };
16
17
  // Refero discovery is a repository-maintenance tool. The npm package deliberately
17
18
  // omits its module, so it is only available from a checkout that contains it.
@@ -0,0 +1,68 @@
1
+ ---
2
+ slug: mengto-documentary-brutalist-agency
3
+ title: 'MengTo Skills: documentary brutalist agency, billboard type and black/white chapters'
4
+ tier: inspiration
5
+ source:
6
+ kind: open
7
+ url: https://github.com/MengTo/Skills/tree/main/agent-skills/web-design/documentary-brutalist-agency
8
+ license: MIT (MengTo/Skills); demo HTML measured live, may be ported
9
+ captured:
10
+ method: both
11
+ at: '2026-09-21'
12
+ tool: wp-design-library@1.1.0
13
+ captures:
14
+ - method: both
15
+ input: http://127.0.0.1:4173/documentary-brutalist-agency/demo/index.html
16
+ artifacts:
17
+ - key: entries/mengto-documentary-brutalist-agency/1/strip.webp
18
+ bytes: 261706
19
+ sha256: c2c874e969b53dbb32c6407e48f116ee3257d5e6ca14b780320fc53449480e40
20
+ uploaded: true
21
+ - key: entries/mengto-documentary-brutalist-agency/1/measure.json
22
+ bytes: 907
23
+ sha256: e7ecd70fba72026d0d8f80b91bcf93dae53b4ab005e5ffc2e159a43ac70f8b52
24
+ uploaded: true
25
+ - key: entries/mengto-documentary-brutalist-agency/1/screen-1440.png
26
+ bytes: 183022
27
+ sha256: b6b66c46f635c466f00868a07f5024c4af2a03d4ac1e05a0db02a12c3fcbc2de
28
+ uploaded: true
29
+ - key: entries/mengto-documentary-brutalist-agency/1/screen-390.png
30
+ bytes: 61551
31
+ sha256: d28003a69a2c23675281d63166891d97639186b92809646388d9269e18475444
32
+ uploaded: true
33
+ media: {}
34
+ roles:
35
+ - hero
36
+ - explainer
37
+ feel:
38
+ - dark
39
+ - brutalist
40
+ - editorial
41
+ palette:
42
+ canvas: '#0b0b0a'
43
+ ink: '#f2efe6'
44
+ accent: '#b8624e'
45
+ type:
46
+ display: Impact, "Arial Narrow", sans-serif
47
+ body: Arial, Helvetica, sans-serif
48
+ motion:
49
+ devices:
50
+ - reveal
51
+ - wipe
52
+ - parallax
53
+ notes: >-
54
+ Static at capture: no animation measured across nav, hero and philosophy chapter. Devices are the ones the source
55
+ skill prescribes for a build in this idiom, not ones observed — 500-760ms masked entrances (wipe), decisive
56
+ black/white chapter cuts with sticky contrast changes at boundaries (reveal, one reading transition at a time), and
57
+ collage parallax capped under 5% (parallax). It forbids continuous parallax, smooth-scroll theatre and decorative
58
+ loops.
59
+ ported_from: null
60
+ ---
61
+
62
+ A billboard hero: three uppercase lines in a compressed grotesk (Impact stack) fill the viewport edge to edge, with three small framed abstract images layered behind and through the letters. The nav is a hairline-ruled bar of tracked uppercase labels. A single tracked paragraph sits bottom-left, a circular scroll affordance bottom-right. The next chapter cuts to warm off-white paper with a small index label (OUR POSITION · 01) and one enormous editorial statement set in a neutral grotesk at roughly a third of the viewport height, ragged, no measure discipline — the statement is the section.
63
+
64
+ Useful for: a hero role where the type is the image and any photography is subordinate; an explainer role that is one sentence at billboard scale. The accent here is eyeballed from the collage frames (muted terracotta), not a UI token — the page has no coloured control. From the recorded frames, not measured.
65
+
66
+ ## Sources
67
+
68
+ - MengTo/Skills, `agent-skills/web-design/documentary-brutalist-agency` (MIT). Demo HTML served locally and measured live at 1440 and 390.
@@ -0,0 +1,68 @@
1
+ ---
2
+ slug: mengto-editorial-portfolio-chapters
3
+ title: 'MengTo Skills: editorial portfolio in chapters, near-black frame and one colour field per project'
4
+ tier: inspiration
5
+ source:
6
+ kind: open
7
+ url: https://github.com/MengTo/Skills/tree/main/agent-skills/web-design/editorial-portfolio-chapters
8
+ license: MIT (MengTo/Skills); demo HTML measured live, may be ported
9
+ captured:
10
+ method: both
11
+ at: '2026-09-21'
12
+ tool: wp-design-library@1.1.0
13
+ captures:
14
+ - method: both
15
+ input: http://127.0.0.1:4173/editorial-portfolio-chapters/demo/index.html
16
+ artifacts:
17
+ - key: entries/mengto-editorial-portfolio-chapters/1/strip.webp
18
+ bytes: 153042
19
+ sha256: c42d3ed9ab55ac486cf78abb29bf5f5921b242ca167f8c475fbcc33c439920d4
20
+ uploaded: true
21
+ - key: entries/mengto-editorial-portfolio-chapters/1/measure.json
22
+ bytes: 1376
23
+ sha256: 4e24e54185fb3ded7d3068240ce0e0d747f1391c0bf41a6b40b8e842f7174d48
24
+ uploaded: true
25
+ - key: entries/mengto-editorial-portfolio-chapters/1/screen-1440.png
26
+ bytes: 172310
27
+ sha256: e522369a2099a8024b0f61b55531ac883a33264a6763b5595a2ad114b88098d0
28
+ uploaded: true
29
+ - key: entries/mengto-editorial-portfolio-chapters/1/screen-390.png
30
+ bytes: 48463
31
+ sha256: 1f9acbb2a6d47056f0d4ad10e09ac4b86c81f25f8e23fbeab0652b1a44c5d10d
32
+ uploaded: true
33
+ media: {}
34
+ roles:
35
+ - hero
36
+ - proof
37
+ feel:
38
+ - dark
39
+ - editorial
40
+ - premium
41
+ palette:
42
+ canvas: '#10100f'
43
+ ink: '#f3f0e8'
44
+ accent: '#0c0c0b'
45
+ type:
46
+ display: Arial, Helvetica, sans-serif
47
+ body: Arial, Helvetica, sans-serif
48
+ motion:
49
+ devices:
50
+ - reveal
51
+ - stagger
52
+ - parallax
53
+ notes: >-
54
+ Static at capture: nothing measured moving in the six sections (three project shells are empty in the demo). Devices
55
+ are what the source skill prescribes, not what was observed — 500-760ms section entrances with direct children
56
+ staggered 45-70ms on cubic-bezier(.22,.61,.36,1) (reveal, stagger), parallax under 5% so the hero stays still enough
57
+ to study (parallax), nav background tied to section boundaries. It forbids fast card choreography, large parallax
58
+ and decorative motion loops.
59
+ ported_from: null
60
+ ---
61
+
62
+ A near-black frame with a compact pill nav (WORK · SERVICES / STUDIO · CONTACT). The hero is one framed rectangle inset from the page edges, carrying four diagonal colour bands — terracotta, navy, pale blue, olive — behind the studio wordmark MORROW set huge in a bold grotesk that picks up the band colours where it overlaps them. A vertical running head sits in the left margin; one short positioning line bottom-right. Below, the first project chapter cuts to a pale lavender field: a small index label (SELECTED WORK · 01 / 04), the project title After Light at display scale bottom-left, one tall image right, a View the project link.
63
+
64
+ Useful for: a hero where the identity is a colour field plus a wordmark rather than a photograph; a proof role done as chapters — one project per colour field, the field itself doing the separation instead of cards. From the recorded frames, not measured. Only one of the four project chapters is populated in the demo.
65
+
66
+ ## Sources
67
+
68
+ - MengTo/Skills, `agent-skills/web-design/editorial-portfolio-chapters` (MIT). Demo HTML served locally and measured live at 1440 and 390.
@@ -0,0 +1,67 @@
1
+ ---
2
+ slug: mengto-technical-wireframe-info-layout
3
+ title: 'MengTo Skills: monochrome technical wireframe, annotated object with connector callouts'
4
+ tier: inspiration
5
+ source:
6
+ kind: open
7
+ url: https://github.com/MengTo/Skills/tree/main/agent-skills/web-design/technical-wireframe-info-layout
8
+ license: MIT (MengTo/Skills); demo HTML measured live, may be ported
9
+ captured:
10
+ method: both
11
+ at: '2026-09-21'
12
+ tool: wp-design-library@1.1.0
13
+ captures:
14
+ - method: both
15
+ input: http://127.0.0.1:4174/technical-wireframe-info-layout/index.html
16
+ artifacts:
17
+ - key: entries/mengto-technical-wireframe-info-layout/1/strip.webp
18
+ bytes: 176466
19
+ sha256: 7cd02c89ef89a924af8f21bc127790a81f4dfdd563a95486aa5d3c54557a8edb
20
+ uploaded: true
21
+ - key: entries/mengto-technical-wireframe-info-layout/1/measure.json
22
+ bytes: 1275
23
+ sha256: d12b7f46e153611eb4163663566603b82b1d215c294ae9989c8ae5a046f94377
24
+ uploaded: true
25
+ - key: entries/mengto-technical-wireframe-info-layout/1/screen-1440.png
26
+ bytes: 127432
27
+ sha256: 99294323d6f00eb6b41983b84ec92bd881433d8504da7c9c3ed05d6a696c6e74
28
+ uploaded: true
29
+ - key: entries/mengto-technical-wireframe-info-layout/1/screen-390.png
30
+ bytes: 40370
31
+ sha256: 51559ff1074a0e8f81a02edc8575f1a01e958cbcd775d1c1b340eeb6057e4e2b
32
+ uploaded: true
33
+ media: {}
34
+ roles:
35
+ - explainer
36
+ - capability
37
+ feel:
38
+ - dark
39
+ - minimal
40
+ - cold
41
+ - saas
42
+ palette:
43
+ canvas: '#000000'
44
+ ink: '#d4d4d8'
45
+ accent: '#71717a'
46
+ type:
47
+ display: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"
48
+ body: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"
49
+ motion:
50
+ devices:
51
+ - reveal
52
+ - count
53
+ notes: >-
54
+ Static at capture: a still instrument panel across top bar, framed diagram and status footer. Devices are the ones
55
+ that would carry it in a build, not ones observed — the metrics and the timestamped diagnostic stream want to count
56
+ in and tick (count); the diagram planes and annotations want a quiet entrance (reveal). Nothing here wants tilt,
57
+ magnet or parallax; the skill's own avoid list rejects motion that turns the diagram into noise.
58
+ ported_from: null
59
+ ---
60
+
61
+ A monochrome engineering panel on pure black, framed by a hairline border inset from the viewport with corner marks. Top bar: a versioned product label (SEMANTIC.NET_V3.1 / SYSTEM ONTOLOGY), three tab-like section names with a lit dot on the active one, a stress meter at right. The main viewport is a wireframe: three stacked isometric planes drawn in thin grey lines, nodes labelled in tracked mono uppercase (PROCESS_ENGINE, INDEX_CORE, SRC_01), dotted connectors between them, one bright node. A right column holds an annotated paragraph, two metrics with hatched progress bars, and a timestamped diagnostic list with the current row boxed. Footer strip: view-mode toggle icons, uptime and latency in mono, one outlined action button.
62
+
63
+ Useful for: an explainer or capability role for a technical product where the object itself is the section — diagram in the centre, annotations in a fixed column — instead of a headline and three cards. Everything is hairlines, mono labels and a single grey accent; no fills, no colour beyond one highlighted node. From the recorded frames, not measured.
64
+
65
+ ## Sources
66
+
67
+ - MengTo/Skills, `agent-skills/web-design/technical-wireframe-info-layout` (MIT); the demo is a Neuform export the skill ships, flattened from its embedded page and measured live at 1440 and 390. Of the eight Neuform-synced demos tried in this batch it is the only one whose page matched its skill's name.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yojahny/wp-design-library",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Design reference corpus and MCP server for claude-wp-builder",
5
5
  "repository": {
6
6
  "type": "git",
@@ -65,6 +65,7 @@
65
65
  "src/cli/refresh.mjs",
66
66
  "src/cli/save.mjs",
67
67
  "src/cli/serve.mjs",
68
+ "src/cli/upload.mjs",
68
69
  "src/cli/ui.mjs",
69
70
  "src/entry.mjs",
70
71
  "src/index/",
package/src/cli/check.mjs CHANGED
@@ -55,7 +55,12 @@ export async function run(args = []) {
55
55
  const problems = [];
56
56
  await mapPool(artifacts, 8, async (a) => {
57
57
  try {
58
- const r = await fetch(objectUrl(config, a.key), { method: 'HEAD', signal: AbortSignal.timeout(20000) });
58
+ // identity, or the CDN compresses JSON and drops content-length: every measure.json then
59
+ // reads as 0 bytes while the object is fine. Images are never compressed, which is why
60
+ // this only ever showed on the .json artifacts.
61
+ const r = await fetch(objectUrl(config, a.key), {
62
+ method: 'HEAD', headers: { 'accept-encoding': 'identity' }, signal: AbortSignal.timeout(20000),
63
+ });
59
64
  // Existence AND size. A 200 with the wrong length means the object is not what the entry
60
65
  // records -- exactly as broken as it being absent, and cheaper to catch here than at read
61
66
  // time on a visitor's page.
@@ -0,0 +1,57 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Readable } from 'node:stream';
4
+
5
+ function opt(args, name) {
6
+ const i = args.indexOf(name);
7
+ return i === -1 ? undefined : args[i + 1];
8
+ }
9
+
10
+ export async function uploadFile({ file, server, token, source, license, url, title, fetcher = fetch }) {
11
+ if (!file) throw new Error('usage: library upload <file> --server <url> [--source kind --license text --url source-url --title title]');
12
+ if (!server) throw new Error('--server or LIBRARY_SERVER is required');
13
+ if (!token) throw new Error('LIBRARY_TOKEN is required');
14
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) throw new Error(`file does not exist: ${file}`);
15
+ if ((source && !license) || (!source && license)) throw new Error('--source and --license must be provided together');
16
+
17
+ const name = path.basename(file);
18
+ const endpoint = new URL(`/inbox/${encodeURIComponent(name)}`, server);
19
+ if (!['http:', 'https:'].includes(endpoint.protocol)) throw new Error('--server must be an HTTP(S) URL');
20
+ const headers = {
21
+ authorization: `Bearer ${token}`,
22
+ 'content-type': 'application/octet-stream',
23
+ 'content-length': String(fs.statSync(file).size),
24
+ };
25
+ if (source) headers['x-library-source'] = source;
26
+ if (license) headers['x-library-license'] = license;
27
+ if (url) headers['x-library-url'] = url;
28
+ if (title) headers['x-library-title'] = title;
29
+
30
+ const response = await fetcher(endpoint, {
31
+ method: 'PUT', headers,
32
+ body: Readable.toWeb(fs.createReadStream(file)),
33
+ duplex: 'half',
34
+ });
35
+ const text = await response.text();
36
+ if (!response.ok) throw new Error(`upload refused (${response.status}): ${text}`);
37
+ return JSON.parse(text);
38
+ }
39
+
40
+ export async function run(args) {
41
+ try {
42
+ const result = await uploadFile({
43
+ file: args[0],
44
+ server: opt(args, '--server') ?? process.env.LIBRARY_SERVER,
45
+ token: process.env.LIBRARY_TOKEN,
46
+ source: opt(args, '--source'),
47
+ license: opt(args, '--license'),
48
+ url: opt(args, '--url'),
49
+ title: opt(args, '--title'),
50
+ });
51
+ process.stdout.write(`${JSON.stringify(result)}\n`);
52
+ return 0;
53
+ } catch (err) {
54
+ process.stderr.write(`${err.message}\n`);
55
+ return 1;
56
+ }
57
+ }
@@ -1,11 +1,33 @@
1
+ import path from 'node:path';
1
2
  import { parseEntry, validateEntry } from '../entry.mjs';
2
3
  import { buildIndex } from '../index/build.mjs';
3
4
 
4
5
  export async function saveEntry({ dir, vocab, indexPath, entriesDir, models, p }) {
5
- const e = parseEntry(dir);
6
- const r = validateEntry(e, vocab);
7
- if (!r.ok) return { ok: false, slug: e.slug, errors: r.errors, refused: r.errors.join('; ') };
8
- // Phase 1: a full rebuild is cheap; single-entry upsert arrives with the vector arms.
9
- await buildIndex({ entriesDir, indexPath, vocab, models, p });
10
- return { ok: true, slug: e.slug, errors: [] };
6
+ const batch = await saveEntries({ dirs: [dir], vocab, indexPath, entriesDir, models, p });
7
+ return batch.results[0];
8
+ }
9
+
10
+ export async function saveEntries({ dirs, vocab, indexPath, entriesDir, models, p }) {
11
+ const seen = new Set();
12
+ const results = dirs.map((dir) => {
13
+ const requested = path.basename(dir);
14
+ if (seen.has(requested)) {
15
+ return { ok: false, slug: requested, errors: ['duplicate slug in batch'], refused: 'duplicate slug in batch' };
16
+ }
17
+ seen.add(requested);
18
+ try {
19
+ const e = parseEntry(dir);
20
+ const r = validateEntry(e, vocab);
21
+ if (!r.ok) return { ok: false, slug: e.slug, errors: r.errors, refused: r.errors.join('; ') };
22
+ return { ok: true, slug: e.slug, errors: [] };
23
+ } catch (err) {
24
+ return { ok: false, slug: requested, errors: [err.message], refused: err.message };
25
+ }
26
+ });
27
+
28
+ const saved = results.filter((r) => r.ok).length;
29
+ // A batch rebuilds once after every draft has been validated. Rebuilding after each
30
+ // slug makes a 2,000-entry approval quadratic even though the final index is identical.
31
+ if (saved) await buildIndex({ entriesDir, indexPath, vocab, models, p });
32
+ return { results, saved, refused: results.length - saved };
11
33
  }
@@ -0,0 +1,110 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+
5
+ export const MEDIA_EXTENSIONS = new Set(['.webp', '.gif', '.mp4', '.mov', '.webm', '.mkv']);
6
+ export const DEFAULT_UPLOAD_MAX_BYTES = 512 * 1024 * 1024;
7
+
8
+ export class UploadError extends Error {
9
+ constructor(status, message) {
10
+ super(message);
11
+ this.status = status;
12
+ }
13
+ }
14
+
15
+ function oneLine(value, name) {
16
+ if (value == null || value === '') return undefined;
17
+ if (typeof value !== 'string' || /[\r\n]/.test(value)) throw new UploadError(400, `${name} must be one line`);
18
+ return value;
19
+ }
20
+
21
+ export function uploadMetadata(headers) {
22
+ const kind = oneLine(headers['x-library-source'], 'x-library-source');
23
+ const license = oneLine(headers['x-library-license'], 'x-library-license');
24
+ const title = oneLine(headers['x-library-title'], 'x-library-title');
25
+ const url = oneLine(headers['x-library-url'], 'x-library-url');
26
+
27
+ if ((kind && !license) || (!kind && license)) {
28
+ throw new UploadError(400, 'x-library-source and x-library-license must be provided together');
29
+ }
30
+ if (kind && !['own', 'open', 'paid', 'public-site'].includes(kind)) {
31
+ throw new UploadError(400, 'x-library-source must be own, open, paid, or public-site');
32
+ }
33
+ if (url) {
34
+ try {
35
+ const parsed = new URL(url);
36
+ if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error();
37
+ } catch {
38
+ throw new UploadError(400, 'x-library-url must be a full HTTP(S) URL');
39
+ }
40
+ }
41
+ return kind ? { kind, license, ...(title ? { title } : {}), ...(url ? { url } : {}) } : null;
42
+ }
43
+
44
+ export function uploadFilename(raw) {
45
+ let filename;
46
+ try { filename = decodeURIComponent(raw); } catch { throw new UploadError(400, 'invalid encoded filename'); }
47
+ if (!filename || filename !== path.basename(filename) || filename === '.' || filename === '..' || /[\0\r\n]/.test(filename)) {
48
+ throw new UploadError(400, 'filename must be one file name without a path');
49
+ }
50
+ if (!MEDIA_EXTENSIONS.has(path.extname(filename).toLowerCase())) {
51
+ throw new UploadError(415, `unsupported media extension; use ${[...MEDIA_EXTENSIONS].join(', ')}`);
52
+ }
53
+ return filename;
54
+ }
55
+
56
+ export async function receiveInboxUpload({ req, dataDir, rawFilename, maxBytes = DEFAULT_UPLOAD_MAX_BYTES }) {
57
+ const filename = uploadFilename(rawFilename);
58
+ const metadata = uploadMetadata(req.headers);
59
+ const declared = Number(req.headers['content-length']);
60
+ if (Number.isFinite(declared) && declared > maxBytes) {
61
+ throw new UploadError(413, `upload exceeds ${maxBytes} byte limit`);
62
+ }
63
+
64
+ const inbox = path.join(dataDir, 'inbox');
65
+ const destination = path.join(inbox, filename);
66
+ const sidecar = `${destination}.json`;
67
+ fs.mkdirSync(inbox, { recursive: true });
68
+ if (fs.existsSync(destination) || fs.existsSync(sidecar)) {
69
+ throw new UploadError(409, `${filename} already exists in inbox`);
70
+ }
71
+
72
+ const temporary = path.join(inbox, `.${filename}.${randomUUID()}.upload`);
73
+ const handle = await fs.promises.open(temporary, 'wx');
74
+ const digest = createHash('sha256');
75
+ let bytes = 0;
76
+ try {
77
+ for await (const chunk of req) {
78
+ bytes += chunk.length;
79
+ if (bytes > maxBytes) throw new UploadError(413, `upload exceeds ${maxBytes} byte limit`);
80
+ digest.update(chunk);
81
+ await handle.write(chunk);
82
+ }
83
+ if (bytes === 0) throw new UploadError(400, 'upload body is empty');
84
+ await handle.sync();
85
+ await handle.close();
86
+
87
+ // link() publishes atomically and refuses a concurrent upload with the same name.
88
+ try {
89
+ await fs.promises.link(temporary, destination);
90
+ } catch (err) {
91
+ if (err.code === 'EEXIST') throw new UploadError(409, `${filename} already exists in inbox`);
92
+ throw err;
93
+ }
94
+ if (metadata) {
95
+ try {
96
+ await fs.promises.writeFile(sidecar, `${JSON.stringify(metadata)}\n`, { flag: 'wx' });
97
+ } catch (err) {
98
+ await fs.promises.unlink(destination).catch(() => {});
99
+ if (err.code === 'EEXIST') throw new UploadError(409, `${filename}.json already exists in inbox`);
100
+ throw err;
101
+ }
102
+ }
103
+ await fs.promises.unlink(temporary);
104
+ return { ok: true, file: filename, bytes, sha256: digest.digest('hex'), sidecar: Boolean(metadata) };
105
+ } catch (err) {
106
+ await handle.close().catch(() => {});
107
+ await fs.promises.unlink(temporary).catch(() => {});
108
+ throw err;
109
+ }
110
+ }
package/src/mcp/http.mjs CHANGED
@@ -7,6 +7,7 @@ import { paths } from '../paths.mjs';
7
7
  import { openIndex } from '../index/query.mjs';
8
8
  import { getEmbedder } from '../index/embed.mjs';
9
9
  import { serveFile } from '../ui/serve.mjs';
10
+ import { receiveInboxUpload, UploadError, DEFAULT_UPLOAD_MAX_BYTES } from '../ingest/upload.mjs';
10
11
 
11
12
  function authorized(header, token) {
12
13
  const want = Buffer.from(`Bearer ${token}`), got = Buffer.from(header ?? '');
@@ -41,6 +42,26 @@ async function handle(req, res, p, token, embedder) {
41
42
  res.writeHead(200, { 'content-type': 'application/json' });
42
43
  return res.end(JSON.stringify({ ok: true, entries, arms, artifacts, version: p.version }));
43
44
  }
45
+ if (url.pathname.startsWith('/inbox/')) {
46
+ if (!authorized(req.headers.authorization, token)) { res.writeHead(401); return res.end('unauthorized'); }
47
+ if (req.method !== 'PUT') { res.writeHead(405, { allow: 'PUT' }); return res.end('method not allowed'); }
48
+ const configured = Number(process.env.LIBRARY_UPLOAD_MAX_BYTES);
49
+ const maxBytes = Number.isSafeInteger(configured) && configured > 0 ? configured : DEFAULT_UPLOAD_MAX_BYTES;
50
+ try {
51
+ const result = await receiveInboxUpload({
52
+ req,
53
+ dataDir: p.data,
54
+ rawFilename: url.pathname.slice('/inbox/'.length),
55
+ maxBytes,
56
+ });
57
+ res.writeHead(201, { 'content-type': 'application/json' });
58
+ return res.end(JSON.stringify(result));
59
+ } catch (err) {
60
+ if (!(err instanceof UploadError)) throw err;
61
+ res.writeHead(err.status, { 'content-type': 'application/json' });
62
+ return res.end(JSON.stringify({ ok: false, refused: err.message }));
63
+ }
64
+ }
44
65
  if (url.pathname === '/mcp') {
45
66
  if (!authorized(req.headers.authorization, token)) { res.writeHead(401); return res.end('unauthorized'); }
46
67
  // ponytail: SDK 1.30's stateless transport (sessionIdGenerator: undefined)
@@ -23,24 +23,31 @@ session did not make.
23
23
 
24
24
  ## Order of operations
25
25
 
26
- 1. **Arguments.** \`path\` must exist; pass it as an absolute path. Animated webp and gif
27
- always work; video (mp4/mov/webm/mkv) needs ffmpeg on the server's PATH. If \`add\`
28
- returns \`refused\` starting with \`ffmpeg\`, relay the message and stop. \`source\` is
26
+ 1. **Arguments.** \`input\` may be a live HTTP(S) page or an animated webp/gif/video
27
+ (mp4/mov/webm/mkv). A local file uses an absolute path. Video needs ffmpeg on the
28
+ server's PATH. If \`add\` returns \`refused\` starting with \`ffmpeg\`, relay the
29
+ message and stop. \`source\` is
29
30
  one of ${list(v.source)}. Derive \`slug\` from the file name when absent (lowercase,
30
31
  dashes, no extension) and confirm it with the user; derive \`title\` from the slug and
31
32
  confirm. \`url\` must be a full URL with scheme. \`license\` defaults by kind: own →
32
33
  "own work"; open → ask for the licence name; paid and public-site → "description
33
34
  only, no redistribution of the source".
34
- 2. **Ingest.** Call the \`add\` tool with those values. If it returns \`refused\` because
35
+ 2. **Stage when hosted.** A hosted server cannot read a path on the caller's machine.
36
+ Upload it first with \`LIBRARY_TOKEN\` configured locally: \`library upload
37
+ <absolute-path> --server <server-origin> --source <kind> --license "<licence>"
38
+ [--url <source-url>] [--title <title>]\`. Use returned \`file\` name as the \`add\`
39
+ input; hosted MCP resolves a bare name inside its inbox. Never print or pass the token as an argument.
40
+ Live page URLs need no upload.
41
+ 3. **Ingest.** Call the \`add\` tool with those values. If it returns \`refused\` because
35
42
  the slug exists, ask before calling again with \`force: true\`; a forced re-add resets
36
43
  the draft. Over a hosted (HTTP) server \`add\` can only read files already in the server's
37
44
  inbox; if the refusal mentions \`inbox\`, say so and stop.
38
- 3. **Read.** Open the returned \`strip\`. Read \`media.duration_ms\` from the draft at
45
+ 4. **Read.** Open the returned \`strip\`. Read \`media.duration_ms\` from the draft at
39
46
  \`<dir>/entry.md\`; if it is above 10000, call \`add\` again with \`force: true\` and
40
47
  \`frames: 36\` before tagging. The server caps at 36 frames; beyond about 18 seconds a
41
48
  section can still be missed, so build a denser sheet yourself from the source file
42
49
  before writing the motion note.
43
- 4. **Tag.** Fill every field in the returned \`blanks\` list, per the rubric below. Write
50
+ 5. **Tag.** Fill every field in the returned \`blanks\` list, per the rubric below. Write
44
51
  the motion note last, after the roster. Two ways, same result: call \`set_fields\` with
45
52
  those field names — this needs **no filesystem** and is the only way that works against
46
53
  a hosted server — or, in a local checkout, edit \`<dir>/entry.md\` directly.
@@ -48,11 +55,11 @@ session did not make.
48
55
  anything, so a refusal never leaves a half-filled draft. It writes judgement fields
49
56
  only; provenance is refused. \`get_vocab\` returns the terms if you did not read them
50
57
  above.
51
- 5. **Approve.** Show the draft to the user and **show the draft and wait for a yes** before
58
+ 6. **Approve.** Show the draft to the user and **show the draft and wait for a yes** before
52
59
  anything else. Apply their edits.
53
- 6. **Save.** Call \`save_entry\`. On \`refused\`, show the field names, fix, and call again;
60
+ 7. **Save.** Call \`save_entry\`. On \`refused\`, show the field names, fix, and call again;
54
61
  **never remove a field to make it pass**.
55
- 7. **Offer the git step.** The library checkout is the directory that contains the
62
+ 8. **Offer the git step.** The library checkout is the directory that contains the
56
63
  \`bin/library.mjs\` this server runs; if that is not a git checkout (the \`npx\` case), ask
57
64
  the user for the path to their clone. If the \`dir\` returned by \`add\` is not inside the
58
65
  checkout, first \`cp -r <dir> <checkout>/entries/\`. Then, only with \`git -C <checkout>\`
@@ -102,6 +109,46 @@ and \`public-site\` are inspiration only and \`save_entry\` refuses \`tier: port
102
109
  - Never run git in the current project.`;
103
110
  }
104
111
 
112
+ export function addBatchPrompt(ctx, a) {
113
+ const v = ctx.vocab.facets;
114
+ const given = Object.entries(a).filter(([, value]) => value).map(([key, value]) => `- ${key}: ${value}`).join('\n');
115
+ return `# Add a batch of design references to wp-design-library
116
+
117
+ Arguments given:
118
+ ${given || '- (none)'}
119
+
120
+ Use the hosted workflow end to end; do not edit the server filesystem.
121
+
122
+ 1. **Stage files.** If recordings are on this machine, run \`library upload\` once per file
123
+ with \`--server\`, \`--source\`, \`--license\`, and optional \`--url\`/\`--title\`.
124
+ Authentication comes from \`LIBRARY_TOKEN\`; never print it or put it in an argument.
125
+ Files already in the server inbox need no upload.
126
+ 2. **Draft.** Call \`add_batch\` once, passing the supplied source/licence as defaults for
127
+ files without sidecars. It resumes interrupted work, skips an existing slug,
128
+ and reports a refusal per bad file while continuing the rest. Report refusals, then keep
129
+ processing every drafted slug.
130
+ 3. **Inspect.** Call \`get_vocab\` once. For every drafted slug call \`get_draft\`; inspect
131
+ every returned strip image. Long recordings may need another capture before the motion
132
+ note can honestly cover every section.
133
+ 4. **Tag in chunks.** Build \`set_fields_batch.entries\` records and submit chunks of at
134
+ most 25 so results remain reviewable. Fill every blank. Roles must come from
135
+ ${list(v.role)}; feel from ${list(v.feel)}; motion devices from ${list(v.motion)}.
136
+ Each body has \`## What it does\`, \`## Section roster\`, and \`## Why it works\`.
137
+ Motion notes begin with the sampled-frame limitation and describe sections in order.
138
+ A refused item does not undo the others; fix it and resubmit only that slug.
139
+ 5. **Review.** Show a compact table containing slug, roles, feel, palette, motion devices,
140
+ and any refusal. Wait for the user's approval of the batch and apply requested changes.
141
+ 6. **Save once.** Call \`save_batch\` with all approved slugs. It validates each entry,
142
+ isolates failures, and rebuilds the index once. Fix and resubmit refused slugs without
143
+ repeating successful ones.
144
+ 7. **Ship.** Run \`library check --remote\`. For a hosted volume, use
145
+ \`library export --saved-only\` to bring entries into a checkout, then create one content
146
+ branch and PR for the batch.
147
+
148
+ Never infer a visual field without inspecting its strip, never write provenance through
149
+ \`set_fields_batch\`, and never drop a field merely to pass validation.`;
150
+ }
151
+
105
152
  // brief-from-library turns a client brief into a reference set: one `recommend` call,
106
153
  // then `get_entry` on what survives, then a `## References` block the session fills in
107
154
  // with the slugs it actually used. The per-role `search` fan-out is kept as a fallback
@@ -157,7 +204,8 @@ export function registerPrompts(server, ctx) {
157
204
  {
158
205
  description: 'Add a design reference to the library: ingest a recording, tag it in this session per the rubric, approve the draft, save it, offer the PR.',
159
206
  argsSchema: {
160
- path: z.string().describe('absolute path to an animated webp/gif or a video file'),
207
+ input: z.string().optional().describe('live page URL, inbox filename, or absolute local media path'),
208
+ path: z.string().optional().describe('legacy alias for an absolute local media path'),
161
209
  source: z.string().describe('own | open | paid | public-site'),
162
210
  slug: z.string().optional().describe('lowercase, digits and dashes'),
163
211
  title: z.string().optional(),
@@ -167,4 +215,17 @@ export function registerPrompts(server, ctx) {
167
215
  },
168
216
  async (a) => ({ messages: [{ role: 'user', content: { type: 'text', text: addEntryPrompt(ctx, a) } }] }),
169
217
  );
218
+
219
+ server.registerPrompt(
220
+ 'add-batch',
221
+ {
222
+ description: 'Ingest, inspect, tag, review, and save every recording in the hosted inbox with batch tools and isolated failures.',
223
+ argsSchema: {
224
+ source: z.string().optional().describe('default own | open | paid | public-site when sidecars omit it'),
225
+ license: z.string().optional().describe('default licence text when sidecars omit it'),
226
+ server: z.string().optional().describe('hosted library origin used by library upload'),
227
+ },
228
+ },
229
+ async (a) => ({ messages: [{ role: 'user', content: { type: 'text', text: addBatchPrompt(ctx, a) } }] }),
230
+ );
170
231
  }
package/src/mcp/tools.mjs CHANGED
@@ -43,7 +43,7 @@ function resolveFilters(vocab, filters = {}) {
43
43
  // (the operator running the CLI already has that file access), so it stays
44
44
  // unrestricted there. Shared by `add` (input) and `similar` (image).
45
45
  function confineToInbox(ctx, inputPath) {
46
- if (ctx.transport !== 'http') return null;
46
+ if (ctx.transport !== 'http') return { path: inputPath, refused: null };
47
47
  // Realpath the inbox root itself (when it exists) so a symlinked <data>/inbox still
48
48
  // confines correctly: inputPath's own realpath resolves through that symlink too, and
49
49
  // comparing against the unresolved join would then never match.
@@ -53,9 +53,12 @@ function confineToInbox(ctx, inputPath) {
53
53
  // existsSync is false for a dangling symlink too, not just a missing path: refuse
54
54
  // both explicitly instead of falling through to path.resolve, which would let a
55
55
  // dangling symlink under inbox pass confinement on its unresolved path alone.
56
- if (!fs.existsSync(inputPath)) return 'input does not exist';
57
- const real = fs.realpathSync(inputPath);
58
- return real.startsWith(inbox) ? null : 'over http, the path must be a file under <data>/inbox';
56
+ const candidate = path.isAbsolute(inputPath) ? inputPath : path.join(inboxRoot, inputPath);
57
+ if (!fs.existsSync(candidate)) return { path: candidate, refused: 'input does not exist' };
58
+ const real = fs.realpathSync(candidate);
59
+ return real.startsWith(inbox)
60
+ ? { path: real, refused: null }
61
+ : { path: real, refused: 'over http, the path must be a file under <data>/inbox' };
59
62
  }
60
63
 
61
64
  export function makeTools(ctx) {
@@ -129,6 +132,51 @@ export function makeTools(ctx) {
129
132
  return { ...out, body: first.trim(), detail: 'summary' };
130
133
  }),
131
134
  },
135
+ {
136
+ name: 'get_draft',
137
+ description: 'Return a saved or unsaved draft directly from the corpus volume, including blank judgement fields and every strip as paths, public URLs, and MCP image content. Use after add_batch because drafts are not searchable until saved.',
138
+ schema: z.object({ slug: z.string().regex(/^[a-z0-9-]+$/) }),
139
+ handler: async ({ slug }) => {
140
+ const dir = path.join(ctx.paths.entries, slug);
141
+ if (!fs.existsSync(path.join(dir, 'entry.md'))) return { refused: `no draft with slug ${slug}` };
142
+ const entry = parseEntry(dir);
143
+ const captures = entry.fm?.captured?.captures ?? [];
144
+ let config = null;
145
+ try { config = readConfig(); } catch { config = null; }
146
+ const strips = [];
147
+ const stripUrls = [];
148
+ for (const capture of captures) {
149
+ const artifact = (capture.artifacts ?? []).find((a) => a?.key?.endsWith('/strip.webp'));
150
+ if (!artifact) { strips.push(null); stripUrls.push(null); continue; }
151
+ let local = null;
152
+ try { local = await artifactFile(ctx.paths, artifact); } catch { local = null; }
153
+ strips.push(local);
154
+ stripUrls.push(config ? objectUrl(config, artifact.key) : null);
155
+ }
156
+ const { REQUIRED_BLANKS } = await import('../entry.mjs');
157
+ const view = { ...entry.fm, body: entry.body };
158
+ const valueAt = (dotted) => dotted.split('.').reduce((o, key) => (o == null ? undefined : o[key]), view);
159
+ const blanks = REQUIRED_BLANKS.filter((field) => {
160
+ const value = valueAt(field);
161
+ return value == null || value === '' || (Array.isArray(value) && value.length === 0);
162
+ });
163
+ return { ...entry, blanks, strip: strips[0] ?? null, strips, strip_url: stripUrls[0] ?? null, strip_urls: stripUrls };
164
+ },
165
+ content: (out) => {
166
+ if (out.refused) return [];
167
+ return (out.strips ?? []).flatMap((strip, index) => {
168
+ if (!strip) return [];
169
+ try {
170
+ return [
171
+ { type: 'text', text: `Capture ${index + 1} strip for ${out.fm.slug}` },
172
+ { type: 'image', mimeType: 'image/webp', data: fs.readFileSync(strip).toString('base64') },
173
+ ];
174
+ } catch {
175
+ return [{ type: 'text', text: `Capture ${index + 1} strip for ${out.fm.slug} became unavailable` }];
176
+ }
177
+ });
178
+ },
179
+ },
132
180
  {
133
181
  name: 'get_motion',
134
182
  description: 'Inspect one motion clip at original playback speed. Returns video URL when published, provenance, observed motion notes, and timestamped frames as native images by default. Use clip to select from available clips; timestamps are relative to the clip. A video URL alone does not provide video understanding.',
@@ -165,8 +213,9 @@ export function makeTools(ctx) {
165
213
  handler: withDb(async (db, a) => {
166
214
  if (!!a.slug === !!a.image) return { refused: 'similar needs exactly one of slug or image' };
167
215
  if (a.image) {
168
- const refusal = confineToInbox(ctx, a.image);
169
- if (refusal) return { refused: refusal };
216
+ const confined = confineToInbox(ctx, a.image);
217
+ if (confined.refused) return { refused: confined.refused };
218
+ a = { ...a, image: confined.path };
170
219
  }
171
220
  return similar(db, { ...a, embedder: ctx.embedder, embedderReason: ctx.embedderReason });
172
221
  }),
@@ -241,8 +290,9 @@ export function makeTools(ctx) {
241
290
  const refusal = await refuseUnsafeUrl(a.input, { transport: ctx.transport });
242
291
  if (refusal) return { refused: refusal };
243
292
  } else {
244
- const refusal = confineToInbox(ctx, a.input);
245
- if (refusal) return { refused: refusal };
293
+ const confined = confineToInbox(ctx, a.input);
294
+ if (confined.refused) return { refused: confined.refused };
295
+ a = { ...a, input: confined.path };
246
296
  }
247
297
  return (await import('../cli/add.mjs')).addEntry(a);
248
298
  },
@@ -282,6 +332,24 @@ export function makeTools(ctx) {
282
332
  return saveEntry({ dir: path.join(ctx.paths.entries, slug), vocab: ctx.vocab, indexPath: ctx.paths.index, entriesDir: ctx.paths.entries, models: ctx.paths.models, p: ctx.paths });
283
333
  },
284
334
  },
335
+ {
336
+ name: 'save_batch',
337
+ description: 'Validate and save many reviewed drafts, isolating invalid entries and rebuilding the index once for every valid slug. Returns one result per slug plus saved/refused counts.',
338
+ schema: z.object({
339
+ slugs: z.array(z.string().regex(/^[a-z0-9-]+$/)).min(1).max(2000),
340
+ }),
341
+ handler: async ({ slugs }) => {
342
+ const { saveEntries } = await import('../ingest/save.mjs');
343
+ return saveEntries({
344
+ dirs: slugs.map((slug) => path.join(ctx.paths.entries, slug)),
345
+ vocab: ctx.vocab,
346
+ indexPath: ctx.paths.index,
347
+ entriesDir: ctx.paths.entries,
348
+ models: ctx.paths.models,
349
+ p: ctx.paths,
350
+ });
351
+ },
352
+ },
285
353
  {
286
354
  name: 'set_fields',
287
355
  description: "Fill a draft's judgement fields: roles, feel, motion.devices, motion.notes, palette, type, body, tier, ported_from — never provenance. Vocabulary terms are resolved through aliases and an unknown one is refused by name with nothing written, so a refusal never leaves a half-filled draft. Returns the fields still blank. This is the step between add and save_entry, and it is the only one that does not need a filesystem.",
@@ -294,6 +362,34 @@ export function makeTools(ctx) {
294
362
  return setFields({ dir: path.join(ctx.paths.entries, slug), fields, vocab: ctx.vocab });
295
363
  },
296
364
  },
365
+ {
366
+ name: 'set_fields_batch',
367
+ description: 'Fill judgement fields for many drafts in one call. Each entry is isolated: an unknown term, provenance field, or missing slug refuses only that item and never partially writes it. Use chunks small enough to inspect, then save_batch after review.',
368
+ schema: z.object({
369
+ entries: z.array(z.object({
370
+ slug: z.string().regex(/^[a-z0-9-]+$/),
371
+ fields: z.record(z.any()),
372
+ })).min(1).max(200),
373
+ }),
374
+ handler: async ({ entries }) => {
375
+ const { setFields } = await import('../ingest/fields.mjs');
376
+ const seen = new Set();
377
+ const results = entries.map(({ slug, fields }) => {
378
+ if (seen.has(slug)) return { ok: false, slug, written: [], refused: ['duplicate slug in batch'] };
379
+ seen.add(slug);
380
+ try {
381
+ return setFields({ dir: path.join(ctx.paths.entries, slug), fields, vocab: ctx.vocab });
382
+ } catch (err) {
383
+ return { ok: false, slug, written: [], refused: [err.message] };
384
+ }
385
+ });
386
+ return {
387
+ results,
388
+ updated: results.filter((r) => r.ok).length,
389
+ refused: results.filter((r) => !r.ok).length,
390
+ };
391
+ },
392
+ },
297
393
  ];
298
394
  }
299
395