@yojahny/wp-design-library 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +4 -0
- package/README.md +70 -3
- package/package.json +42 -6
- package/src/cli/check.mjs +2 -1
- package/src/index/embed.mjs +16 -2
- package/src/index/query.mjs +4 -4
- package/src/ingest/measure.mjs +6 -1
- package/src/mcp/instructions.mjs +17 -0
- package/src/mcp/prompts.mjs +13 -7
- package/src/mcp/server.mjs +5 -2
- package/src/mcp/tools.mjs +65 -6
- package/tests/README.md +12 -0
- package/tests/checks/entry-detail.sh +52 -0
- package/tests/checks/frames-dense.sh +1 -0
- package/tests/checks/index-degrade.sh +1 -1
- package/tests/checks/instructions.sh +31 -0
- package/tests/checks/measure.sh +5 -1
- package/tests/checks/optional-deps.sh +55 -0
- package/tests/checks/recommend.sh +107 -0
- package/tests/checks/resources.sh +3 -0
- package/tests/run.sh +9 -3
package/Dockerfile
CHANGED
|
@@ -14,6 +14,10 @@ RUN if [ "$WITH_CHROME" = "1" ] && [ "$(dpkg --print-architecture)" = "amd64" ];
|
|
|
14
14
|
WORKDIR /app
|
|
15
15
|
COPY package*.json ./
|
|
16
16
|
RUN npm ci --omit=dev
|
|
17
|
+
# The vector arm and URL measurement are optional peers (npm ci skips an optional
|
|
18
|
+
# peer by design); the hosted image wants both, so install them explicitly at the
|
|
19
|
+
# pins package.json declares.
|
|
20
|
+
RUN npm install --no-save --no-audit --no-fund @huggingface/transformers@4.2.0 playwright-core@1.63.0
|
|
17
21
|
COPY . .
|
|
18
22
|
# Vector-search models are not baked into the image: they land on the /data volume
|
|
19
23
|
# under models/ and are fetched at container start when LIBRARY_FETCH_MODELS=1.
|
package/README.md
CHANGED
|
@@ -24,9 +24,28 @@ only for `own` or `open` sources; the licence gate refuses anything else.
|
|
|
24
24
|
```bash
|
|
25
25
|
git clone git@github.com:yojahny55/wp-design-library.git
|
|
26
26
|
cd wp-design-library && npm install
|
|
27
|
-
npm test #
|
|
27
|
+
npm test # 25 checks, all should PASS (measure.sh SKIPs without a system Chrome)
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
## Install: core vs full
|
|
31
|
+
|
|
32
|
+
A plain `npm install` (or the plugin's `npx @yojahny/wp-design-library serve`) gives you
|
|
33
|
+
keyword search, ingest from recordings, the gallery, and every MCP tool — everything except
|
|
34
|
+
the vector arm of `search`/`similar` and live-URL measurement (`add https://…`, `refresh`).
|
|
35
|
+
Those two features sit behind `@huggingface/transformers` and `playwright-core`, listed as
|
|
36
|
+
optional `peerDependencies` rather than regular dependencies precisely so the `npx` path (what
|
|
37
|
+
the plugin actually runs) stays a core-only install. Measured on a fresh npm cache, a cold
|
|
38
|
+
install is 57 MB and takes 6 s, against 736 MB and 48 s when both are regular dependencies;
|
|
39
|
+
513 MB of the difference is `onnxruntime-node`, which transformers pulls in.
|
|
40
|
+
Add them yourself when you want both arms:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm install @huggingface/transformers@4.2.0 playwright-core@1.63.0
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Without them, `search` degrades to the `fts` arm alone (`skipped` names the missing package),
|
|
47
|
+
and `add`/`refresh` refuse a URL input with a named reason instead of crashing.
|
|
48
|
+
|
|
30
49
|
## Adding an entry
|
|
31
50
|
|
|
32
51
|
Adding is two steps by design: the tool does the mechanical part, a Claude
|
|
@@ -225,11 +244,19 @@ node bin/library.mjs index # rebuild the index from entries/
|
|
|
225
244
|
node bin/library.mjs check # validate every entry, 1 line per problem
|
|
226
245
|
```
|
|
227
246
|
|
|
228
|
-
Over MCP,
|
|
229
|
-
`get_entry
|
|
247
|
+
Over MCP, seven tools: `search` (keyword + facet filters, aliases resolved),
|
|
248
|
+
`get_entry` (pass `detail: "summary"` for just the body's first paragraph, instead of
|
|
249
|
+
the default `"full"`), `similar`, `recommend` (turns a brief into a per-role reference
|
|
250
|
+
set in one call), `add`, `refresh`, `save_entry`. `search` and `similar` carry
|
|
230
251
|
`arms` (which retrieval arms ran; `["fts"]` in Phase 1); any tool may instead respond
|
|
231
252
|
with `refused` and the reason.
|
|
232
253
|
|
|
254
|
+
The server also ships MCP `instructions`, read once at session start. They claim what
|
|
255
|
+
this library is authoritative about — role/composition fit and which motion device
|
|
256
|
+
carries a section — and fence what it is not: colour is not this library's to give, the
|
|
257
|
+
client's own material and existing site own the palette. Where the project's own
|
|
258
|
+
conventions and this library disagree, the project wins.
|
|
259
|
+
|
|
233
260
|
## Vector search
|
|
234
261
|
|
|
235
262
|
`index` also builds a `vec` arm — two `sqlite-vec` tables (`vec_text` 384-d,
|
|
@@ -348,6 +375,36 @@ The container runs as `node` (uid 1000), not root: `docker/entrypoint.sh` starts
|
|
|
348
375
|
root only to `chown` an existing (possibly root-owned) `/data` volume, then drops to
|
|
349
376
|
`node` via `setpriv` before exec'ing the server.
|
|
350
377
|
|
|
378
|
+
## Releasing
|
|
379
|
+
|
|
380
|
+
1. Bump `version` in `package.json`.
|
|
381
|
+
2. Tag the release: `git tag vX.Y.Z` (the workflow triggers on any `v*` tag).
|
|
382
|
+
3. Push the tag: `git push origin vX.Y.Z`.
|
|
383
|
+
4. `.github/workflows/publish.yml` runs on that push: checks out, installs with
|
|
384
|
+
`npm ci`, runs `npm test`, then `npm publish`. Trusted publishing means no npm
|
|
385
|
+
token lives in this repo — the registry trusts the GitHub Actions run itself via
|
|
386
|
+
OIDC (`id-token: write`).
|
|
387
|
+
|
|
388
|
+
One-time setup on npmjs.com, before the first tagged release:
|
|
389
|
+
|
|
390
|
+
1. Open the package's settings page on npmjs.com.
|
|
391
|
+
2. Under **Trusted Publisher**, add a publisher.
|
|
392
|
+
3. Choose **GitHub Actions** as the provider.
|
|
393
|
+
4. Organisation: `yojahny55`.
|
|
394
|
+
5. Repository: `wp-design-library`.
|
|
395
|
+
6. Workflow filename: `publish.yml` (the filename only, not the path under
|
|
396
|
+
`.github/workflows/`).
|
|
397
|
+
7. Environment: leave empty (the workflow does not define one).
|
|
398
|
+
|
|
399
|
+
Trusted publisher configurations created now default to **staged** publishing rather
|
|
400
|
+
than a direct `npm publish`. Either flip the allowed-actions toggle on the trusted
|
|
401
|
+
publisher to permit direct publishing (matches the workflow above as written), or
|
|
402
|
+
change the workflow's last step to `npm stage publish` and approve each release by
|
|
403
|
+
hand in the npm UI. Provenance is not generated for this release, because this repo
|
|
404
|
+
is private — provenance requires a public repository. After the first successful
|
|
405
|
+
publish, publishing access can be tightened to require 2FA and disallow tokens
|
|
406
|
+
without breaking this workflow, since it never uses a token.
|
|
407
|
+
|
|
351
408
|
## Known ceilings
|
|
352
409
|
|
|
353
410
|
- **Chrome runs without its own sandbox inside the container.** Docker's default
|
|
@@ -364,6 +421,16 @@ root only to `chown` an existing (possibly root-owned) `/data` volume, then drop
|
|
|
364
421
|
(models and entries) so an old root-owned volume keeps working with no operator
|
|
365
422
|
step; start latency grows with the corpus. Replace it with an ownership probe if it
|
|
366
423
|
ever shows up in the health-check window.
|
|
424
|
+
- **Instructions are advice, not enforcement.** A client is free to ignore every word,
|
|
425
|
+
including the line saying colour is not this library's to give. What the text buys is
|
|
426
|
+
that an agent which has never read `claude-wp-builder`'s command prose now has something
|
|
427
|
+
correct to go on instead of nothing at all.
|
|
428
|
+
- **`recommend` fans out one search per role, server-side.** It saves round-trips and
|
|
429
|
+
transcript, not database work. At the current corpus size that is irrelevant; it is worth
|
|
430
|
+
revisiting if twelve queries per call ever becomes measurable.
|
|
431
|
+
- **`detail: "summary"` skips leading headings and takes the first prose paragraph.** An
|
|
432
|
+
entry whose opening paragraph says nothing useful summarises badly. The fix is a better
|
|
433
|
+
entry, not a cleverer splitter.
|
|
367
434
|
|
|
368
435
|
## Layout
|
|
369
436
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yojahny/wp-design-library",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Design reference corpus and MCP server for claude-wp-builder",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/yojahny55/wp-design-library.git"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/yojahny55/wp-design-library#readme",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/yojahny55/wp-design-library/issues"
|
|
12
|
+
},
|
|
5
13
|
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
|
|
14
|
+
"bin": {
|
|
15
|
+
"library": "bin/library.mjs"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22"
|
|
19
|
+
},
|
|
8
20
|
"scripts": {
|
|
9
21
|
"test": "bash tests/run.sh",
|
|
10
22
|
"check": "node bin/library.mjs check",
|
|
@@ -12,15 +24,39 @@
|
|
|
12
24
|
"serve": "node bin/library.mjs serve"
|
|
13
25
|
},
|
|
14
26
|
"dependencies": {
|
|
15
|
-
"@huggingface/transformers": "4.2.0",
|
|
16
27
|
"@modelcontextprotocol/sdk": "^1.20.0",
|
|
17
28
|
"better-sqlite3": "^12.2.0",
|
|
18
29
|
"js-yaml": "^4.1.0",
|
|
19
|
-
"playwright-core": "1.63.0",
|
|
20
30
|
"sharp": "^0.34.3",
|
|
21
31
|
"sqlite-vec": "0.1.9",
|
|
22
32
|
"zod": "^3.25.0"
|
|
23
33
|
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@huggingface/transformers": "4.2.0",
|
|
36
|
+
"playwright-core": "1.63.0"
|
|
37
|
+
},
|
|
38
|
+
"peerDependenciesMeta": {
|
|
39
|
+
"@huggingface/transformers": {
|
|
40
|
+
"optional": true
|
|
41
|
+
},
|
|
42
|
+
"playwright-core": {
|
|
43
|
+
"optional": true
|
|
44
|
+
}
|
|
45
|
+
},
|
|
24
46
|
"license": "MIT",
|
|
25
|
-
"private": false
|
|
47
|
+
"private": false,
|
|
48
|
+
"files": [
|
|
49
|
+
".dockerignore",
|
|
50
|
+
".env.example",
|
|
51
|
+
"Dockerfile",
|
|
52
|
+
"LICENSE",
|
|
53
|
+
"README.md",
|
|
54
|
+
"bin/",
|
|
55
|
+
"docker-compose.yml",
|
|
56
|
+
"docker/",
|
|
57
|
+
"entries/",
|
|
58
|
+
"src/",
|
|
59
|
+
"tests/",
|
|
60
|
+
"vocab.yaml"
|
|
61
|
+
]
|
|
26
62
|
}
|
package/src/cli/check.mjs
CHANGED
|
@@ -7,7 +7,8 @@ import { fetchModels, modelDirs } from '../index/embed.mjs';
|
|
|
7
7
|
export async function run(args = []) {
|
|
8
8
|
const p = paths();
|
|
9
9
|
if (args.includes('--fetch-models')) {
|
|
10
|
-
await fetchModels(p.models);
|
|
10
|
+
const r = await fetchModels(p.models);
|
|
11
|
+
if (r?.refused) { process.stdout.write(JSON.stringify(r) + '\n'); return 1; }
|
|
11
12
|
const d = modelDirs(p.models);
|
|
12
13
|
process.stdout.write(`model: ${d.text}\nmodel: ${d.image}\n`);
|
|
13
14
|
return 0;
|
package/src/index/embed.mjs
CHANGED
|
@@ -19,6 +19,12 @@ export function modelDirs(models) {
|
|
|
19
19
|
return { text: path.join(models, TEXT_MODEL), image: path.join(models, IMAGE_MODEL) };
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// Resolvable without importing/executing the module, so it works as a cheap, sync
|
|
23
|
+
// "is it installed" probe for the optional peer.
|
|
24
|
+
function packageAvailable(specifier) {
|
|
25
|
+
try { import.meta.resolve(specifier); return true; } catch { return false; }
|
|
26
|
+
}
|
|
27
|
+
|
|
22
28
|
export async function getEmbedder({ models, mode = process.env.LIBRARY_EMBED } = {}) {
|
|
23
29
|
if (mode === 'off') return null;
|
|
24
30
|
if (mode === 'stub') {
|
|
@@ -34,7 +40,9 @@ export async function getEmbedder({ models, mode = process.env.LIBRARY_EMBED } =
|
|
|
34
40
|
}
|
|
35
41
|
const d = modelDirs(models);
|
|
36
42
|
if (!fs.existsSync(d.text) || !fs.existsSync(d.image)) return null; // caller reads absentReason()
|
|
37
|
-
|
|
43
|
+
let tf;
|
|
44
|
+
try { tf = await import('@huggingface/transformers'); }
|
|
45
|
+
catch { return null; } // package not installed; caller reads absentReason()
|
|
38
46
|
tf.env.cacheDir = models; tf.env.allowRemoteModels = process.env.LIBRARY_FETCH_MODELS === '1';
|
|
39
47
|
const textPipe = await tf.pipeline('feature-extraction', TEXT_MODEL);
|
|
40
48
|
const proc = await tf.AutoProcessor.from_pretrained(IMAGE_MODEL);
|
|
@@ -55,13 +63,19 @@ export function absentReason(models, mode = process.env.LIBRARY_EMBED) {
|
|
|
55
63
|
// An explicit opt-out is the reason on its own; do not let a stale/present
|
|
56
64
|
// model dir on disk relabel it, and do not touch the filesystem to find out.
|
|
57
65
|
if (mode === 'off') return 'LIBRARY_EMBED=off';
|
|
66
|
+
// The package is a smaller, more fundamental blocker than an unfetched model
|
|
67
|
+
// directory, and it's checked first so a host missing the peer gets told that,
|
|
68
|
+
// not a misleading "models absent" (a fresh host has neither).
|
|
69
|
+
if (!packageAvailable('@huggingface/transformers')) return '@huggingface/transformers is not installed';
|
|
58
70
|
const d = modelDirs(models);
|
|
59
71
|
const missing = Object.entries(d).filter(([, p]) => !fs.existsSync(p)).map(([k]) => k);
|
|
60
72
|
return missing.length ? `models absent: ${missing.join(', ')} under ${models}` : null;
|
|
61
73
|
}
|
|
62
74
|
|
|
63
75
|
export async function fetchModels(models) {
|
|
64
|
-
|
|
76
|
+
let tf;
|
|
77
|
+
try { tf = await import('@huggingface/transformers'); }
|
|
78
|
+
catch { return { refused: '@huggingface/transformers is not installed' }; }
|
|
65
79
|
tf.env.cacheDir = models; tf.env.allowRemoteModels = true;
|
|
66
80
|
await tf.pipeline('feature-extraction', TEXT_MODEL);
|
|
67
81
|
await tf.AutoProcessor.from_pretrained(IMAGE_MODEL);
|
package/src/index/query.mjs
CHANGED
|
@@ -20,7 +20,7 @@ function filterSql(filters = {}, col = 'slug') {
|
|
|
20
20
|
|
|
21
21
|
const RRF_K = 60;
|
|
22
22
|
|
|
23
|
-
export async function search(db, { query = '', filters = {}, limit = 10, embedder = null }) {
|
|
23
|
+
export async function search(db, { query = '', filters = {}, limit = 10, embedder = null, embedderReason = null }) {
|
|
24
24
|
const { where, params } = filterSql(filters, 'e.slug');
|
|
25
25
|
if (!query.trim()) {
|
|
26
26
|
const { where, params } = filterSql(filters);
|
|
@@ -39,7 +39,7 @@ export async function search(db, { query = '', filters = {}, limit = 10, embedde
|
|
|
39
39
|
return { arms: ['fts'], skipped: [{ arm: 'vec', reason }], hits: rows };
|
|
40
40
|
};
|
|
41
41
|
if (!(db.vec && embedder)) {
|
|
42
|
-
return ftsOnly(db.vec ? 'no embedder' : 'sqlite-vec not loaded');
|
|
42
|
+
return ftsOnly(db.vec ? (embedderReason ?? 'no embedder') : 'sqlite-vec not loaded');
|
|
43
43
|
}
|
|
44
44
|
let vec;
|
|
45
45
|
try {
|
|
@@ -65,9 +65,9 @@ export async function search(db, { query = '', filters = {}, limit = 10, embedde
|
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
export async function similar(db, { slug, image, limit = 10, embedder = null }) {
|
|
68
|
+
export async function similar(db, { slug, image, limit = 10, embedder = null, embedderReason = null }) {
|
|
69
69
|
if (!(db.vec && embedder)) {
|
|
70
|
-
return { refused: `similar needs the image arm: ${db.vec ? 'no embedder' : 'sqlite-vec not loaded'}` };
|
|
70
|
+
return { refused: `similar needs the image arm: ${db.vec ? (embedderReason ?? 'no embedder') : 'sqlite-vec not loaded'}` };
|
|
71
71
|
}
|
|
72
72
|
let vec;
|
|
73
73
|
if (slug) {
|
package/src/ingest/measure.mjs
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { chromium } from 'playwright-core';
|
|
4
3
|
|
|
5
4
|
const VIEWPORT_WIDE = { width: 1440, height: 900 };
|
|
6
5
|
const VIEWPORT_NARROW = { width: 390, height: 844 };
|
|
@@ -87,6 +86,12 @@ function collect() {
|
|
|
87
86
|
// comes back as { ok: false, error }, with whatever partial output (frames,
|
|
88
87
|
// screenshots) had already been captured still attached.
|
|
89
88
|
export async function measure(url, outDir, { chrome = process.env.CHROME_PATH, record = false, stops = 12 } = {}) {
|
|
89
|
+
let chromium;
|
|
90
|
+
try {
|
|
91
|
+
({ chromium } = await import('playwright-core'));
|
|
92
|
+
} catch {
|
|
93
|
+
return { ok: false, error: 'playwright-core is not installed; install it for URL measurement' };
|
|
94
|
+
}
|
|
90
95
|
if (!chrome || !fs.existsSync(chrome)) {
|
|
91
96
|
return { ok: false, error: 'chrome not available: CHROME_PATH not set or not found' };
|
|
92
97
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Reaches every session in a project where this server is registered, not only the
|
|
2
|
+
// command that wants it — so it is kept under 1500 characters and says only what a
|
|
3
|
+
// client cannot infer from the tool descriptions. tests/checks/instructions.sh pins
|
|
4
|
+
// the size and the three claims that keep a client from misusing the library.
|
|
5
|
+
export const INSTRUCTIONS = `A corpus of design references for building WordPress page sections. Each entry carries roles (hero, proof, feature, offer, testimonial, faq, closing...), feel tags, the data-motion device names it uses, and a scroll strip.
|
|
6
|
+
|
|
7
|
+
AUTHORITATIVE ABOUT: which composition suits a role, and which motion device carries it. NOT about colour — the client's own material and their existing site own the palette. An entry's colours are context for how that reference reads, never tokens to adopt.
|
|
8
|
+
|
|
9
|
+
CALL IT WHEN: building or redesigning a page section; choosing a motion device; needing a worked example of a role.
|
|
10
|
+
|
|
11
|
+
DO NOT CALL IT WHEN: the project already has a design system or tokens — follow those; the work is copy, logic, tests or infrastructure; the design is already specified.
|
|
12
|
+
|
|
13
|
+
Where the project's own conventions and this library disagree, the project wins.
|
|
14
|
+
|
|
15
|
+
BUDGET: a tool result is re-read on every later turn, so a study costs the transcript, not the call. One recommend, or one search per role you actually need, then get_entry on the three to five you keep, is a complete study. A further search costs more than it finds. Use get_entry with detail:"summary" to decide, detail:"full" only to build.
|
|
16
|
+
|
|
17
|
+
READING A HIT: ranks.fts null means it matched by semantic distance alone, with no keyword in common. A role whose hits are all weak has no reference — report that rather than filling it with a weak one.`;
|
package/src/mcp/prompts.mjs
CHANGED
|
@@ -85,9 +85,10 @@ and \`public-site\` are inspiration only and \`save_entry\` refuses \`tier: port
|
|
|
85
85
|
- Never run git in the current project.`;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
// brief-from-library turns a client brief into a
|
|
89
|
-
//
|
|
90
|
-
//
|
|
88
|
+
// brief-from-library turns a client brief into a reference set: one `recommend` call,
|
|
89
|
+
// then `get_entry` on what survives, then a `## References` block the session fills in
|
|
90
|
+
// with the slugs it actually used. The per-role `search` fan-out is kept as a fallback
|
|
91
|
+
// for a client whose server predates `recommend`.
|
|
91
92
|
|
|
92
93
|
export function briefFromLibraryPrompt(ctx, { brief }) {
|
|
93
94
|
const v = ctx.vocab.facets;
|
|
@@ -101,9 +102,14 @@ ${brief}
|
|
|
101
102
|
|
|
102
103
|
## What to do
|
|
103
104
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
Call the \`recommend\` tool once with this brief. It runs one search per role and
|
|
106
|
+
returns a compact per-role digest. Read the digest, then call \`get_entry\` with
|
|
107
|
+
\`detail: "summary"\` on the candidates worth considering and \`detail: "full"\` on the
|
|
108
|
+
ones you will actually build from.
|
|
109
|
+
|
|
110
|
+
If \`recommend\` is unavailable, fall back to calling \`search\` once per role
|
|
111
|
+
(${roles}) with \`limit: 3\` and \`filters.feel\` set to whichever feel terms
|
|
112
|
+
(${feels}) match the brief's own vibe words.
|
|
107
113
|
|
|
108
114
|
Read the results, pick the entries that best fit the brief for each role, and use
|
|
109
115
|
\`get_entry\` on any slug whose body you need to see in full before deciding.
|
|
@@ -123,7 +129,7 @@ export function registerPrompts(server, ctx) {
|
|
|
123
129
|
server.registerPrompt(
|
|
124
130
|
'brief-from-library',
|
|
125
131
|
{
|
|
126
|
-
description: "Turn a client brief into a
|
|
132
|
+
description: "Turn a client brief into a per-role reference set against this library: one recommend call, then a References block of the slugs actually used.",
|
|
127
133
|
argsSchema: { brief: z.string().describe('the client brief or page description to search the library against') },
|
|
128
134
|
},
|
|
129
135
|
async (a) => ({ messages: [{ role: 'user', content: { type: 'text', text: briefFromLibraryPrompt(ctx, a) } }] }),
|
package/src/mcp/server.mjs
CHANGED
|
@@ -2,10 +2,11 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
3
|
import { paths } from '../paths.mjs';
|
|
4
4
|
import { loadVocab } from '../vocab.mjs';
|
|
5
|
-
import { getEmbedder } from '../index/embed.mjs';
|
|
5
|
+
import { getEmbedder, absentReason } from '../index/embed.mjs';
|
|
6
6
|
import { registerPrompts } from './prompts.mjs';
|
|
7
7
|
import { registerResources } from './resources.mjs';
|
|
8
8
|
import { makeTools, registerTools } from './tools.mjs';
|
|
9
|
+
import { INSTRUCTIONS } from './instructions.mjs';
|
|
9
10
|
|
|
10
11
|
export async function buildServer(opts = {}) {
|
|
11
12
|
const transport = opts.transport ?? 'stdio';
|
|
@@ -13,7 +14,9 @@ export async function buildServer(opts = {}) {
|
|
|
13
14
|
const ctx = { paths: p, vocab: loadVocab(p.vocab), transport, models: p.models };
|
|
14
15
|
// http passes its own cached embedder in (one per process, not one per request); stdio resolves its own.
|
|
15
16
|
ctx.embedder = 'embedder' in opts ? opts.embedder : await getEmbedder({ models: ctx.models });
|
|
16
|
-
|
|
17
|
+
// Names why, when it's null: off / package not installed / models absent are distinct.
|
|
18
|
+
ctx.embedderReason = ctx.embedder ? null : absentReason(ctx.models);
|
|
19
|
+
const server = new McpServer({ name: 'wp-design-library', version: p.version }, { instructions: INSTRUCTIONS });
|
|
17
20
|
registerTools(server, makeTools(ctx));
|
|
18
21
|
registerPrompts(server, ctx);
|
|
19
22
|
registerResources(server, ctx);
|
package/src/mcp/tools.mjs
CHANGED
|
@@ -70,17 +70,34 @@ export function makeTools(ctx) {
|
|
|
70
70
|
}),
|
|
71
71
|
handler: withDb(async (db, a) => {
|
|
72
72
|
const { filters, unknown } = resolveFilters(ctx.vocab, a.filters ?? {});
|
|
73
|
-
const result = await search(db, { ...a, filters, embedder: ctx.embedder });
|
|
73
|
+
const result = await search(db, { ...a, filters, embedder: ctx.embedder, embedderReason: ctx.embedderReason });
|
|
74
74
|
return unknown.length ? { ...result, unknown } : result;
|
|
75
75
|
}),
|
|
76
76
|
},
|
|
77
77
|
{
|
|
78
78
|
name: 'get_entry',
|
|
79
|
-
description:
|
|
80
|
-
schema: z.object({
|
|
81
|
-
|
|
79
|
+
description: "Return one entry: frontmatter, prose body, and the path to its strip.png. detail:'summary' returns the body's first paragraph instead of the whole body — enough to decide whether to fetch the rest.",
|
|
80
|
+
schema: z.object({
|
|
81
|
+
slug: z.string(),
|
|
82
|
+
detail: z.enum(['summary', 'full']).default('full'),
|
|
83
|
+
}),
|
|
84
|
+
handler: withDb((db, { slug, detail }) => {
|
|
82
85
|
const e = getEntry(db, slug);
|
|
83
|
-
|
|
86
|
+
if (!e) return { refused: `no entry with slug ${slug}` };
|
|
87
|
+
const out = { ...e, strip: `${e.dir}/strip.png` };
|
|
88
|
+
if (detail !== 'summary') return out;
|
|
89
|
+
// The first PROSE paragraph. Bodies conventionally open with `## What it does`,
|
|
90
|
+
// so splitting on the first blank line alone would return a bare heading, which
|
|
91
|
+
// tells a caller nothing. Skip leading heading lines, then take one paragraph.
|
|
92
|
+
// An entry whose first paragraph says nothing useful summarises badly — that is
|
|
93
|
+
// an entry to rewrite, not a splitter to make cleverer.
|
|
94
|
+
// This relies on the authoring convention that a heading is followed by a blank
|
|
95
|
+
// line: a body with no blank line after its heading is one unsplit paragraph
|
|
96
|
+
// that still starts with `#`, so it's skipped and the fallback returns that same
|
|
97
|
+
// unsplit blob — summary equals full for that entry. Also an entry to rewrite.
|
|
98
|
+
const paras = String(out.body ?? '').trim().split(/\n\s*\n/);
|
|
99
|
+
const first = paras.find((x) => !/^\s*#/.test(x)) ?? paras[0] ?? '';
|
|
100
|
+
return { ...out, body: first.trim(), detail: 'summary' };
|
|
84
101
|
}),
|
|
85
102
|
},
|
|
86
103
|
{
|
|
@@ -97,7 +114,49 @@ export function makeTools(ctx) {
|
|
|
97
114
|
const refusal = confineToInbox(ctx, a.image);
|
|
98
115
|
if (refusal) return { refused: refusal };
|
|
99
116
|
}
|
|
100
|
-
return similar(db, { ...a, embedder: ctx.embedder });
|
|
117
|
+
return similar(db, { ...a, embedder: ctx.embedder, embedderReason: ctx.embedderReason });
|
|
118
|
+
}),
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: 'recommend',
|
|
122
|
+
description: "Turn a brief into a per-role reference set in one call. Runs one search per vocabulary role with feel filters drawn from the brief's own words, and returns a compact digest — no prose bodies. Use get_entry on the three to five you keep. A hit marked weak matched by semantic distance alone, with no keyword in common; a role whose hits are all weak has no reference.",
|
|
123
|
+
schema: z.object({
|
|
124
|
+
brief: z.string().trim().min(1),
|
|
125
|
+
limit: z.number().int().min(1).max(10).default(3),
|
|
126
|
+
}),
|
|
127
|
+
handler: withDb(async (db, { brief, limit }) => {
|
|
128
|
+
// Feel terms are whichever vocabulary feels the brief actually names. Matching
|
|
129
|
+
// on the brief's own words keeps the filter honest: a brief that says nothing
|
|
130
|
+
// about feel gets no feel filter rather than a guessed one.
|
|
131
|
+
const words = brief.toLowerCase();
|
|
132
|
+
const feel = [...ctx.vocab.facets.feel].filter((f) => words.includes(f));
|
|
133
|
+
let arms = null;
|
|
134
|
+
let skipped;
|
|
135
|
+
const roles = [];
|
|
136
|
+
for (const role of ctx.vocab.facets.role) {
|
|
137
|
+
const r = await search(db, {
|
|
138
|
+
query: brief,
|
|
139
|
+
filters: { role: [role], ...(feel.length ? { feel } : {}) },
|
|
140
|
+
limit,
|
|
141
|
+
embedder: ctx.embedder,
|
|
142
|
+
embedderReason: ctx.embedderReason,
|
|
143
|
+
});
|
|
144
|
+
arms ??= r.arms;
|
|
145
|
+
skipped ??= r.skipped;
|
|
146
|
+
roles.push({
|
|
147
|
+
role,
|
|
148
|
+
hits: (r.hits ?? []).map((h) => ({
|
|
149
|
+
slug: h.slug,
|
|
150
|
+
title: h.title,
|
|
151
|
+
tier: h.tier,
|
|
152
|
+
score: h.score,
|
|
153
|
+
// ranks is absent when only one arm ran; a hit is weak only when the
|
|
154
|
+
// fused result says it had no keyword match.
|
|
155
|
+
weak: Boolean(h.ranks) && h.ranks.fts === null,
|
|
156
|
+
})),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return { arms: arms ?? ['fts'], ...(skipped ? { skipped } : {}), feel, roles };
|
|
101
160
|
}),
|
|
102
161
|
},
|
|
103
162
|
{
|
package/tests/README.md
CHANGED
|
@@ -19,3 +19,15 @@ no port; it exercises `add`/`export`/`index`/`check`/`save` directly or drives `
|
|
|
19
19
|
(no `--http`) over stdio.
|
|
20
20
|
|
|
21
21
|
A new serve-based check takes the next free port below 4191.
|
|
22
|
+
|
|
23
|
+
## Checks with no port
|
|
24
|
+
|
|
25
|
+
`instructions.sh`, `entry-detail.sh` and `recommend.sh` drive the server over stdio
|
|
26
|
+
(`serve`, no `--http`) and start no server on a fixed port, so the table above is
|
|
27
|
+
unchanged by them.
|
|
28
|
+
|
|
29
|
+
| Check | Asserts |
|
|
30
|
+
|---|---|
|
|
31
|
+
| instructions.sh | the `initialize` response's `instructions`: present, under the 1500-char budget, and carrying the colour fence, the project-wins line, the call budget, and the `ranks.fts` explanation |
|
|
32
|
+
| entry-detail.sh | `get_entry`'s `detail: "summary"` returns a strictly shorter body than `detail: "full"` for the same slug, with matching frontmatter and strip path |
|
|
33
|
+
| recommend.sh | `recommend` returns one group per vocabulary role, weak hits marked, no prose bodies, and the same named-reason degradation `search` already gives when the vector arm is absent |
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# summary returns a strictly shorter body than full for the same slug, and both
|
|
3
|
+
# carry the same frontmatter and strip path — a summary that dropped either would
|
|
4
|
+
# be a different tool, not a cheaper one.
|
|
5
|
+
set -eu
|
|
6
|
+
cd "$(dirname "$0")/../.."
|
|
7
|
+
tmp=$(mktemp -d); export LIBRARY_SEED="$tmp/noseed"; trap 'rm -rf "$tmp"' EXIT
|
|
8
|
+
mkdir -p "$tmp/entries"; cp -r tests/fixtures/entry-ok "$tmp/entries/entry-ok"
|
|
9
|
+
LIBRARY_DATA="$tmp" node bin/library.mjs index >/dev/null 2>&1
|
|
10
|
+
{
|
|
11
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}'
|
|
12
|
+
printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}'
|
|
13
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_entry","arguments":{"slug":"entry-ok"}}}'
|
|
14
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_entry","arguments":{"slug":"entry-ok","detail":"summary"}}}'
|
|
15
|
+
sleep 1
|
|
16
|
+
} | LIBRARY_DATA="$tmp" timeout 20 node bin/library.mjs serve >"$tmp/out" 2>"$tmp/err" || true
|
|
17
|
+
|
|
18
|
+
node --input-type=module -e '
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
const byId = {};
|
|
21
|
+
for (const line of fs.readFileSync(process.argv[1], "utf8").split("\n")) {
|
|
22
|
+
if (!line.startsWith("{")) continue;
|
|
23
|
+
const d = JSON.parse(line);
|
|
24
|
+
if (d.id) byId[d.id] = d;
|
|
25
|
+
}
|
|
26
|
+
const fail = (m) => { console.error("FAIL: " + m); process.exit(1); };
|
|
27
|
+
const full = byId[2]?.result?.structuredContent;
|
|
28
|
+
const sum = byId[3]?.result?.structuredContent;
|
|
29
|
+
if (!full) fail("no full result");
|
|
30
|
+
if (!sum) fail("no summary result");
|
|
31
|
+
if (typeof sum.body !== "string") fail("summary has no body");
|
|
32
|
+
if (!(sum.body.length < full.body.length)) fail(`summary body (${sum.body.length}) is not shorter than full (${full.body.length})`);
|
|
33
|
+
if (/^\s*#/.test(sum.body)) fail(`summary is a bare heading, not prose: ${JSON.stringify(sum.body)}`);
|
|
34
|
+
if (/\n\s*\n/.test(sum.body)) fail(`summary spans a paragraph break, so it is a truncation not a paragraph: ${JSON.stringify(sum.body)}`);
|
|
35
|
+
if (/^\s*#/m.test(sum.body)) fail(`summary contains a heading line: ${JSON.stringify(sum.body)}`);
|
|
36
|
+
if (sum.body.trim().length < 20) fail(`summary is too short to decide from: ${JSON.stringify(sum.body)}`);
|
|
37
|
+
if (sum.detail !== "summary") fail("summary result is not labelled detail:summary");
|
|
38
|
+
if (sum.strip !== full.strip) fail("summary lost the strip path");
|
|
39
|
+
if (JSON.stringify(sum.fm) !== JSON.stringify(full.fm)) fail("summary changed the frontmatter");
|
|
40
|
+
// A splitter that FILTERS every prose paragraph and joins them (instead of taking
|
|
41
|
+
// just the first) can still pass every assertion above: joined with a space instead
|
|
42
|
+
// of a blank line, it dodges the paragraph-break check, and it is still shorter than
|
|
43
|
+
// full because only headings were dropped. Pin the actual promise: summary is a
|
|
44
|
+
// PREFIX of full body, and a later prose paragraph never appears in it.
|
|
45
|
+
if (!full.body.includes(sum.body)) fail(`summary is not a substring of full body: ${JSON.stringify(sum.body)}`);
|
|
46
|
+
const fullProse = full.body.trim().split(/\n\s*\n/).filter((x) => !/^\s*#/.test(x));
|
|
47
|
+
if (fullProse.length < 2) fail("fixture needs more than one prose paragraph to exercise this assertion");
|
|
48
|
+
const later = fullProse[fullProse.length - 1];
|
|
49
|
+
if (sum.body.includes(later)) fail(`summary includes a later prose paragraph, so it did not stop at the first: ${JSON.stringify(later)}`);
|
|
50
|
+
console.log(`full ${full.body.length} chars, summary ${sum.body.length}`);
|
|
51
|
+
' "$tmp/out" || { cat "$tmp/err"; exit 1; }
|
|
52
|
+
echo PASS
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
set -eu
|
|
3
3
|
cd "$(dirname "$0")/../.."
|
|
4
|
+
command -v ffmpeg >/dev/null || { echo "SKIP: ffmpeg not installed"; exit 0; }
|
|
4
5
|
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
5
6
|
mkdir -p "$tmp/f" "$tmp/data/entries"
|
|
6
7
|
node --input-type=module -e 'import sharp from "sharp"; for (let i = 0; i < 60; i++) await sharp({create:{width:40,height:20,channels:3,background:{r:i*4,g:0,b:0}}}).png().toFile(process.argv[1] + "/" + i + ".png")' "$tmp/f"
|
|
@@ -6,7 +6,7 @@ mkdir -p "$tmp/entries"; cp -r tests/fixtures/entry-ok "$tmp/entries/entry-ok"
|
|
|
6
6
|
# models absent → fts only, skip reported, exit 0
|
|
7
7
|
out=$(LIBRARY_DATA="$tmp" LIBRARY_EMBED= node bin/library.mjs index 2>&1)
|
|
8
8
|
echo "$out" | grep -qE "arms: fts([^,]|$)" || { echo "FAIL: arms without models; got: $out"; exit 1; }
|
|
9
|
-
echo "$out" | grep -
|
|
9
|
+
echo "$out" | grep -qE "embeddings: skipped \((models absent|@huggingface/transformers is not installed)" || { echo "FAIL: skip line; got: $out"; exit 1; }
|
|
10
10
|
# stub → both arms, vectors stored with the right dims
|
|
11
11
|
out=$(LIBRARY_DATA="$tmp" LIBRARY_EMBED=stub node bin/library.mjs index 2>&1)
|
|
12
12
|
echo "$out" | grep -q "arms: fts,vec" || { echo "FAIL: arms with stub; got: $out"; exit 1; }
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# The server's instructions reach every session in a project where it is registered,
|
|
3
|
+
# so they are asserted for presence, for size, and for the three claims that keep a
|
|
4
|
+
# client from misusing the library: colour is not ours, the project wins, and a study
|
|
5
|
+
# has a budget.
|
|
6
|
+
set -eu
|
|
7
|
+
cd "$(dirname "$0")/../.."
|
|
8
|
+
tmp=$(mktemp -d); export LIBRARY_SEED="$tmp/noseed"; trap 'rm -rf "$tmp"' EXIT
|
|
9
|
+
mkdir -p "$tmp/entries"
|
|
10
|
+
{
|
|
11
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}'
|
|
12
|
+
sleep 1
|
|
13
|
+
} | LIBRARY_DATA="$tmp" timeout 20 node bin/library.mjs serve >"$tmp/out" 2>"$tmp/err" || true
|
|
14
|
+
|
|
15
|
+
ins=$(node --input-type=module -e '
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
for (const line of fs.readFileSync(process.argv[1], "utf8").split("\n")) {
|
|
18
|
+
if (!line.startsWith("{")) continue;
|
|
19
|
+
const d = JSON.parse(line);
|
|
20
|
+
if (d.id === 1) { process.stdout.write(d.result.instructions ?? ""); break; }
|
|
21
|
+
}' "$tmp/out")
|
|
22
|
+
|
|
23
|
+
[ -n "$ins" ] || { echo "FAIL: no instructions on initialize"; cat "$tmp/err"; exit 1; }
|
|
24
|
+
n=${#ins}
|
|
25
|
+
[ "$n" -lt 1500 ] || { echo "FAIL: instructions are $n chars, budget is 1500"; exit 1; }
|
|
26
|
+
printf '%s' "$ins" | grep -q 'NOT about colour' || { echo "FAIL: instructions do not fence colour"; exit 1; }
|
|
27
|
+
printf '%s' "$ins" | grep -q 'the project wins' || { echo "FAIL: instructions do not defer to the project"; exit 1; }
|
|
28
|
+
printf '%s' "$ins" | grep -q 'costs more than it finds' || { echo "FAIL: instructions carry no call budget"; exit 1; }
|
|
29
|
+
printf '%s' "$ins" | grep -q 'ranks.fts' || { echo "FAIL: instructions do not explain a weak hit"; exit 1; }
|
|
30
|
+
echo "instructions: $n chars"
|
|
31
|
+
echo PASS
|
package/tests/checks/measure.sh
CHANGED
|
@@ -3,6 +3,8 @@ set -eu
|
|
|
3
3
|
cd "$(dirname "$0")/../.."
|
|
4
4
|
[ -n "${CHROME_PATH:-}" ] || CHROME_PATH=$(command -v google-chrome || command -v chromium || true)
|
|
5
5
|
[ -n "$CHROME_PATH" ] || { echo "SKIP: no chrome/chromium found"; exit 0; }
|
|
6
|
+
node --input-type=module -e 'await import("playwright-core")' 2>/dev/null \
|
|
7
|
+
|| { echo "SKIP: playwright-core not installed"; exit 0; }
|
|
6
8
|
export CHROME_PATH
|
|
7
9
|
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
8
10
|
mkdir -p "$tmp/entries"
|
|
@@ -51,7 +53,9 @@ if (strip.height < single.height * 2) { console.error('FAIL: strip height ' + st
|
|
|
51
53
|
# refresh re-measures and rewrites captured.at
|
|
52
54
|
out=$(LIBRARY_DATA="$tmp" node bin/library.mjs refresh fx)
|
|
53
55
|
echo "$out" | grep -q '"ok":true' || { echo "FAIL: refresh did not succeed"; echo "$out"; exit 1; }
|
|
54
|
-
|
|
56
|
+
# UTC, because measure.mjs writes `new Date().toISOString()`. Using local `date`
|
|
57
|
+
# here fails every evening west of Greenwich, on a correct entry.
|
|
58
|
+
today=$(date -u +%Y-%m-%d)
|
|
55
59
|
grep -Eq "^ at: '?$today'?\$" "$tmp/entries/fx/entry.md" || { echo "FAIL: captured.at not rewritten to today"; cat "$tmp/entries/fx/entry.md"; exit 1; }
|
|
56
60
|
|
|
57
61
|
# add without --record: strip is just the 1440 screenshot, method is measured (not both)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -eu
|
|
3
|
+
cd "$(dirname "$0")/../.."
|
|
4
|
+
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
5
|
+
mkdir -p "$tmp/entries"; cp -r tests/fixtures/entry-ok "$tmp/entries/entry-ok"
|
|
6
|
+
|
|
7
|
+
# A sandbox that genuinely lacks the two optional peers: copy the code, then symlink
|
|
8
|
+
# every real node_modules entry except @huggingface/transformers and playwright-core.
|
|
9
|
+
# Scoped packages are symlinked individually under @scope/, not the scope directory
|
|
10
|
+
# itself — symlinking the scope dir would drag the excluded package back in.
|
|
11
|
+
sandbox="$tmp/sandbox"
|
|
12
|
+
mkdir -p "$sandbox/node_modules"
|
|
13
|
+
cp -r bin src package.json vocab.yaml "$sandbox/"
|
|
14
|
+
for entry in node_modules/*; do
|
|
15
|
+
name=$(basename "$entry")
|
|
16
|
+
case "$name" in
|
|
17
|
+
@*|playwright-core) continue ;;
|
|
18
|
+
esac
|
|
19
|
+
ln -s "$PWD/$entry" "$sandbox/node_modules/$name"
|
|
20
|
+
done
|
|
21
|
+
for scope in node_modules/@*; do
|
|
22
|
+
sname=$(basename "$scope")
|
|
23
|
+
mkdir -p "$sandbox/node_modules/$sname"
|
|
24
|
+
for pkg in "$scope"/*; do
|
|
25
|
+
pname=$(basename "$pkg")
|
|
26
|
+
[ "$sname/$pname" = "@huggingface/transformers" ] && continue
|
|
27
|
+
ln -s "$PWD/$pkg" "$sandbox/node_modules/$sname/$pname"
|
|
28
|
+
done
|
|
29
|
+
done
|
|
30
|
+
[ ! -e "$sandbox/node_modules/playwright-core" ] || { echo "FAIL: sandbox still has playwright-core"; exit 1; }
|
|
31
|
+
[ ! -e "$sandbox/node_modules/@huggingface/transformers" ] || { echo "FAIL: sandbox still has @huggingface/transformers"; exit 1; }
|
|
32
|
+
|
|
33
|
+
# search over stdio: fts only, skip names the transformers package
|
|
34
|
+
{
|
|
35
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}'
|
|
36
|
+
printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}'
|
|
37
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search","arguments":{"query":"parallax"}}}'
|
|
38
|
+
sleep 1
|
|
39
|
+
} | LIBRARY_DATA="$tmp" LIBRARY_SEED="$tmp/noseed" timeout 10 node "$sandbox/bin/library.mjs" serve >"$tmp/out" 2>"$tmp/err" || true
|
|
40
|
+
grep -q '"arms":\["fts"\]' "$tmp/out" || { echo "FAIL: arms not fts-only; got:"; cat "$tmp/out"; exit 1; }
|
|
41
|
+
grep -q '"skipped":\[{"arm":"vec","reason":"@huggingface/transformers is not installed"}\]' "$tmp/out" \
|
|
42
|
+
|| { echo "FAIL: skipped reason did not name the transformers package; got:"; cat "$tmp/out"; exit 1; }
|
|
43
|
+
|
|
44
|
+
# add <https url>: refused, naming playwright-core
|
|
45
|
+
out=$(LIBRARY_DATA="$tmp" node "$sandbox/bin/library.mjs" add https://example.com --slug px-url --title "Px Url" --source own --license x) || true
|
|
46
|
+
echo "$out" | grep -q '"refused"' || { echo "FAIL: https add did not refuse; got: $out"; exit 1; }
|
|
47
|
+
echo "$out" | grep -q "playwright-core is not installed" || { echo "FAIL: refusal did not name playwright-core; got: $out"; exit 1; }
|
|
48
|
+
|
|
49
|
+
# add <local recording>: still works — the regression a static playwright-core import
|
|
50
|
+
# in measure.mjs would cause, since add.mjs imports measure.mjs unconditionally
|
|
51
|
+
out=$(LIBRARY_DATA="$tmp" node "$sandbox/bin/library.mjs" add "$PWD/tests/fixtures/three-frame.webp" --slug px-local --title "Px Local" --source own --license x)
|
|
52
|
+
echo "$out" | grep -q '"blanks"' || { echo "FAIL: local-file add did not draft; got: $out"; exit 1; }
|
|
53
|
+
[ -f "$tmp/entries/px-local/strip.png" ] || { echo "FAIL: strip.png missing for local-file add"; exit 1; }
|
|
54
|
+
|
|
55
|
+
echo PASS
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# recommend replaces a twelve-call client fan-out with one call: one group per
|
|
3
|
+
# vocabulary role, no prose bodies, weak hits marked, and the same named-reason
|
|
4
|
+
# degradation search already gives when the vector arm is absent.
|
|
5
|
+
set -eu
|
|
6
|
+
cd "$(dirname "$0")/../.."
|
|
7
|
+
tmp=$(mktemp -d); export LIBRARY_SEED="$tmp/noseed"; trap 'rm -rf "$tmp"' EXIT
|
|
8
|
+
mkdir -p "$tmp/entries"; cp -r tests/fixtures/entry-ok "$tmp/entries/entry-ok"
|
|
9
|
+
# entries_fts is a trigram index (schema.sql), and search() ANDs one quoted phrase
|
|
10
|
+
# per query word (query.mjs's fts5Quote): a 1-character phrase like "a" can never
|
|
11
|
+
# match under a trigram tokenizer, no matter what any entry contains. A brief built
|
|
12
|
+
# from ordinary English ("a ... and a ...") is therefore guaranteed zero hits on
|
|
13
|
+
# every role, which would let the weak-marking assertion below pass vacuously
|
|
14
|
+
# (empty hits arrays, never actually checked) even if `weak` were dropped entirely.
|
|
15
|
+
# So: drop the two 1-char words from the brief, and seed a second fixture entry
|
|
16
|
+
# whose title carries every remaining query word verbatim, so role "hero" gets a
|
|
17
|
+
# real hit and the weak-key assertion is actually exercised.
|
|
18
|
+
mkdir -p "$tmp/entries/entry-hit"; cp tests/fixtures/entry-ok/strip.png "$tmp/entries/entry-hit/strip.png"
|
|
19
|
+
cat > "$tmp/entries/entry-hit/entry.md" <<'EOF'
|
|
20
|
+
---
|
|
21
|
+
slug: entry-hit
|
|
22
|
+
title: dark premium hero and pricing table
|
|
23
|
+
tier: inspiration
|
|
24
|
+
source:
|
|
25
|
+
kind: open
|
|
26
|
+
url: https://example.com
|
|
27
|
+
license: MIT
|
|
28
|
+
captured:
|
|
29
|
+
method: frames
|
|
30
|
+
at: 2026-09-14
|
|
31
|
+
tool: wp-design-library@0.1.0
|
|
32
|
+
roles: [hero]
|
|
33
|
+
feel: [dark, premium]
|
|
34
|
+
palette:
|
|
35
|
+
canvas: "#0b0f14"
|
|
36
|
+
ink: "#f4f6f8"
|
|
37
|
+
accent: "#6ea8ff"
|
|
38
|
+
type:
|
|
39
|
+
display: geometric sans, light
|
|
40
|
+
body: same family
|
|
41
|
+
motion:
|
|
42
|
+
devices: [reveal]
|
|
43
|
+
notes: fades in gently
|
|
44
|
+
ported_from: null
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## What it does
|
|
48
|
+
|
|
49
|
+
A second fixture entry seeded so recommend has a real hit to mark weak or not.
|
|
50
|
+
|
|
51
|
+
## Section roster
|
|
52
|
+
|
|
53
|
+
hero
|
|
54
|
+
EOF
|
|
55
|
+
LIBRARY_DATA="$tmp" node bin/library.mjs index >/dev/null 2>&1
|
|
56
|
+
{
|
|
57
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}'
|
|
58
|
+
printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}'
|
|
59
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"recommend","arguments":{"brief":"dark premium hero and pricing table"}}}'
|
|
60
|
+
sleep 1
|
|
61
|
+
} | LIBRARY_DATA="$tmp" LIBRARY_EMBED= timeout 30 node bin/library.mjs serve >"$tmp/out" 2>"$tmp/err" || true
|
|
62
|
+
|
|
63
|
+
node --input-type=module -e '
|
|
64
|
+
import fs from "node:fs";
|
|
65
|
+
import yaml from "js-yaml";
|
|
66
|
+
const fail = (m) => { console.error("FAIL: " + m); process.exit(1); };
|
|
67
|
+
const tmpDir = process.argv[2];
|
|
68
|
+
function rolesOf(slug) {
|
|
69
|
+
const raw = fs.readFileSync(`${tmpDir}/entries/${slug}/entry.md`, "utf8");
|
|
70
|
+
const m = raw.match(/^---\n([\s\S]*?)\n---/);
|
|
71
|
+
if (!m) fail(`entry ${slug} has no frontmatter`);
|
|
72
|
+
return yaml.load(m[1]).roles ?? [];
|
|
73
|
+
}
|
|
74
|
+
let res = null;
|
|
75
|
+
for (const line of fs.readFileSync(process.argv[1], "utf8").split("\n")) {
|
|
76
|
+
if (!line.startsWith("{")) continue;
|
|
77
|
+
const d = JSON.parse(line);
|
|
78
|
+
if (d.id === 2) res = d.result?.structuredContent;
|
|
79
|
+
}
|
|
80
|
+
if (!res) fail("no recommend result");
|
|
81
|
+
const vocabRoles = yaml.load(fs.readFileSync("vocab.yaml", "utf8")).role;
|
|
82
|
+
if (!Array.isArray(res.roles)) fail("no roles array");
|
|
83
|
+
if (res.roles.length !== vocabRoles.length) fail(`${res.roles.length} role groups for ${vocabRoles.length} vocabulary roles`);
|
|
84
|
+
let totalHits = 0;
|
|
85
|
+
for (const g of res.roles) {
|
|
86
|
+
if (!vocabRoles.includes(g.role)) fail("unknown role group " + g.role);
|
|
87
|
+
for (const h of g.hits ?? []) {
|
|
88
|
+
totalHits++;
|
|
89
|
+
if ("body" in h) fail("recommend returned a prose body");
|
|
90
|
+
if (!("weak" in h)) fail("hit is not marked weak or not");
|
|
91
|
+
// The whole promise of recommend is that a group only carries hits filed under
|
|
92
|
+
// its own role — assert that against the entry'"'"'s own frontmatter, not just
|
|
93
|
+
// against the shape of the response.
|
|
94
|
+
const entryRoles = rolesOf(h.slug);
|
|
95
|
+
if (!entryRoles.includes(g.role)) fail(`hit ${h.slug} in role group ${g.role} does not carry that role (entry roles: ${JSON.stringify(entryRoles)})`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// A pass with zero hits everywhere would let the weak-key and role-membership
|
|
99
|
+
// assertions above pass vacuously (nothing to iterate). The seeded second fixture
|
|
100
|
+
// exists so this never happens silently.
|
|
101
|
+
if (totalHits === 0) fail("no hits at all — weak-marking and role membership were never exercised");
|
|
102
|
+
if (!Array.isArray(res.arms)) fail("no arms");
|
|
103
|
+
if (!res.arms.includes("fts")) fail("fts arm did not run");
|
|
104
|
+
if (!res.skipped || !res.skipped[0]?.reason) fail("vector arm skipped without a named reason");
|
|
105
|
+
console.log(`roles ${res.roles.length}, arms ${res.arms.join(",")}, skipped ${res.skipped[0].reason}`);
|
|
106
|
+
' "$tmp/out" "$tmp" || { cat "$tmp/err"; exit 1; }
|
|
107
|
+
echo PASS
|
|
@@ -31,11 +31,14 @@ bad=$(grep '"id":9' "$tmp/out")
|
|
|
31
31
|
echo "$bad" | grep -q '"error"' || { echo "FAIL: bad slug did not error"; exit 1; }
|
|
32
32
|
echo "$bad" | grep -qF 'no entry bad.slug' || { echo "FAIL: bad slug error lacks 'no entry bad.slug'"; exit 1; }
|
|
33
33
|
grep '"id":7' "$tmp/out" | grep -q '"name":"brief-from-library"' || { echo "FAIL: prompts/list lacks brief-from-library"; exit 1; }
|
|
34
|
+
grep '"id":7' "$tmp/out" | grep -qF 'per-role reference set' || { echo "FAIL: prompt description does not match its recommend-first body"; exit 1; }
|
|
34
35
|
get=$(grep '"id":8' "$tmp/out")
|
|
35
36
|
echo "$get" | grep -qF 'limit: 3' || { echo "FAIL: prompt text lacks: limit: 3"; exit 1; }
|
|
36
37
|
echo "$get" | grep -qF '## References' || { echo "FAIL: prompt text lacks: ## References"; exit 1; }
|
|
37
38
|
echo "$get" | grep -qF 'ranks.fts' || { echo "FAIL: prompt text lacks the weak-vector-match rule (ranks.fts)"; exit 1; }
|
|
38
39
|
echo "$get" | grep -qF 'having no reference rather than filling it' || { echo "FAIL: prompt text lacks the weak-match-only role guidance"; exit 1; }
|
|
40
|
+
echo "$get" | grep -qF 'Call the `recommend` tool once' || { echo "FAIL: prompt does not reach for recommend"; exit 1; }
|
|
41
|
+
echo "$get" | grep -qF 'fall back to calling' || { echo "FAIL: prompt lost its search fallback"; exit 1; }
|
|
39
42
|
echo "$get" | grep -qi 'hero' || { echo "FAIL: prompt text lacks the word hero"; exit 1; }
|
|
40
43
|
# vocabulary is rendered from vocab.yaml, so every feel term appears
|
|
41
44
|
for t in $(node --input-type=module -e 'import { loadVocab } from "./src/vocab.mjs"; console.log([...loadVocab().facets.feel].join(" "))'); do
|
package/tests/run.sh
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
set -u
|
|
3
3
|
cd "$(dirname "$0")/.."
|
|
4
|
-
pass=0; fail=0
|
|
4
|
+
pass=0; fail=0; skip=0
|
|
5
|
+
# A check that cannot run its subject exits 0 after printing SKIP. Counting those as
|
|
6
|
+
# PASS would let a runner missing every tool report a clean suite, which is how three
|
|
7
|
+
# absent-dependency failures reached the publish workflow unnoticed.
|
|
5
8
|
for f in tests/checks/*.sh; do
|
|
6
|
-
if out=$(bash "$f" 2>&1); then
|
|
9
|
+
if out=$(bash "$f" 2>&1); then
|
|
10
|
+
if printf '%s' "$out" | grep -q '^SKIP'; then
|
|
11
|
+
skip=$((skip+1)); echo "SKIP $f ($(printf '%s' "$out" | grep -m1 '^SKIP' | cut -c7-))"
|
|
12
|
+
else pass=$((pass+1)); echo "PASS $f"; fi
|
|
7
13
|
else fail=$((fail+1)); echo "FAIL $f"; echo "$out" | tail -20; fi
|
|
8
14
|
done
|
|
9
|
-
echo "PASS=$pass FAIL=$fail"
|
|
15
|
+
echo "PASS=$pass SKIP=$skip FAIL=$fail"
|
|
10
16
|
[ "$fail" -eq 0 ]
|