@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 +26 -5
- package/bin/library.mjs +1 -0
- package/package.json +2 -1
- package/src/cli/upload.mjs +57 -0
- package/src/ingest/save.mjs +28 -6
- package/src/ingest/upload.mjs +110 -0
- package/src/mcp/http.mjs +21 -0
- package/src/mcp/prompts.mjs +71 -10
- package/src/mcp/tools.mjs +104 -8
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,
|
|
264
|
-
`get_entry` (pass `detail: "summary"` for
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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.
|
|
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
|
+
}
|
package/src/ingest/save.mjs
CHANGED
|
@@ -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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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)
|
package/src/mcp/prompts.mjs
CHANGED
|
@@ -23,24 +23,31 @@ session did not make.
|
|
|
23
23
|
|
|
24
24
|
## Order of operations
|
|
25
25
|
|
|
26
|
-
1. **Arguments.** \`
|
|
27
|
-
|
|
28
|
-
returns \`refused\` starting with \`ffmpeg\`, relay the
|
|
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. **
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
169
|
-
if (
|
|
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
|
|
245
|
-
if (
|
|
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
|
|