@yojahny/wp-design-library 1.1.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yojahny/wp-design-library",
3
- "version": "1.1.1",
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/",
@@ -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