pattern-mcp 0.1.1 → 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/dist/index.js CHANGED
@@ -2,39 +2,52 @@
2
2
  /**
3
3
  * Pattern
4
4
  *
5
- * MCP server exposing two tools. `recommend_component` judges whether a UI
6
- * component need should be met with an existing shadcn/ui or 21st.dev
7
- * component, or requires a custom build guided by a real-app reference
8
- * from Mobbin. `record_component_decision` appends a confirmed decision to
9
- * local per-project memory (see MEMORY_PATH below), which recommend_component
10
- * can optionally read back (via project_id) as consistency context for a
11
- * future call -- never as a cached verdict; coverage is still scored fresh
12
- * every time.
5
+ * MCP server exposing tools built around one judgment: whether a UI
6
+ * component need should be met with an existing shadcn/ui, 21st.dev, or
7
+ * ReUI (reui.io) component, or requires a custom build guided by a
8
+ * real-app reference from Mobbin.
13
9
  *
14
- * The judgment logic (extract requirements -> search -> score real code ->
15
- * threshold into a verdict) is delegated to a single Anthropic API call
16
- * with the server-side web_search tool enabled, so the same reasoning
10
+ * Two separate local stores back this, with two different rules:
11
+ * - `record_component_decision` appends a confirmed decision to local
12
+ * per-project memory (see MEMORY_PATH below), which recommend_component
13
+ * can optionally read back (via project_id) as consistency context for
14
+ * a future call -- never as a cached verdict; coverage is still scored
15
+ * fresh every time. Unchanged, still true.
16
+ * - Every recommend_component call that reaches the API instead appends
17
+ * to a per-project ledger (see LEDGER_PATH below). Unlike memory.json,
18
+ * a high-confidence ledger entry CAN be served directly on a later,
19
+ * matching call instead of a fresh search+score -- the one deliberate
20
+ * exception to "always fresh," bounded by exact component_need/domain/
21
+ * framework/conventions match and a staleness TTL, and always flagged
22
+ * via `served_from_ledger: true` in the response so nothing is silently
23
+ * passed off as freshly verified. See findLedgerCacheHit.
24
+ *
25
+ * The judgment logic itself (extract requirements -> search -> score real
26
+ * code -> threshold into a verdict) is delegated to a single Anthropic API
27
+ * call with the server-side web_search tool enabled, so the same reasoning
17
28
  * this project validated by hand in conversation is what runs here.
18
29
  */
19
30
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
20
31
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
21
32
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
33
+ import { createHash, randomUUID } from "node:crypto";
22
34
  import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
23
35
  import { homedir } from "node:os";
24
36
  import { dirname, join } from "node:path";
25
- const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
37
+ export const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
26
38
  // Configurable so Sonnet vs. Haiku can be A/B tested without a code change.
27
39
  // Defaults to Sonnet 5. Try MODEL=claude-haiku-4-5-20251001 to test the
28
40
  // cheaper tier -- re-run the 5 validated test cases from the product brief
29
41
  // (price breakdown, cancellation policy, earnings dashboard, gallery,
30
42
  // messaging) and diff verdicts before trusting it in production.
31
- const MODEL = process.env.PATTERN_MODEL ?? "claude-sonnet-5";
32
- // Search budget for candidate discovery. Defaults to 2, matching the
33
- // process the system prompt was originally validated against. Set to
34
- // "unlimited" to remove the cap entirely (enforced server-side via the
35
- // web_search tool's max_uses -- not just prompt instruction, since models
36
- // don't reliably self-limit against a purely textual budget).
37
- const SEARCH_BUDGET_RAW = process.env.PATTERN_SEARCH_BUDGET ?? "2";
43
+ export const MODEL = process.env.PATTERN_MODEL ?? "claude-sonnet-5";
44
+ // Search budget for candidate discovery. Defaults to 3 -- one search per
45
+ // source (shadcn/ui, 21st.dev, ReUI), fired together in the same turn per
46
+ // the system prompt's step 3. Set to "unlimited" to remove the cap
47
+ // entirely (enforced server-side via the web_search tool's max_uses --
48
+ // not just prompt instruction, since models don't reliably self-limit
49
+ // against a purely textual budget).
50
+ const SEARCH_BUDGET_RAW = process.env.PATTERN_SEARCH_BUDGET ?? "3";
38
51
  const SEARCH_BUDGET = SEARCH_BUDGET_RAW.trim().toLowerCase() === "unlimited"
39
52
  ? null
40
53
  : (() => {
@@ -47,7 +60,7 @@ const SEARCH_BUDGET = SEARCH_BUDGET_RAW.trim().toLowerCase() === "unlimited"
47
60
  // Static skip-list: single-purpose primitives with no meaningful internal
48
61
  // structure to score coverage against. Decided in the product brief as a
49
62
  // starting point -- revisit once real usage data exists (see README).
50
- const SKIP_LIST = [
63
+ export const SKIP_LIST = [
51
64
  "button",
52
65
  "input",
53
66
  "checkbox",
@@ -59,7 +72,7 @@ const SKIP_LIST = [
59
72
  "avatar",
60
73
  "icon",
61
74
  ];
62
- function isSkipListMatch(componentNeed) {
75
+ export function isSkipListMatch(componentNeed) {
63
76
  const needLower = componentNeed.toLowerCase().trim();
64
77
  return SKIP_LIST.some((item) => needLower === item || needLower === `a ${item}` || needLower === `an ${item}`);
65
78
  }
@@ -99,8 +112,269 @@ const LOG_PATH = process.env.PATTERN_LOG_PATH ?? join(homedir(), ".pattern", "ca
99
112
  // of what's in this file (see README's "no verdict caching" rule).
100
113
  const MEMORY_PATH = process.env.PATTERN_MEMORY_PATH ?? join(homedir(), ".pattern", "memory.json");
101
114
  const MAX_DECISIONS_PER_PROJECT = 50;
115
+ // Per-project judgment ledger -- distinct from both LOG_PATH and
116
+ // MEMORY_PATH above. Every recommend_component call that reaches the API
117
+ // with a project_id and lands on reason "scored" or "no_candidates_found"
118
+ // appends one line here (see appendLedgerEntry), unlike MEMORY_PATH which
119
+ // only gains an entry when record_component_decision is explicitly called.
120
+ // Unlike MEMORY_PATH, this file's entries CAN produce a cached verdict on a
121
+ // later call (see findLedgerCacheHit) -- the one deliberate exception to
122
+ // this project's "coverage is scored fresh every time" rule, bounded by
123
+ // exact component_need/domain/framework/conventions match, confidence
124
+ // "high", and LEDGER_TTL_DAYS staleness, and always flagged in the
125
+ // response via served_from_ledger so nothing is silently passed off as
126
+ // fresh. Same homedir/project_id-keyed convention as LOG_PATH/MEMORY_PATH,
127
+ // not a repo-root file -- this server has no concept of "which repo" a
128
+ // call is about, only the caller-supplied project_id string.
129
+ const LEDGER_PATH = process.env.PATTERN_LEDGER_PATH ?? join(homedir(), ".pattern", "ledger.jsonl");
130
+ const LEDGER_TTL_DAYS = Number(process.env.PATTERN_LEDGER_TTL_DAYS ?? 30);
131
+ // Kill switch for the cache-hit short-circuit specifically -- does NOT
132
+ // disable the ledger itself. Entries still get written and read_ledger
133
+ // still works either way; this only controls whether judgeComponent is
134
+ // allowed to skip a fresh search+score on a matching entry. Set
135
+ // PATTERN_NO_LEDGER_CACHE_HIT (any truthy value) to revert to "every
136
+ // recommend_component call always scores fresh" without removing any
137
+ // ledger code -- flip it back off (unset the var) to re-enable.
138
+ const LEDGER_CACHE_HIT_ENABLED = !process.env.PATTERN_NO_LEDGER_CACHE_HIT;
139
+ // $/1M tokens, checked against the Anthropic pricing page rather than
140
+ // recalled from training data (rates drift). Both current and legacy
141
+ // Haiku 4.5 model-id spellings are listed since PATTERN_MODEL is
142
+ // user-configurable and either form may be in use. Falls back to Sonnet 5
143
+ // rates (with a diagnostic) for any model not listed here -- an estimate
144
+ // clearly logged as such beats silently returning $0.
145
+ const PRICING = {
146
+ "claude-sonnet-5": { inputPerMTok: 2.0, outputPerMTok: 10.0 },
147
+ "claude-opus-5": { inputPerMTok: 5.0, outputPerMTok: 25.0 },
148
+ "claude-haiku-4-5": { inputPerMTok: 1.0, outputPerMTok: 5.0 },
149
+ "claude-haiku-4-5-20251001": { inputPerMTok: 1.0, outputPerMTok: 5.0 },
150
+ };
151
+ // Anthropic's standard prompt-caching multipliers, applied on top of a
152
+ // model's base input rate -- cache writes cost ~1.25x, cache reads ~0.1x.
153
+ // These ratios are documented as consistent across models, unlike the
154
+ // base per-model rates above.
155
+ const CACHE_WRITE_MULTIPLIER = 1.25;
156
+ const CACHE_READ_MULTIPLIER = 0.1;
157
+ // Estimate only -- see PRICING's comment above. Rounded to 4 decimal
158
+ // places since a single call is well under a cent in many cases.
159
+ export function estimateCostUsd(usage, model) {
160
+ const pricing = PRICING[model];
161
+ if (!pricing) {
162
+ console.error(JSON.stringify({
163
+ diagnostic: "pricing_fallback",
164
+ reason: `no pricing entry for model "${model}" -- estimated_cost_usd uses Sonnet 5 rates as a stand-in`,
165
+ model,
166
+ }));
167
+ }
168
+ const { inputPerMTok, outputPerMTok } = pricing ?? PRICING["claude-sonnet-5"];
169
+ const input = usage.input_tokens ?? 0;
170
+ const output = usage.output_tokens ?? 0;
171
+ const cacheWrite = usage.cache_creation_input_tokens ?? 0;
172
+ const cacheRead = usage.cache_read_input_tokens ?? 0;
173
+ const cost = (input * inputPerMTok +
174
+ output * outputPerMTok +
175
+ cacheWrite * inputPerMTok * CACHE_WRITE_MULTIPLIER +
176
+ cacheRead * inputPerMTok * CACHE_READ_MULTIPLIER) /
177
+ 1_000_000;
178
+ return Math.round(cost * 10000) / 10000;
179
+ }
180
+ // This bundled call runs extraction, search, and scoring inside ONE model
181
+ // turn via server-executed tools (web_search/web_fetch run on Anthropic's
182
+ // servers, not as separate round-trips this code makes) -- so there's no
183
+ // natural place to put three separate stopwatches. Streaming the response
184
+ // and timing content-block boundaries is the only way to get a real
185
+ // per-phase split without adding a second API call (which would change
186
+ // cost/behavior -- out of scope here).
187
+ //
188
+ // Validated against 8 real streamed traces before shipping (4 custom_build,
189
+ // 4 use_existing, covering both branches of step 6) rather than assumed:
190
+ // every trace showed the same shape --
191
+ // [thinking] -> [search tool_use x2 -> search tool_result x2] -> [thinking/text...]
192
+ // with the first tool_use block starting at the exact millisecond the
193
+ // opening `thinking` block stopped (0-16ms of jitter across all 8 runs),
194
+ // and the discovery-search wave (always exactly the 2 calls step 3 asks
195
+ // the model to fire together) always followed immediately by a `thinking`
196
+ // or `text` block -- never by a third tool call with no reasoning in
197
+ // between. That gives two clean, consistently-observed cut points:
198
+ // first-tool-block-start (end of extract) and end-of-the-first-contiguous
199
+ // tool-block-run (end of search).
200
+ //
201
+ // For custom_build cases specifically, step 6's reference search
202
+ // (Mobbin/Figma) and its web_fetch deep-link check happen in a SECOND
203
+ // tool-block run, separated from the first by a `thinking` block that
204
+ // contains the actual coverage-scoring/verdict reasoning -- i.e. search
205
+ // and score are not simply sequential there, scoring happens in the
206
+ // middle. Using "last tool result in the whole response" as the search/
207
+ // score boundary (an earlier draft of this) would have wrongly folded that
208
+ // interstitial scoring reasoning, plus all of step 6, into "search". The
209
+ // boundary below avoids that: "search" is only ever the first contiguous
210
+ // tool-block run. Concretely this means breakdown_ms.score, for a
211
+ // custom_build verdict, also covers step 6's reference-finding and
212
+ // write-up -- not just coverage scoring -- which is disclosed in the
213
+ // README rather than presented as a narrower number than it is.
214
+ function classifyBlockKind(type) {
215
+ return type === "tool_use" ||
216
+ type === "server_tool_use" ||
217
+ type === "web_search_tool_result" ||
218
+ type === "web_fetch_tool_result"
219
+ ? "tool"
220
+ : "other";
221
+ }
222
+ export function computeBreakdownMs(t) {
223
+ return {
224
+ extract: t.extractEndMs - t.requestStartMs,
225
+ search: t.searchEndMs - t.extractEndMs,
226
+ score: t.scoreEndMs - t.searchEndMs,
227
+ };
228
+ }
229
+ function buildMeta(timings, usage) {
230
+ return {
231
+ total_ms: timings.scoreEndMs - timings.requestStartMs,
232
+ breakdown_ms: computeBreakdownMs(timings),
233
+ tokens_used: {
234
+ input: (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0),
235
+ output: usage.output_tokens ?? 0,
236
+ },
237
+ estimated_cost_usd: estimateCostUsd(usage, MODEL),
238
+ };
239
+ }
240
+ // Streams a Messages API request over SSE and reconstructs the same
241
+ // {content, stop_reason, usage} shape the non-streaming endpoint returns,
242
+ // so every downstream consumer (search/fetch-call parsing, JSON
243
+ // extraction, the enforce* functions) is unaffected by this transport
244
+ // change. Also captures the phase timestamps described above. This is
245
+ // hand-rolled SSE parsing rather than the Anthropic SDK to avoid pulling
246
+ // in a new dependency for what's a small, stable, well-documented event
247
+ // shape (message_start/content_block_start/_delta/_stop/message_delta/
248
+ // message_stop).
249
+ async function streamAnthropicMessage(body) {
250
+ const requestStartMs = Date.now();
251
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
252
+ method: "POST",
253
+ headers: {
254
+ "content-type": "application/json",
255
+ "x-api-key": ANTHROPIC_API_KEY,
256
+ "anthropic-version": "2023-06-01",
257
+ },
258
+ body: JSON.stringify({ ...body, stream: true }),
259
+ });
260
+ if (!response.ok) {
261
+ const errText = await response.text();
262
+ throw new Error(`Anthropic API error ${response.status}: ${errText}`);
263
+ }
264
+ if (!response.body) {
265
+ throw new Error("Anthropic API streaming response had no body to read.");
266
+ }
267
+ const blocks = [];
268
+ const partialJson = {};
269
+ let usage = {};
270
+ let stop_reason;
271
+ let firstToolBlockStartMs;
272
+ let lastToolResultStopMs;
273
+ let searchEndMs; // frozen the first time a non-tool block interrupts the run
274
+ let sawAnyToolBlock = false;
275
+ const handleEvent = (payload) => {
276
+ const now = Date.now();
277
+ switch (payload.type) {
278
+ case "message_start":
279
+ usage = { ...usage, ...payload.message?.usage };
280
+ break;
281
+ case "content_block_start": {
282
+ const idx = payload.index;
283
+ blocks[idx] = structuredClone(payload.content_block);
284
+ const kind = classifyBlockKind(blocks[idx].type);
285
+ if (kind === "tool") {
286
+ sawAnyToolBlock = true;
287
+ if (firstToolBlockStartMs === undefined)
288
+ firstToolBlockStartMs = now;
289
+ }
290
+ else if (sawAnyToolBlock && searchEndMs === undefined && lastToolResultStopMs !== undefined) {
291
+ // A thinking/text block has interrupted the first tool-block run --
292
+ // freeze the search/score boundary at the last tool result seen so far.
293
+ searchEndMs = lastToolResultStopMs;
294
+ }
295
+ break;
296
+ }
297
+ case "content_block_delta": {
298
+ const idx = payload.index;
299
+ const delta = payload.delta;
300
+ if (delta?.type === "text_delta") {
301
+ blocks[idx].text = (blocks[idx].text ?? "") + delta.text;
302
+ }
303
+ else if (delta?.type === "input_json_delta") {
304
+ partialJson[idx] = (partialJson[idx] ?? "") + delta.partial_json;
305
+ }
306
+ break;
307
+ }
308
+ case "content_block_stop": {
309
+ const idx = payload.index;
310
+ if (partialJson[idx] !== undefined) {
311
+ try {
312
+ blocks[idx].input = JSON.parse(partialJson[idx] || "{}");
313
+ }
314
+ catch {
315
+ blocks[idx].input = {};
316
+ }
317
+ }
318
+ if (blocks[idx]?.type === "web_search_tool_result" || blocks[idx]?.type === "web_fetch_tool_result") {
319
+ lastToolResultStopMs = now;
320
+ }
321
+ break;
322
+ }
323
+ case "message_delta":
324
+ if (payload.usage)
325
+ usage = { ...usage, ...payload.usage };
326
+ if (payload.delta?.stop_reason)
327
+ stop_reason = payload.delta.stop_reason;
328
+ break;
329
+ default:
330
+ break;
331
+ }
332
+ };
333
+ const reader = response.body.getReader();
334
+ const decoder = new TextDecoder();
335
+ let buf = "";
336
+ let dataLines = [];
337
+ while (true) {
338
+ const { done, value } = await reader.read();
339
+ if (done)
340
+ break;
341
+ buf += decoder.decode(value, { stream: true });
342
+ let idx;
343
+ while ((idx = buf.indexOf("\n")) !== -1) {
344
+ const line = buf.slice(0, idx).replace(/\r$/, "");
345
+ buf = buf.slice(idx + 1);
346
+ if (line === "") {
347
+ if (dataLines.length > 0) {
348
+ try {
349
+ handleEvent(JSON.parse(dataLines.join("\n")));
350
+ }
351
+ catch {
352
+ // Malformed/partial SSE frame -- skip it rather than crash the call.
353
+ }
354
+ }
355
+ dataLines = [];
356
+ continue;
357
+ }
358
+ if (line.startsWith("data:"))
359
+ dataLines.push(line.slice(5).trim());
360
+ // "event:" lines are ignored -- payload.type inside `data:` is
361
+ // sufficient to dispatch on, and is what the code above already uses.
362
+ }
363
+ }
364
+ const scoreEndMs = Date.now();
365
+ const extractEndMs = firstToolBlockStartMs ?? scoreEndMs;
366
+ const resolvedSearchEndMs = searchEndMs ?? lastToolResultStopMs ?? extractEndMs;
367
+ return {
368
+ content: blocks,
369
+ stop_reason,
370
+ usage,
371
+ timings: { requestStartMs, extractEndMs, searchEndMs: resolvedSearchEndMs, scoreEndMs },
372
+ };
373
+ }
102
374
  const TOOL_NAME = "recommend_component";
103
375
  const RECORD_DECISION_TOOL_NAME = "record_component_decision";
376
+ const EXTRACT_REQUIREMENTS_TOOL_NAME = "extract_requirements";
377
+ const READ_LEDGER_TOOL_NAME = "read_ledger";
104
378
  const INPUT_SCHEMA = {
105
379
  type: "object",
106
380
  properties: {
@@ -130,12 +404,43 @@ const INPUT_SCHEMA = {
130
404
  "belongs to. When provided, past decisions confirmed via " +
131
405
  "record_component_decision for this same project_id are surfaced to " +
132
406
  "the model as a consistency signal (never a rule -- a genuinely " +
133
- "better match found in this search still wins). Omit to skip memory " +
134
- "lookup entirely; this never falls back to a shared/global bucket.",
407
+ "better match found in this search still wins). Separately, this call " +
408
+ "may also be served directly from a recent, high-confidence prior " +
409
+ "recommend_component judgment for this same project_id/component_need/" +
410
+ "domain/framework/existing_stack, skipping search+score entirely -- " +
411
+ "check the response for served_from_ledger: true, which is always set " +
412
+ "when this happens; see read_ledger to inspect what's stored. Omit " +
413
+ "project_id to skip both lookups entirely; neither ever falls back to " +
414
+ "a shared/global bucket.",
415
+ },
416
+ checklist: {
417
+ type: "array",
418
+ items: { type: "string" },
419
+ description: "Optional. A hand-provided (or extract_requirements-provided) requirement " +
420
+ "checklist to score against directly, skipping this call's own internal " +
421
+ "requirement extraction. Use this to inspect or correct the checklist " +
422
+ "before spending the search+score budget -- call extract_requirements " +
423
+ "first, review or edit its checklist, then pass it here. Omit to keep " +
424
+ "today's default behavior: recommend_component extracts its own " +
425
+ "checklist internally, unchanged.",
135
426
  },
136
427
  },
137
428
  required: ["component_need", "domain", "framework"],
138
429
  };
430
+ const EXTRACT_REQUIREMENTS_INPUT_SCHEMA = {
431
+ type: "object",
432
+ properties: {
433
+ component_need: {
434
+ type: "string",
435
+ description: "Same field as recommend_component's input -- a specific description of the UI component needed, not a category.",
436
+ },
437
+ domain: {
438
+ type: "string",
439
+ description: "Same field as recommend_component's input -- the product type/domain. Extraction is grounded in this, not the component name alone.",
440
+ },
441
+ },
442
+ required: ["component_need", "domain"],
443
+ };
139
444
  const RECORD_DECISION_INPUT_SCHEMA = {
140
445
  type: "object",
141
446
  properties: {
@@ -160,20 +465,60 @@ const RECORD_DECISION_INPUT_SCHEMA = {
160
465
  },
161
466
  source: {
162
467
  type: "string",
163
- description: "Where it came from, e.g. 'shadcn', '21st.dev', or 'custom' for a custom build.",
468
+ description: "Where it came from, e.g. 'shadcn', '21st.dev', 'reui', or 'custom' for a custom build.",
164
469
  },
165
470
  timestamp: {
166
471
  type: "string",
167
472
  description: "Optional. ISO 8601 timestamp of the decision. Defaults to the current time if omitted.",
168
473
  },
474
+ time_saved_minutes: {
475
+ type: "number",
476
+ description: "Optional. Your own estimate, in minutes, of the time this decision saved you by having " +
477
+ "Pattern's verdict instead of researching candidates and judging fit yourself from scratch. " +
478
+ "This is self-reported by the calling agent -- Pattern has no way to measure a counterfactual, " +
479
+ "so it never computes this itself (unlike _meta, which is Pattern's own real cost/latency). " +
480
+ "Omit if you don't have a meaningful estimate; never guess a number just to fill the field.",
481
+ },
169
482
  },
170
483
  required: ["project_id", "component_need", "action", "source"],
171
484
  };
172
- function buildSystemPrompt(searchBudget) {
485
+ const READ_LEDGER_INPUT_SCHEMA = {
486
+ type: "object",
487
+ properties: {
488
+ project_id: {
489
+ type: "string",
490
+ description: "The project_id used in prior recommend_component calls whose ledger entries you want to inspect.",
491
+ },
492
+ component_need: {
493
+ type: "string",
494
+ description: "Optional. Filters entries by simple keyword match against their component_need. Omit to list all entries for the project.",
495
+ },
496
+ limit: {
497
+ type: "number",
498
+ description: "Optional. Maximum number of entries to return, most recent first. Defaults to 20.",
499
+ },
500
+ },
501
+ required: ["project_id"],
502
+ };
503
+ // Shared between buildSystemPrompt's own step 2 and
504
+ // buildExtractionSystemPrompt (the extract_requirements tool's standalone
505
+ // prompt) -- the extraction *instructions* are one piece of text reused
506
+ // by both, even though the two tools issue physically separate API calls
507
+ // (recommend_component's step 2 runs inside the same server-tool-use
508
+ // turn as search+score; extract_requirements is a standalone call with no
509
+ // tools at all). This is what "factor it out into a shared function" means
510
+ // here: the wording, not a shared HTTP call.
511
+ const EXTRACTION_INSTRUCTIONS = "Turn the component need + domain into a concrete checklist of elements the component must contain -- specific enough to check against real code, not a vibe. Ground it in the stated domain, not the component name alone. Extract exactly 8 checklist items, ranked by importance to the component's core function (most important first) -- a fixed count, not a range, so coverage = met/total isn't itself a moving target across runs.";
512
+ function buildSystemPrompt(searchBudget, opts) {
173
513
  const budgetLine = searchBudget === null
174
514
  ? "Budget: no fixed limit on search calls for candidate discovery -- search as much as genuinely helps you find and verify real candidates, but don't search redundantly once you have enough to score confidently."
175
515
  : `Budget: at most ${searchBudget} search call${searchBudget === 1 ? "" : "s"} for candidate discovery. This is separate from, and does not include, the Mobbin and Figma Community lookups in step 6 -- two extra search calls (one per source) are reserved for those and will not work if you spend them here.`;
176
- return `You are a UI component judgment layer. Given a component need, you decide whether it should be met with an existing shadcn/ui or 21st.dev component, or requires a custom build guided by a real-app reference. You have access to a web_search tool -- use it.
516
+ const step2 = opts?.checklistProvided
517
+ ? `2. USE THE PROVIDED CHECKLIST
518
+ The user message includes a "Provided checklist" section -- a requirement checklist already prepared for you (either hand-written by the calling agent, or produced by a prior extract_requirements call). Do not extract your own checklist, and do not add, remove, reorder, or reword any item. Treat it as fixed input and score coverage against exactly these items in step 4 below.`
519
+ : `2. EXTRACT REQUIREMENTS
520
+ ${EXTRACTION_INSTRUCTIONS}`;
521
+ return `You are a UI component judgment layer. Given a component need, you decide whether it should be met with an existing shadcn/ui, 21st.dev, or ReUI (reui.io) component, or requires a custom build guided by a real-app reference. You have access to a web_search tool -- use it.
177
522
 
178
523
  If the user message includes a "Past confirmed decisions in this project" section, treat it only as a signal, not a rule: if a highly similar past decision exists, consider consistency with it while scoring and recommending, but don't let it override a genuinely better match found in this search, and don't skip or shortcut your own search and scoring because a past decision exists. You decide relevance yourself -- nothing upstream has already matched these past decisions to the current need for you. Step 8 below tells you exactly how to report what you did with it.
179
524
 
@@ -182,16 +527,17 @@ Follow this process exactly:
182
527
  1. SKIP-LIST CHECK
183
528
  If the component need is a trivial, single-purpose primitive with no meaningful internal structure (button, input, checkbox, label, badge, spinner, loader, tooltip, avatar, icon), skip the rest of this process and return verdict "use_existing" with reason "skip_list", confidence "high", and a note that this is a commodity primitive not worth scoring.
184
529
 
185
- 2. EXTRACT REQUIREMENTS
186
- Turn the component need + domain into a concrete checklist of elements the component must contain -- specific enough to check against real code, not a vibe. Ground it in the stated domain, not the component name alone. Extract exactly 8 checklist items, ranked by importance to the component's core function (most important first) -- a fixed count, not a range, so coverage = met/total isn't itself a moving target across runs.
530
+ ${step2}
187
531
 
188
532
  3. SEARCH FOR CANDIDATES
189
- Search shadcn/ui and 21st.dev for components matching the need, filtered to the stated framework. Fire the shadcn and 21st.dev searches together in the same turn (they're independent lookups) rather than one at a time -- this avoids re-sending the growing conversation on extra round-trips. ${budgetLine} If those don't surface enough to score, proceed with what you have rather than continuing to search -- a "low confidence, here's why" verdict is more useful than an unbounded search loop.
533
+ Search shadcn/ui, 21st.dev, and ReUI (reui.io) for components matching the need, filtered to the stated framework. Fire the shadcn, 21st.dev, and ReUI searches together in the same turn (they're independent lookups) rather than one at a time -- this avoids re-sending the growing conversation on extra round-trips. ${budgetLine} If those don't surface enough to score, proceed with what you have rather than continuing to search -- a "low confidence, here's why" verdict is more useful than an unbounded search loop.
190
534
 
191
535
  If search returns zero real candidates -- not just weak matches, but nothing relevant at all (e.g. only vendor policy pages, unrelated components) -- stop here and return verdict "custom_build" with reason "no_candidates_found". Do not fabricate a coverage score in this case; omit requirements_checked and coverage entirely.
192
536
 
193
537
  4. SCORE COVERAGE AGAINST THE CHECKLIST
194
- For each real candidate, evaluate against the checklist using actual evidence you can find about the component's real props/structure/code -- not just its marketing description, since descriptions can claim functionality the component doesn't actually have. Mark each requirement met or not-met with a one-line reason. Compute coverage = (requirements met) / (total requirements) for the best-fitting candidate. Base this only on your web_search results from step 3 -- do not use the web_fetch tool here or anywhere in steps 2-5; it is reserved entirely for step 6's reference deep-link check below, and using it earlier can starve that reserved budget.
538
+ For each real candidate, evaluate against the checklist using actual evidence you can find about the component's real props/structure/code -- not just its marketing description, since descriptions can claim functionality the component doesn't actually have. Mark each requirement met or not-met with a one-line reason. Compute coverage = (requirements met) / (total requirements) for the best-fitting candidate.
539
+
540
+ Before finalizing that coverage score, fetch the best-fitting candidate's own real docs/source page ONCE with the web_fetch tool -- a reserved slot exists for exactly this, separate from step 6's reference-verification budget below, so using it here will not starve that reserved budget. Re-check every requirement against what that fetched page actually says, not just the web_search snippet/description you started with -- a search result can describe functionality a component doesn't actually have, or omit a real prop/feature it does have, and only the fetched page is real evidence either way. Only fetch a URL that a real search result in step 3 actually returned -- never construct or guess one. If the fetch fails, or there's no confirmed URL to fetch, score from the web_search evidence alone and say so in the affected items' evidence text. This one candidate-verification fetch is the only exception to "no web_fetch in steps 2-5" -- it remains reserved for step 6's reference deep-link check otherwise.
195
541
 
196
542
  5. APPLY VERDICT THRESHOLDS
197
543
  coverage >= 80% -> verdict "use_existing", confidence "high"
@@ -244,14 +590,48 @@ Respond with ONLY a single JSON object, no prose before or after, no markdown co
244
590
  "past_decision_signal": { "considered": true|false, "note": "string" } | omit this field entirely if step 8 doesn't apply
245
591
  }`;
246
592
  }
593
+ // Standalone prompt for the extract_requirements tool -- shares
594
+ // EXTRACTION_INSTRUCTIONS with buildSystemPrompt's own step 2 (see that
595
+ // constant's comment) but is otherwise a much smaller prompt: no tools, no
596
+ // search/score steps, just the extraction reasoning. This is what makes
597
+ // extract_requirements fast and cheap relative to recommend_component.
598
+ function buildExtractionSystemPrompt() {
599
+ return `You are the requirement-extraction step of a UI component judgment tool. Given a component need and a product domain, produce a checklist of concrete elements the component must contain.
600
+
601
+ ${EXTRACTION_INSTRUCTIONS}
602
+
603
+ Respond with ONLY a single JSON object, no prose before or after, no markdown code fences, matching this exact shape:
604
+
605
+ {
606
+ "checklist": ["string", "string", "..."]
607
+ }`;
608
+ }
609
+ // Placeholder heuristic, not a validated confidence signal -- see the
610
+ // extract_requirements section of README.md for why (a known gap to
611
+ // revisit with real usage data, not fabricated precision). A longer,
612
+ // more specific component_need gives the extraction step more to ground
613
+ // the checklist in; a one- or two-word need is exactly the "too vague"
614
+ // case the README already warns produces misleading matches elsewhere in
615
+ // this tool, so it's flagged "low" here too.
616
+ export function estimateExtractionConfidence(componentNeed) {
617
+ const wordCount = componentNeed.trim().split(/\s+/).filter(Boolean).length;
618
+ if (wordCount <= 2)
619
+ return "low";
620
+ if (wordCount <= 5)
621
+ return "medium";
622
+ return "high";
623
+ }
247
624
  async function runSinglePass(input) {
248
625
  if (!ANTHROPIC_API_KEY) {
249
626
  throw new Error("ANTHROPIC_API_KEY is not set. Export it in the environment running this MCP server.");
250
627
  }
628
+ const passStartMs = Date.now();
629
+ const checklistSource = input.checklist && input.checklist.length > 0 ? "provided" : "extracted";
251
630
  // Fast path: skip-list check happens locally too, so trivial primitives
252
631
  // never spend a real API call. The system prompt also enforces this, but
253
632
  // checking here avoids the round-trip entirely for the common case.
254
633
  if (isSkipListMatch(input.component_need)) {
634
+ const skipListElapsedMs = Math.max(1, Date.now() - passStartMs);
255
635
  return {
256
636
  ok: true,
257
637
  result: {
@@ -262,19 +642,34 @@ async function runSinglePass(input) {
262
642
  requirements_checked: null,
263
643
  coverage: null,
264
644
  recommendation: {
265
- source: "shadcn/ui or 21st.dev (commodity primitive)",
645
+ source: "shadcn/ui, 21st.dev, or ReUI (commodity primitive)",
266
646
  install_command: null,
267
647
  component_description: null,
268
648
  reference: null,
269
649
  },
650
+ checklist_source: checklistSource,
651
+ // No API call happens on this path -- tokens/cost are genuinely
652
+ // zero, not omitted. total_ms is clamped to at least 1 so the
653
+ // field is never zero even though this branch is sub-millisecond;
654
+ // all of that trivial time is attributed to "extract" since it's
655
+ // the local skip-list check, not a search or scoring step.
656
+ _meta: {
657
+ total_ms: skipListElapsedMs,
658
+ breakdown_ms: { extract: skipListElapsedMs, search: 0, score: 0 },
659
+ tokens_used: { input: 0, output: 0 },
660
+ estimated_cost_usd: 0,
661
+ },
270
662
  },
271
663
  };
272
664
  }
273
665
  // Coverage still computes fresh below regardless of what this finds --
274
- // memory only ever adds context to the user message, it never short-
275
- // circuits search/scoring or gets treated as a cached verdict. No
276
- // project_id -> no lookup at all, not a shared/global fallback (see
277
- // getPastDecisions).
666
+ // memory (MEMORY_PATH/record_component_decision) only ever adds context
667
+ // to the user message, it never short-circuits search/scoring or gets
668
+ // treated as a cached verdict. No project_id -> no lookup at all, not a
669
+ // shared/global fallback (see getPastDecisions). This is distinct from
670
+ // the ledger cache-hit check in judgeComponent, which CAN skip this
671
+ // entire function on a matching high-confidence entry -- that check
672
+ // happens one level up, before runSinglePass is ever called.
278
673
  const pastDecisions = input.project_id ? getPastDecisions(input.project_id) : [];
279
674
  const pastDecisionsBlock = pastDecisions.length === 0
280
675
  ? ""
@@ -285,10 +680,15 @@ async function runSinglePass(input) {
285
680
  return `- ${verb} for "${d.component_need}"${domainPart}, source: ${d.source}, confirmed ${d.timestamp}`;
286
681
  })
287
682
  .join("\n")}`;
683
+ const checklistBlock = input.checklist && input.checklist.length > 0
684
+ ? `\n\nProvided checklist (use exactly these items, do not re-extract):\n${input.checklist
685
+ .map((item, i) => `${i + 1}. ${item}`)
686
+ .join("\n")}`
687
+ : "";
288
688
  const userMessage = `component_need: ${input.component_need}
289
689
  domain: ${input.domain}
290
690
  framework: ${input.framework}
291
- existing_stack: ${input.existing_stack ?? "(not specified)"}${pastDecisionsBlock}`;
691
+ existing_stack: ${input.existing_stack ?? "(not specified)"}${checklistBlock}${pastDecisionsBlock}`;
292
692
  // Diagnostic only, same pattern as the other stderr diagnostics in this
293
693
  // file -- proves the memory lookup actually reached the prompt sent to
294
694
  // the model, not just that it was read from disk successfully.
@@ -300,75 +700,69 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${pastDecisionsBlock
300
700
  included_in_prompt: pastDecisionsBlock || null,
301
701
  }));
302
702
  }
303
- const response = await fetch("https://api.anthropic.com/v1/messages", {
304
- method: "POST",
305
- headers: {
306
- "content-type": "application/json",
307
- "x-api-key": ANTHROPIC_API_KEY,
308
- "anthropic-version": "2023-06-01",
309
- },
310
- body: JSON.stringify({
311
- model: MODEL,
312
- // Raised from 4096: higher search budgets produce more candidates
313
- // and more per-requirement evidence text, and 4096 was observed
314
- // truncating mid-response (stop_reason "max_tokens"), which corrupts
315
- // the JSON extractJson() pulls out below.
316
- max_tokens: 8192,
317
- // System prompt is identical on every call, so mark it cacheable --
318
- // cache reads cost roughly a tenth of fresh input tokens. This is
319
- // the single biggest cost lever here: the same ~800-token prompt is
320
- // otherwise re-sent in full on every turn of the search loop, and on
321
- // every separate tool call besides.
322
- system: [
323
- {
324
- type: "text",
325
- text: buildSystemPrompt(SEARCH_BUDGET),
326
- cache_control: { type: "ephemeral" },
327
- },
328
- ],
329
- messages: [{ role: "user", content: userMessage }],
330
- tools: [
331
- {
332
- type: "web_search_20250305",
333
- name: "web_search",
334
- // Server-enforced cap, not just prompt instruction -- omitted
335
- // entirely when SEARCH_BUDGET is null (unlimited). +2 reserves
336
- // one slot each for the step-6 Mobbin and Figma Community
337
- // lookups so neither has to compete with discovery for the same
338
- // budget: without a reservation like this, discovery searches
339
- // (fired first) consumed the whole cap and the Mobbin search was
340
- // silently blocked (max_uses_exceeded) every time a custom_build
341
- // verdict was reached, and the model backfilled a plausible-
342
- // looking but ungrounded reference URL instead of reporting that
343
- // it never actually searched -- confirmed via a direct rerun
344
- // where 0 Mobbin queries were attempted but a specific Mobbin
345
- // URL was still returned. Figma Community gets the same
346
- // treatment now that it's a second reference source.
347
- ...(SEARCH_BUDGET !== null ? { max_uses: SEARCH_BUDGET + 2 } : {}),
348
- },
349
- {
350
- type: "web_fetch_20250910",
351
- name: "web_fetch",
352
- // Exactly one fetch per reference source (Mobbin, Figma
353
- // Community) -- step 6 fetches the search result page to look
354
- // for a deep link to the specific screen/flow already
355
- // identified, never more than once per source. Not reserved
356
- // from the web_search budget above; this is a separate tool
357
- // with its own separate cap.
358
- max_uses: 2,
359
- // Category/browse pages can be large, and all we need from them
360
- // is a permalink, not the full page -- caps token cost of a
361
- // fetch that turns out not to have a deep link after all.
362
- max_content_tokens: 15000,
363
- },
364
- ],
365
- }),
703
+ const data = await streamAnthropicMessage({
704
+ model: MODEL,
705
+ // Raised from 4096: higher search budgets produce more candidates
706
+ // and more per-requirement evidence text, and 4096 was observed
707
+ // truncating mid-response (stop_reason "max_tokens"), which corrupts
708
+ // the JSON extractJson() pulls out below.
709
+ max_tokens: 8192,
710
+ // System prompt is identical on every call, so mark it cacheable --
711
+ // cache reads cost roughly a tenth of fresh input tokens. This is
712
+ // the single biggest cost lever here: the same ~800-token prompt is
713
+ // otherwise re-sent in full on every turn of the search loop, and on
714
+ // every separate tool call besides.
715
+ system: [
716
+ {
717
+ type: "text",
718
+ text: buildSystemPrompt(SEARCH_BUDGET, { checklistProvided: checklistSource === "provided" }),
719
+ cache_control: { type: "ephemeral" },
720
+ },
721
+ ],
722
+ messages: [{ role: "user", content: userMessage }],
723
+ tools: [
724
+ {
725
+ type: "web_search_20250305",
726
+ name: "web_search",
727
+ // Server-enforced cap, not just prompt instruction -- omitted
728
+ // entirely when SEARCH_BUDGET is null (unlimited). +2 reserves
729
+ // one slot each for the step-6 Mobbin and Figma Community
730
+ // lookups so neither has to compete with discovery for the same
731
+ // budget: without a reservation like this, discovery searches
732
+ // (fired first) consumed the whole cap and the Mobbin search was
733
+ // silently blocked (max_uses_exceeded) every time a custom_build
734
+ // verdict was reached, and the model backfilled a plausible-
735
+ // looking but ungrounded reference URL instead of reporting that
736
+ // it never actually searched -- confirmed via a direct rerun
737
+ // where 0 Mobbin queries were attempted but a specific Mobbin
738
+ // URL was still returned. Figma Community gets the same
739
+ // treatment now that it's a second reference source.
740
+ ...(SEARCH_BUDGET !== null ? { max_uses: SEARCH_BUDGET + 2 } : {}),
741
+ },
742
+ {
743
+ type: "web_fetch_20250910",
744
+ name: "web_fetch",
745
+ // 3 reserved slots, same "reserve, don't let an earlier step
746
+ // starve a later one's budget" pattern as web_search's
747
+ // SEARCH_BUDGET + 2 above: 1 for step 4's single candidate-
748
+ // verification fetch (re-checking the best-fitting candidate's
749
+ // real docs against the checklist, added to catch evidence
750
+ // errors search-snippet-only scoring was producing -- confirmed
751
+ // live: an invented feature claim and a missed real one, both on
752
+ // the same case, both from trusting search snippets over the
753
+ // actual page), and 2 for step 6's Mobbin + Figma Community
754
+ // deep-link checks (exactly one fetch per reference source,
755
+ // never more than once per source). Not reserved from the
756
+ // web_search budget above; this is a separate tool with its own
757
+ // separate cap.
758
+ max_uses: 3,
759
+ // Category/browse pages can be large, and all we need from them
760
+ // is a permalink, not the full page -- caps token cost of a
761
+ // fetch that turns out not to have a deep link after all.
762
+ max_content_tokens: 15000,
763
+ },
764
+ ],
366
765
  });
367
- if (!response.ok) {
368
- const errText = await response.text();
369
- throw new Error(`Anthropic API error ${response.status}: ${errText}`);
370
- }
371
- const data = (await response.json());
372
766
  // Diagnostic only -- logged to stderr (stdout is the MCP JSON-RPC
373
767
  // channel) so callers can measure actual vs. attempted search-call
374
768
  // counts against the configured budget without it leaking into the
@@ -485,6 +879,13 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${pastDecisionsBlock
485
879
  enforceCoverageRecount(parsed);
486
880
  enforceVerdictThreshold(parsed);
487
881
  enforceRecommendationConsistency(parsed);
882
+ // Set server-side rather than trusted from the model -- deterministic
883
+ // from whether input.checklist was actually supplied, same "never trust
884
+ // the model where the server already knows the truth" policy as the
885
+ // other enforce* functions above.
886
+ parsed.checklist_source = checklistSource;
887
+ parsed._meta = buildMeta(data.timings, data.usage);
888
+ parsed._meta.scoring_fetch = findScoringFetch(fetchCallDetails);
488
889
  // Same "server-side, not just prompt instruction" policy as the rest of
489
890
  // this file: a past_decision_signal is only trusted when this call
490
891
  // actually had past-decision context to consider. Strips a fabricated
@@ -501,6 +902,77 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${pastDecisionsBlock
501
902
  }
502
903
  return { ok: true, result: parsed };
503
904
  }
905
+ // Backs the extract_requirements tool. Deliberately a separate, much
906
+ // smaller call than runSinglePass above: no tools declared (extraction is
907
+ // pure reasoning over component_need + domain, no search needed), so this
908
+ // is fast and cheap relative to recommend_component's full pipeline. Also
909
+ // applies the same local skip-list short-circuit as recommend_component,
910
+ // for the same reason (trivial primitives shouldn't cost an API call here
911
+ // either).
912
+ async function runExtraction(input) {
913
+ if (!ANTHROPIC_API_KEY) {
914
+ throw new Error("ANTHROPIC_API_KEY is not set. Export it in the environment running this MCP server.");
915
+ }
916
+ const startMs = Date.now();
917
+ if (isSkipListMatch(input.component_need)) {
918
+ const elapsedMs = Math.max(1, Date.now() - startMs);
919
+ return {
920
+ ok: true,
921
+ result: {
922
+ checklist: [],
923
+ extraction_confidence: "high",
924
+ _meta: {
925
+ total_ms: elapsedMs,
926
+ breakdown_ms: { extract: elapsedMs, search: 0, score: 0 },
927
+ tokens_used: { input: 0, output: 0 },
928
+ estimated_cost_usd: 0,
929
+ },
930
+ },
931
+ };
932
+ }
933
+ const userMessage = `component_need: ${input.component_need}\ndomain: ${input.domain}`;
934
+ const data = await streamAnthropicMessage({
935
+ model: MODEL,
936
+ max_tokens: 1024,
937
+ system: [
938
+ {
939
+ type: "text",
940
+ text: buildExtractionSystemPrompt(),
941
+ cache_control: { type: "ephemeral" },
942
+ },
943
+ ],
944
+ messages: [{ role: "user", content: userMessage }],
945
+ });
946
+ if (data.stop_reason === "max_tokens") {
947
+ throw new Error("Anthropic response was truncated (stop_reason: max_tokens) before finishing its JSON output.");
948
+ }
949
+ const finalText = data.content
950
+ .filter((block) => block.type === "text")
951
+ .map((block) => block.text ?? "")
952
+ .join("\n")
953
+ .trim();
954
+ if (!finalText) {
955
+ throw new Error(`Anthropic response contained no text content to extract JSON from (stop_reason: ${data.stop_reason ?? "unknown"}).`);
956
+ }
957
+ const extracted = extractJson(finalText);
958
+ let parsed;
959
+ try {
960
+ parsed = JSON.parse(extracted);
961
+ }
962
+ catch {
963
+ console.error(JSON.stringify({ diagnostic: "postprocess_skipped", reason: "extract_requirements output did not parse as JSON" }));
964
+ return { ok: false, raw: extracted };
965
+ }
966
+ const checklist = Array.isArray(parsed.checklist) ? parsed.checklist.filter((item) => typeof item === "string") : [];
967
+ return {
968
+ ok: true,
969
+ result: {
970
+ checklist,
971
+ extraction_confidence: estimateExtractionConfidence(input.component_need),
972
+ _meta: buildMeta(data.timings, data.usage),
973
+ },
974
+ };
975
+ }
504
976
  // Coverage can only land on one of 9 discrete values when exactly 8
505
977
  // checklist items are extracted (0, 12.5, 25, 37.5, 50, 62.5, 75, 87.5,
506
978
  // 100%). The 40% verdict threshold sits between met=3 (37.5%) and met=4
@@ -519,8 +991,8 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${pastDecisionsBlock
519
991
  // both point the same direction for that case. It was pure extra cost
520
992
  // with no observed stability benefit; revisit if a future case shows
521
993
  // otherwise.
522
- const BOUNDARY_RISK_MET_COUNTS_FOR_8_ITEMS = new Set([3, 4, 6, 7]);
523
- function isBoundaryRisk(result) {
994
+ export const BOUNDARY_RISK_MET_COUNTS_FOR_8_ITEMS = new Set([3, 4, 6, 7]);
995
+ export function isBoundaryRisk(result) {
524
996
  if (result.reason !== "scored")
525
997
  return false;
526
998
  const items = result.requirements_checked;
@@ -570,6 +1042,9 @@ function logCall(input, result) {
570
1042
  if (result.verdict === "custom_build") {
571
1043
  entry.reference_sources_grounded = groundedReferenceSources(result.recommendation);
572
1044
  }
1045
+ entry.checklist_source = result.checklist_source ?? null;
1046
+ entry.total_ms = result._meta?.total_ms ?? null;
1047
+ entry.estimated_cost_usd = result._meta?.estimated_cost_usd ?? null;
573
1048
  }
574
1049
  appendFileSync(LOG_PATH, JSON.stringify(entry) + "\n", "utf8");
575
1050
  }
@@ -614,6 +1089,12 @@ function recordDecision(input) {
614
1089
  action: input.action,
615
1090
  source: input.source,
616
1091
  timestamp: input.timestamp ?? new Date().toISOString(),
1092
+ // Finite-number guard only -- no range/sanity clamp, since a caller's
1093
+ // own estimate isn't Pattern's to second-guess. NaN/Infinity would
1094
+ // corrupt memory.json's JSON on write, so those alone are rejected.
1095
+ time_saved_minutes: typeof input.time_saved_minutes === "number" && Number.isFinite(input.time_saved_minutes)
1096
+ ? input.time_saved_minutes
1097
+ : undefined,
617
1098
  };
618
1099
  const memory = readMemory();
619
1100
  const existing = memory[input.project_id] ?? [];
@@ -625,16 +1106,197 @@ function recordDecision(input) {
625
1106
  // provided. Never called with no project_id -- callers skip memory
626
1107
  // entirely in that case (see runSinglePass) rather than falling back to
627
1108
  // some shared bucket that would mix unrelated projects' decisions.
628
- function getPastDecisions(projectId) {
1109
+ export function getPastDecisions(projectId) {
629
1110
  const memory = readMemory();
630
1111
  return memory[projectId] ?? [];
631
1112
  }
1113
+ function hashConventions(existingStack) {
1114
+ if (!existingStack)
1115
+ return null;
1116
+ return createHash("sha256").update(existingStack).digest("hex").slice(0, 16);
1117
+ }
1118
+ // Same "missing/malformed collapses to empty" philosophy as readMemory,
1119
+ // but line-oriented (JSONL) rather than whole-file JSON -- a single
1120
+ // corrupted line (e.g. a hand-edited file, or a write that got cut off)
1121
+ // is skipped rather than failing the whole read.
1122
+ function readLedgerEntries(projectId) {
1123
+ let raw;
1124
+ try {
1125
+ raw = readFileSync(LEDGER_PATH, "utf8");
1126
+ }
1127
+ catch {
1128
+ return [];
1129
+ }
1130
+ const entries = [];
1131
+ for (const line of raw.split("\n")) {
1132
+ if (!line.trim())
1133
+ continue;
1134
+ try {
1135
+ const parsed = JSON.parse(line);
1136
+ if (parsed && typeof parsed === "object" && parsed.project_id === projectId) {
1137
+ entries.push(parsed);
1138
+ }
1139
+ }
1140
+ catch {
1141
+ // skip malformed line
1142
+ }
1143
+ }
1144
+ return entries;
1145
+ }
1146
+ // The only entry point that writes ledger.jsonl. Validates every
1147
+ // candidate against the DistilledCandidate boundary before it ever touches
1148
+ // disk -- a raw object reaching here throws rather than silently
1149
+ // persisting (see assertDistilledCandidateShape).
1150
+ function appendLedgerEntry(entry) {
1151
+ for (const candidate of entry.candidates_evaluated) {
1152
+ assertDistilledCandidateShape(candidate);
1153
+ }
1154
+ mkdirSync(dirname(LEDGER_PATH), { recursive: true });
1155
+ appendFileSync(LEDGER_PATH, JSON.stringify(entry) + "\n", "utf8");
1156
+ }
1157
+ // Verdict-serving match: deliberately stricter than findLedgerMatches
1158
+ // below (exact component_need/domain/framework, not keyword overlap)
1159
+ // since this decides whether a fresh API call gets skipped entirely, not
1160
+ // just what gets listed back to a caller browsing history.
1161
+ function findLedgerCacheHit(input, entries) {
1162
+ const snapshot = hashConventions(input.existing_stack);
1163
+ const needLower = input.component_need.trim().toLowerCase();
1164
+ const ttlMs = LEDGER_TTL_DAYS * 24 * 60 * 60 * 1000;
1165
+ const now = Date.now();
1166
+ const eligible = entries.filter((e) => {
1167
+ if (e.component_need.trim().toLowerCase() !== needLower)
1168
+ return false;
1169
+ if (e.domain !== input.domain)
1170
+ return false;
1171
+ if (e.framework !== input.framework)
1172
+ return false;
1173
+ if (e.project_conventions_snapshot !== snapshot)
1174
+ return false;
1175
+ if (e.confidence !== "high")
1176
+ return false;
1177
+ if (e.reason !== "scored" && e.reason !== "no_candidates_found")
1178
+ return false;
1179
+ const age = now - new Date(e.timestamp).getTime();
1180
+ if (!Number.isFinite(age) || age > ttlMs)
1181
+ return false;
1182
+ return true;
1183
+ });
1184
+ if (eligible.length === 0)
1185
+ return null;
1186
+ return eligible.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0];
1187
+ }
1188
+ // Broader listing for the read_ledger tool itself -- simple keyword match
1189
+ // on component_need (no embeddings, per the build plan's explicit v1
1190
+ // scope), not the strict exact match findLedgerCacheHit needs.
1191
+ function findLedgerMatches(projectId, componentNeed, limit = 20) {
1192
+ let entries = readLedgerEntries(projectId);
1193
+ if (componentNeed && componentNeed.trim()) {
1194
+ const needle = componentNeed.trim().toLowerCase();
1195
+ entries = entries.filter((e) => e.component_need.toLowerCase().includes(needle));
1196
+ }
1197
+ entries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
1198
+ return entries.slice(0, limit);
1199
+ }
632
1200
  // Orchestrates the ensemble: run once, and only pay for 2 more full
633
1201
  // pipeline passes when the single-run result landed close enough to a
634
1202
  // verdict threshold that a single item's judgment swinging could flip
635
1203
  // the answer. Cases far from any boundary return the fast single-run
636
1204
  // path unchanged, at no extra cost.
1205
+ // Sums _meta across every pass that actually ran, for the ensemble case --
1206
+ // "total" here means cumulative internal compute/cost across all reruns,
1207
+ // not perceived wall-clock latency (the second and third passes run
1208
+ // concurrently via Promise.all, so wall-clock is closer to ~2x one pass,
1209
+ // not ~3x). Cost and token spend are genuinely additive across reruns, so
1210
+ // that's what total_ms/tokens_used/estimated_cost_usd report here; this is
1211
+ // called out in the README so a 3x-looking total_ms isn't mistaken for
1212
+ // request latency.
1213
+ function aggregateMeta(passes) {
1214
+ const metas = passes.map((p) => p.result._meta).filter((m) => !!m);
1215
+ if (metas.length === 0)
1216
+ return undefined;
1217
+ return {
1218
+ total_ms: metas.reduce((sum, m) => sum + m.total_ms, 0),
1219
+ breakdown_ms: {
1220
+ extract: metas.reduce((sum, m) => sum + m.breakdown_ms.extract, 0),
1221
+ search: metas.reduce((sum, m) => sum + m.breakdown_ms.search, 0),
1222
+ score: metas.reduce((sum, m) => sum + m.breakdown_ms.score, 0),
1223
+ },
1224
+ tokens_used: {
1225
+ input: metas.reduce((sum, m) => sum + m.tokens_used.input, 0),
1226
+ output: metas.reduce((sum, m) => sum + m.tokens_used.output, 0),
1227
+ },
1228
+ estimated_cost_usd: Math.round(metas.reduce((sum, m) => sum + m.estimated_cost_usd, 0) * 10000) / 10000,
1229
+ };
1230
+ }
1231
+ // Builds the LedgerEntry appended after a fresh (non-cache-hit) judgment.
1232
+ // checklist/checklist_source come from the result itself, not input.checklist
1233
+ // -- that field captures what was actually scored regardless of whether the
1234
+ // caller pre-supplied it or this call extracted it internally.
1235
+ function buildLedgerEntry(input, projectId, result) {
1236
+ const candidate = distillCandidate(result);
1237
+ const checklist = Array.isArray(result.requirements_checked)
1238
+ ? result.requirements_checked.map((r) => r.requirement).filter((r) => !!r)
1239
+ : [];
1240
+ return {
1241
+ id: randomUUID(),
1242
+ timestamp: new Date().toISOString(),
1243
+ project_id: projectId,
1244
+ component_need: input.component_need,
1245
+ domain: input.domain,
1246
+ framework: input.framework,
1247
+ checklist,
1248
+ checklist_source: result.checklist_source ?? "extracted",
1249
+ candidates_evaluated: candidate ? [candidate] : [],
1250
+ verdict: result.verdict,
1251
+ chosen_candidate: candidate?.name ?? null,
1252
+ confidence: result.confidence,
1253
+ reason: result.reason,
1254
+ coverage: result.coverage ?? null,
1255
+ project_conventions_snapshot: hashConventions(input.existing_stack),
1256
+ };
1257
+ }
637
1258
  async function judgeComponent(input) {
1259
+ // The one deliberate exception to "coverage is scored fresh every time"
1260
+ // (see file header and runSinglePass's memory-lookup comment) -- bounded
1261
+ // by exact component_need/domain/framework/conventions match, confidence
1262
+ // "high", and LEDGER_TTL_DAYS staleness. Checked before the skip-list
1263
+ // fast-path so a skip-list primitive never bothers with a ledger read.
1264
+ // Gated by LEDGER_CACHE_HIT_ENABLED (PATTERN_NO_LEDGER_CACHE_HIT) so the
1265
+ // "always fresh" behavior can be restored without removing this code.
1266
+ const ledgerCacheHit = LEDGER_CACHE_HIT_ENABLED && !isSkipListMatch(input.component_need) && input.project_id
1267
+ ? findLedgerCacheHit(input, readLedgerEntries(input.project_id))
1268
+ : null;
1269
+ if (ledgerCacheHit) {
1270
+ console.error(JSON.stringify({
1271
+ diagnostic: "ledger_cache_hit",
1272
+ project_id: input.project_id,
1273
+ ledger_entry_id: ledgerCacheHit.id,
1274
+ original_timestamp: ledgerCacheHit.timestamp,
1275
+ }));
1276
+ const candidate = ledgerCacheHit.candidates_evaluated[0] ?? null;
1277
+ const result = {
1278
+ verdict: ledgerCacheHit.verdict,
1279
+ confidence: ledgerCacheHit.confidence,
1280
+ reason: "ledger_cache_hit",
1281
+ coverage: ledgerCacheHit.coverage,
1282
+ requirements_checked: null,
1283
+ recommendation: candidate
1284
+ ? { source: candidate.source, install_command: null, component_description: candidate.name, reference: null }
1285
+ : null,
1286
+ ensemble: { triggered: false },
1287
+ checklist_source: ledgerCacheHit.checklist_source,
1288
+ served_from_ledger: true,
1289
+ ledger_entry_id: ledgerCacheHit.id,
1290
+ original_verdict_timestamp: ledgerCacheHit.timestamp,
1291
+ _meta: {
1292
+ total_ms: 1,
1293
+ breakdown_ms: { extract: 1, search: 0, score: 0 },
1294
+ tokens_used: { input: 0, output: 0 },
1295
+ estimated_cost_usd: 0,
1296
+ },
1297
+ };
1298
+ return JSON.stringify(result);
1299
+ }
638
1300
  // Session cap and local logging both apply only to calls that actually
639
1301
  // reach the API -- skip-list hits never do, so both are excluded here
640
1302
  // on the same condition rather than counted/logged and refunded.
@@ -656,6 +1318,9 @@ async function judgeComponent(input) {
656
1318
  first.result.ensemble = { triggered: false };
657
1319
  if (reachesApi)
658
1320
  logCall(input, first.result);
1321
+ if (reachesApi && input.project_id && (first.result.reason === "scored" || first.result.reason === "no_candidates_found")) {
1322
+ appendLedgerEntry(buildLedgerEntry(input, input.project_id, first.result));
1323
+ }
659
1324
  return JSON.stringify(first.result);
660
1325
  }
661
1326
  console.error(JSON.stringify({
@@ -692,6 +1357,16 @@ async function judgeComponent(input) {
692
1357
  if (majorityCount < passes.length)
693
1358
  base.confidence = "low";
694
1359
  base.ensemble = { triggered: true, runs: verdicts, agreement };
1360
+ // Captured before aggregateMeta overwrites base._meta (same object as
1361
+ // winningPass.result._meta) with a fresh summed-across-passes object --
1362
+ // scoring_fetch isn't summed like cost/tokens, it describes whichever
1363
+ // single pass's evidence actually became requirements_checked/
1364
+ // recommendation below, so it must come from the winning pass
1365
+ // specifically, not be dropped by aggregateMeta not knowing about it.
1366
+ const winningScoringFetch = winningPass.result._meta?.scoring_fetch;
1367
+ base._meta = aggregateMeta(passes) ?? base._meta;
1368
+ if (base._meta)
1369
+ base._meta.scoring_fetch = winningScoringFetch;
695
1370
  console.error(JSON.stringify({
696
1371
  diagnostic: "ensemble_decision",
697
1372
  runs: verdicts,
@@ -703,6 +1378,9 @@ async function judgeComponent(input) {
703
1378
  // reachable for calls that passed the skip-list check above -- always
704
1379
  // reachesApi === true here, no guard needed.
705
1380
  logCall(input, base);
1381
+ if (input.project_id && (base.reason === "scored" || base.reason === "no_candidates_found")) {
1382
+ appendLedgerEntry(buildLedgerEntry(input, input.project_id, base));
1383
+ }
706
1384
  return JSON.stringify(base);
707
1385
  }
708
1386
  // The model's stated `coverage` string doesn't always match its own
@@ -715,7 +1393,7 @@ async function judgeComponent(input) {
715
1393
  // a plain enumerable list, not arithmetic the model has to get right --
716
1394
  // and overwrite `coverage` with the true tally before anything else reads
717
1395
  // it.
718
- function enforceCoverageRecount(parsed) {
1396
+ export function enforceCoverageRecount(parsed) {
719
1397
  if (parsed.reason !== "scored")
720
1398
  return;
721
1399
  const items = parsed.requirements_checked;
@@ -742,7 +1420,7 @@ function enforceCoverageRecount(parsed) {
742
1420
  // thresholds (>=80 high, 40-79 low, <40 custom_build) call for
743
1421
  // "use_existing" at low confidence. Recompute deterministically instead of
744
1422
  // trusting the model's arithmetic.
745
- function parseCoveragePercent(coverage) {
1423
+ export function parseCoveragePercent(coverage) {
746
1424
  if (!coverage)
747
1425
  return null;
748
1426
  const parenMatch = coverage.match(/\((\d+(?:\.\d+)?)%\)/);
@@ -757,7 +1435,39 @@ function parseCoveragePercent(coverage) {
757
1435
  }
758
1436
  return null;
759
1437
  }
760
- function enforceVerdictThreshold(parsed) {
1438
+ const ALLOWED_DISTILLED_CANDIDATE_KEYS = new Set(["source", "name", "url", "coverage_pct"]);
1439
+ // Throws rather than silently stripping unknown keys -- a raw object
1440
+ // reaching this function is a bug (some caller skipped distillCandidate),
1441
+ // and failing loudly is what makes "Pattern never persists scraped source"
1442
+ // a checkable claim rather than a hopeful one.
1443
+ export function assertDistilledCandidateShape(value) {
1444
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1445
+ throw new Error("DistilledCandidate must be a plain object");
1446
+ }
1447
+ const keys = Object.keys(value);
1448
+ const extra = keys.filter((k) => !ALLOWED_DISTILLED_CANDIDATE_KEYS.has(k));
1449
+ if (extra.length > 0) {
1450
+ throw new Error(`DistilledCandidate has disallowed key(s): ${extra.join(", ")}`);
1451
+ }
1452
+ }
1453
+ // Only ever called for verdict "use_existing" with a populated
1454
+ // recommendation -- custom_build has no existing candidate to distill, so
1455
+ // candidates_evaluated/chosen_candidate stay empty/null in the ledger for
1456
+ // those. `url` reuses the already fetch-verified scoring_fetch URL
1457
+ // (see JudgmentResult._meta.scoring_fetch) rather than inventing a second
1458
+ // notion of "the candidate's real page" -- if that fetch didn't happen or
1459
+ // failed, url is null rather than falling back to an unverified guess.
1460
+ export function distillCandidate(result) {
1461
+ if (result.verdict !== "use_existing" || !result.recommendation)
1462
+ return null;
1463
+ return {
1464
+ source: result.recommendation.source ?? null,
1465
+ name: result.recommendation.component_description ?? null,
1466
+ url: result._meta?.scoring_fetch?.succeeded ? result._meta.scoring_fetch.url ?? null : null,
1467
+ coverage_pct: parseCoveragePercent(result.coverage),
1468
+ };
1469
+ }
1470
+ export function enforceVerdictThreshold(parsed) {
761
1471
  if (parsed.reason !== "scored")
762
1472
  return;
763
1473
  const pct = parseCoveragePercent(parsed.coverage);
@@ -811,7 +1521,7 @@ function enforceVerdictThreshold(parsed) {
811
1521
  // after every other correction, on every single pass, so each pass
812
1522
  // entering the ensemble is already self-consistent before any
813
1523
  // cross-pass selection happens.
814
- function enforceRecommendationConsistency(parsed) {
1524
+ export function enforceRecommendationConsistency(parsed) {
815
1525
  const rec = parsed.recommendation;
816
1526
  if (!rec)
817
1527
  return;
@@ -843,7 +1553,7 @@ function enforceRecommendationConsistency(parsed) {
843
1553
  // still valid when only one source grounded) or an array of up to 2 --
844
1554
  // normalize, filter per-entry, then collapse back down: 0 survivors ->
845
1555
  // null, 1 -> bare object (never a one-element array), 2 -> array.
846
- function referenceSourceKeyword(source) {
1556
+ export function referenceSourceKeyword(source) {
847
1557
  const normalized = (source ?? "").toLowerCase();
848
1558
  if (normalized.includes("mobbin"))
849
1559
  return "mobbin";
@@ -851,10 +1561,24 @@ function referenceSourceKeyword(source) {
851
1561
  return "figma";
852
1562
  return null; // unrecognized source -- can't verify, treated as ungrounded below
853
1563
  }
854
- const DOMAIN_FOR_SOURCE_KEYWORD = {
1564
+ export const DOMAIN_FOR_SOURCE_KEYWORD = {
855
1565
  mobbin: "mobbin.com",
856
1566
  figma: "figma.com",
857
1567
  };
1568
+ // Distinguishes step 4's single candidate-verification fetch from step 6's
1569
+ // Mobbin/Figma reference fetches -- both use the same web_fetch tool and
1570
+ // the same reserved budget's underlying diagnostics, so this identifies
1571
+ // step 4's fetch as whichever call (if any) targets a domain that ISN'T a
1572
+ // reference source. Diagnostic only, feeding _meta.scoring_fetch -- never
1573
+ // used to correct or invalidate individual requirement judgments (see that
1574
+ // field's own comment for why there's no safe fallback to correct to).
1575
+ export function findScoringFetch(fetchCallDetails) {
1576
+ const referenceDomains = Object.values(DOMAIN_FOR_SOURCE_KEYWORD);
1577
+ const candidateFetch = fetchCallDetails.find((d) => d.url && !referenceDomains.some((domain) => d.url.includes(domain)));
1578
+ if (!candidateFetch)
1579
+ return { attempted: false, succeeded: false, url: null };
1580
+ return { attempted: true, succeeded: candidateFetch.succeeded, url: candidateFetch.url ?? null };
1581
+ }
858
1582
  // Figma Community's own URL structure makes a "/community/file/<id>/<slug>"
859
1583
  // URL inherently specific to one file -- unlike Mobbin's "/explore/..."
860
1584
  // category pages, there's no browse-vs-specific gap to resolve here.
@@ -865,13 +1589,13 @@ const DOMAIN_FOR_SOURCE_KEYWORD = {
865
1589
  // only ever fail -- treating an already-specific file URL as grounded
866
1590
  // without a fetch avoids wasting the reserved fetch budget on a check that
867
1591
  // cannot succeed and isn't needed anyway.
868
- const FIGMA_FILE_URL_PATTERN = /\/community\/file\//i;
1592
+ export const FIGMA_FILE_URL_PATTERN = /\/community\/file\//i;
869
1593
  // Pulls literal http(s) URLs out of arbitrary tool-result content (search
870
1594
  // results, fetched page text) without needing to know that content's
871
1595
  // exact shape -- used only to find real candidate URLs, never to
872
1596
  // construct one, so a shape we didn't anticipate just yields fewer
873
1597
  // matches rather than a wrong parse.
874
- function extractUrlsForDomain(content, domain) {
1598
+ export function extractUrlsForDomain(content, domain) {
875
1599
  if (!content)
876
1600
  return [];
877
1601
  const text = typeof content === "string" ? content : JSON.stringify(content);
@@ -888,7 +1612,7 @@ function extractUrlsForDomain(content, domain) {
888
1612
  // "entry_point" and the URL is swapped for one a real search/fetch call
889
1613
  // actually returned -- the model's own unconfirmed claim is never kept,
890
1614
  // same policy already enforced for search-only grounding above.
891
- function applyDeepLinkGrounding(entry, keyword, searchResultUrlsByKeyword, fetchCallDetails) {
1615
+ export function applyDeepLinkGrounding(entry, keyword, searchResultUrlsByKeyword, fetchCallDetails) {
892
1616
  const domain = DOMAIN_FOR_SOURCE_KEYWORD[keyword];
893
1617
  const claimedUrl = (entry.url ?? "").trim();
894
1618
  if (keyword === "figma" && FIGMA_FILE_URL_PATTERN.test(claimedUrl)) {
@@ -926,7 +1650,7 @@ function applyDeepLinkGrounding(entry, keyword, searchResultUrlsByKeyword, fetch
926
1650
  entry.reference_description = `${entry.reference_description} (${caveat})`;
927
1651
  }
928
1652
  }
929
- function enforceReferenceGrounding(parsed, searchCallDetails, searchResultUrlsByKeyword, fetchCallDetails) {
1653
+ export function enforceReferenceGrounding(parsed, searchCallDetails, searchResultUrlsByKeyword, fetchCallDetails) {
930
1654
  const rawReference = parsed.recommendation?.reference;
931
1655
  if (!rawReference)
932
1656
  return;
@@ -970,7 +1694,7 @@ function enforceReferenceGrounding(parsed, searchCallDetails, searchResultUrlsBy
970
1694
  // output directly (the README's whole contract is structured JSON, not
971
1695
  // prose), so pull out the {...} substring rather than trust verbatim
972
1696
  // compliance.
973
- function extractJson(text) {
1697
+ export function extractJson(text) {
974
1698
  const start = text.indexOf("{");
975
1699
  const end = text.lastIndexOf("}");
976
1700
  if (start === -1 || end === -1 || end < start)
@@ -983,9 +1707,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
983
1707
  {
984
1708
  name: TOOL_NAME,
985
1709
  description: "Judges whether a UI component need should be met with an existing " +
986
- "shadcn/ui or 21st.dev component, or requires a custom build guided " +
987
- "by a real-app reference from Mobbin. Returns a structured verdict " +
988
- "(use_existing | custom_build), not a list of search results. Call " +
1710
+ "shadcn/ui, 21st.dev, or ReUI (reui.io) component, or requires a " +
1711
+ "custom build guided by a real-app reference from Mobbin. Returns " +
1712
+ "a structured verdict (use_existing | custom_build), not a list " +
1713
+ "of search results. Call " +
989
1714
  "this whenever you are about to scaffold a new, non-trivial UI " +
990
1715
  "component from scratch, when you're unsure your own default output " +
991
1716
  "will look production-quality, or when the user references a " +
@@ -1010,9 +1735,38 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1010
1735
  "own past confirmed decisions (recorded via " +
1011
1736
  "record_component_decision) as a consistency signal -- coverage " +
1012
1737
  "is still scored fresh every call regardless; this never returns " +
1013
- "a cached verdict.",
1738
+ "a cached verdict. Pass checklist (optional, string array) to skip " +
1739
+ "this call's own internal requirement extraction and score " +
1740
+ "directly against a checklist you already have -- e.g. from a " +
1741
+ "prior extract_requirements call you inspected or edited first. " +
1742
+ "Omit it to keep today's default behavior unchanged. The response " +
1743
+ "always includes checklist_source ('extracted' | 'provided') and " +
1744
+ "an internal _meta block (timing/token/cost accounting) -- neither " +
1745
+ "affects the verdict itself. Surface _meta.estimated_cost_usd to " +
1746
+ "the user after the call (e.g. 'that judgment cost ~$0.12'), the " +
1747
+ "same way install_command is shown before running -- it's real " +
1748
+ "spend against the user's own API key, not internal bookkeeping " +
1749
+ "to keep from them.",
1014
1750
  inputSchema: INPUT_SCHEMA,
1015
1751
  },
1752
+ {
1753
+ name: EXTRACT_REQUIREMENTS_TOOL_NAME,
1754
+ description: "Runs only the requirement-extraction step recommend_component " +
1755
+ "normally does internally, and returns the checklist on its own -- " +
1756
+ "no search, no scoring, no verdict. Use this when you want to " +
1757
+ "inspect (and optionally hand-edit) the checklist BEFORE " +
1758
+ "recommend_component spends its search+score budget, e.g. to catch " +
1759
+ "a misread requirement early. Pass the resulting (or your edited) " +
1760
+ "checklist back into recommend_component's optional checklist " +
1761
+ "param to score against it directly. extraction_confidence is a " +
1762
+ "heuristic based on how specific component_need is, not a " +
1763
+ "calibrated signal -- treat 'low' as a hint to reread the input, " +
1764
+ "not a hard error. Cheaper and faster than recommend_component " +
1765
+ "since it makes no search calls at all. Also returns an internal " +
1766
+ "_meta block -- surface _meta.estimated_cost_usd to the user " +
1767
+ "after the call, same as recommend_component.",
1768
+ inputSchema: EXTRACT_REQUIREMENTS_INPUT_SCHEMA,
1769
+ },
1016
1770
  {
1017
1771
  name: RECORD_DECISION_TOOL_NAME,
1018
1772
  description: "Records a UI component decision you have actually acted on -- call " +
@@ -1023,9 +1777,27 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1023
1777
  "calls with the same project_id will see this decision as a " +
1024
1778
  "consistency signal, not a binding rule. Use a stable project_id " +
1025
1779
  "(e.g. the project's directory path or name) so decisions are " +
1026
- "grouped correctly and never mixed with another project's.",
1780
+ "grouped correctly and never mixed with another project's. Pass " +
1781
+ "time_saved_minutes (optional) if you have a genuine estimate of how " +
1782
+ "much time this decision saved you -- this is your own self-reported " +
1783
+ "number, never computed or verified by Pattern.",
1027
1784
  inputSchema: RECORD_DECISION_INPUT_SCHEMA,
1028
1785
  },
1786
+ {
1787
+ name: READ_LEDGER_TOOL_NAME,
1788
+ description: "Lists past recommend_component judgment entries for a project_id -- " +
1789
+ "every call that reached the API and produced a verdict, not just " +
1790
+ "ones you explicitly confirmed via record_component_decision. Each " +
1791
+ "entry holds only distilled fields (verdict, confidence, coverage, " +
1792
+ "chosen candidate's source/name/url) -- never the original " +
1793
+ "per-requirement evidence text. Useful for auditing what Pattern has " +
1794
+ "already judged for a project, or for understanding why a later " +
1795
+ "call came back with served_from_ledger: true (see recommend_component " +
1796
+ "-- a high-confidence entry here, matching on component_need/domain/" +
1797
+ "framework/existing_stack and recent enough, can be served directly " +
1798
+ "instead of a fresh search+score).",
1799
+ inputSchema: READ_LEDGER_INPUT_SCHEMA,
1800
+ },
1029
1801
  ],
1030
1802
  }));
1031
1803
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -1045,6 +1817,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1045
1817
  };
1046
1818
  }
1047
1819
  }
1820
+ if (request.params.name === EXTRACT_REQUIREMENTS_TOOL_NAME) {
1821
+ const args = request.params.arguments;
1822
+ // Same session-cap protection as recommend_component, extended to
1823
+ // this tool since it's a real API call too (skip-list hits excluded,
1824
+ // same exclusion recommend_component applies).
1825
+ const reachesApi = !isSkipListMatch(args.component_need);
1826
+ try {
1827
+ if (reachesApi) {
1828
+ if (sessionCallCount >= SESSION_CALL_CAP) {
1829
+ throw new Error(`Session call cap (${SESSION_CALL_CAP}) reached. This protects against runaway costs on your API key. Restart the MCP server to reset the counter, or set PATTERN_SESSION_CAP to raise the limit.`);
1830
+ }
1831
+ sessionCallCount++;
1832
+ console.error(JSON.stringify({ diagnostic: "session_call_count", count: sessionCallCount, cap: SESSION_CALL_CAP }));
1833
+ }
1834
+ const outcome = await runExtraction(args);
1835
+ const resultText = outcome.ok ? JSON.stringify(outcome.result) : outcome.raw;
1836
+ return {
1837
+ content: [{ type: "text", text: resultText }],
1838
+ };
1839
+ }
1840
+ catch (err) {
1841
+ const message = err instanceof Error ? err.message : String(err);
1842
+ return {
1843
+ content: [{ type: "text", text: `Error: ${message}` }],
1844
+ isError: true,
1845
+ };
1846
+ }
1847
+ }
1048
1848
  if (request.params.name === RECORD_DECISION_TOOL_NAME) {
1049
1849
  const args = request.params.arguments;
1050
1850
  try {
@@ -1066,13 +1866,37 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1066
1866
  };
1067
1867
  }
1068
1868
  }
1869
+ if (request.params.name === READ_LEDGER_TOOL_NAME) {
1870
+ const args = request.params.arguments;
1871
+ try {
1872
+ const entries = findLedgerMatches(args.project_id, args.component_need, args.limit);
1873
+ return {
1874
+ content: [{ type: "text", text: JSON.stringify({ project_id: args.project_id, entries }) }],
1875
+ };
1876
+ }
1877
+ catch (err) {
1878
+ const message = err instanceof Error ? err.message : String(err);
1879
+ return {
1880
+ content: [{ type: "text", text: `Error: ${message}` }],
1881
+ isError: true,
1882
+ };
1883
+ }
1884
+ }
1069
1885
  throw new Error(`Unknown tool: ${request.params.name}`);
1070
1886
  });
1071
1887
  async function main() {
1072
1888
  const transport = new StdioServerTransport();
1073
1889
  await server.connect(transport);
1074
1890
  }
1075
- main().catch((err) => {
1076
- console.error("Fatal error starting pattern-mcp:", err);
1077
- process.exit(1);
1078
- });
1891
+ // Guard exists so verification scripts (e.g. verify-ledger-boundary.mjs)
1892
+ // can import this module's exported pure functions (distillCandidate,
1893
+ // assertDistilledCandidateShape, parseCoveragePercent, etc.) without also
1894
+ // spinning up a stdio server that blocks on stdin. Real usage (the bin
1895
+ // entry point, `npx pattern-mcp`) never sets this, so autostart is
1896
+ // unaffected.
1897
+ if (!process.env.PATTERN_NO_AUTOSTART) {
1898
+ main().catch((err) => {
1899
+ console.error("Fatal error starting pattern-mcp:", err);
1900
+ process.exit(1);
1901
+ });
1902
+ }