@yojahny/wp-design-library 0.2.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/README.md +22 -4
- package/package.json +20 -7
- package/src/mcp/instructions.mjs +17 -0
- package/src/mcp/prompts.mjs +13 -7
- package/src/mcp/server.mjs +2 -1
- package/src/mcp/tools.mjs +63 -4
- package/tests/README.md +12 -0
- package/tests/checks/entry-detail.sh +52 -0
- package/tests/checks/instructions.sh +31 -0
- package/tests/checks/recommend.sh +107 -0
- package/tests/checks/resources.sh +3 -0
package/README.md
CHANGED
|
@@ -244,11 +244,19 @@ node bin/library.mjs index # rebuild the index from entries/
|
|
|
244
244
|
node bin/library.mjs check # validate every entry, 1 line per problem
|
|
245
245
|
```
|
|
246
246
|
|
|
247
|
-
Over MCP,
|
|
248
|
-
`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
|
|
249
251
|
`arms` (which retrieval arms ran; `["fts"]` in Phase 1); any tool may instead respond
|
|
250
252
|
with `refused` and the reason.
|
|
251
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
|
+
|
|
252
260
|
## Vector search
|
|
253
261
|
|
|
254
262
|
`index` also builds a `vec` arm — two `sqlite-vec` tables (`vec_text` 384-d,
|
|
@@ -370,8 +378,8 @@ root only to `chown` an existing (possibly root-owned) `/data` volume, then drop
|
|
|
370
378
|
## Releasing
|
|
371
379
|
|
|
372
380
|
1. Bump `version` in `package.json`.
|
|
373
|
-
2. Tag the release: `git tag
|
|
374
|
-
3. Push the tag: `git push origin
|
|
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`.
|
|
375
383
|
4. `.github/workflows/publish.yml` runs on that push: checks out, installs with
|
|
376
384
|
`npm ci`, runs `npm test`, then `npm publish`. Trusted publishing means no npm
|
|
377
385
|
token lives in this repo — the registry trusts the GitHub Actions run itself via
|
|
@@ -413,6 +421,16 @@ without breaking this workflow, since it never uses a token.
|
|
|
413
421
|
(models and entries) so an old root-owned volume keeps working with no operator
|
|
414
422
|
step; start latency grows with the corpus. Replace it with an ownership probe if it
|
|
415
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.
|
|
416
434
|
|
|
417
435
|
## Layout
|
|
418
436
|
|
package/package.json
CHANGED
|
@@ -1,13 +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": {
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/yojahny55/wp-design-library.git"
|
|
8
|
+
},
|
|
6
9
|
"homepage": "https://github.com/yojahny55/wp-design-library#readme",
|
|
7
|
-
"bugs": {
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/yojahny55/wp-design-library/issues"
|
|
12
|
+
},
|
|
8
13
|
"type": "module",
|
|
9
|
-
"bin": {
|
|
10
|
-
|
|
14
|
+
"bin": {
|
|
15
|
+
"library": "bin/library.mjs"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22"
|
|
19
|
+
},
|
|
11
20
|
"scripts": {
|
|
12
21
|
"test": "bash tests/run.sh",
|
|
13
22
|
"check": "node bin/library.mjs check",
|
|
@@ -27,8 +36,12 @@
|
|
|
27
36
|
"playwright-core": "1.63.0"
|
|
28
37
|
},
|
|
29
38
|
"peerDependenciesMeta": {
|
|
30
|
-
"@huggingface/transformers": {
|
|
31
|
-
|
|
39
|
+
"@huggingface/transformers": {
|
|
40
|
+
"optional": true
|
|
41
|
+
},
|
|
42
|
+
"playwright-core": {
|
|
43
|
+
"optional": true
|
|
44
|
+
}
|
|
32
45
|
},
|
|
33
46
|
"license": "MIT",
|
|
34
47
|
"private": false,
|
|
@@ -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
|
@@ -6,6 +6,7 @@ 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';
|
|
@@ -15,7 +16,7 @@ export async function buildServer(opts = {}) {
|
|
|
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.
|
|
17
18
|
ctx.embedderReason = ctx.embedder ? null : absentReason(ctx.models);
|
|
18
|
-
const server = new McpServer({ name: 'wp-design-library', version: p.version });
|
|
19
|
+
const server = new McpServer({ name: 'wp-design-library', version: p.version }, { instructions: INSTRUCTIONS });
|
|
19
20
|
registerTools(server, makeTools(ctx));
|
|
20
21
|
registerPrompts(server, ctx);
|
|
21
22
|
registerResources(server, ctx);
|
package/src/mcp/tools.mjs
CHANGED
|
@@ -76,11 +76,28 @@ export function makeTools(ctx) {
|
|
|
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
|
{
|
|
@@ -100,6 +117,48 @@ export function makeTools(ctx) {
|
|
|
100
117
|
return similar(db, { ...a, embedder: ctx.embedder, embedderReason: ctx.embedderReason });
|
|
101
118
|
}),
|
|
102
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 };
|
|
160
|
+
}),
|
|
161
|
+
},
|
|
103
162
|
{
|
|
104
163
|
name: 'add',
|
|
105
164
|
description: 'Ingest a video/animated image, or measure a live https:// URL with the system Chrome: sample frames or measure into strip.png/measure.json and write a draft entry. Returns the blank fields the caller must fill before save_entry.',
|
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
|
|
@@ -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
|
|
@@ -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
|