pattern-mcp 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.
Files changed (3) hide show
  1. package/README.md +227 -27
  2. package/dist/index.js +382 -30
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -5,8 +5,9 @@
5
5
  [![npm downloads](https://img.shields.io/npm/dm/pattern-mcp.svg)](https://www.npmjs.com/package/pattern-mcp)
6
6
  [![MIT license](https://img.shields.io/badge/license-MIT-111111.svg)](./LICENSE)
7
7
 
8
- Pattern is an MCP server that helps coding agents make better UI
9
- component decisions.
8
+ Pattern is an MCP server that checks a UI component need against real,
9
+ current evidence before your agent commits to it, so a wrong decision
10
+ gets caught before it's built, not after.
10
11
 
11
12
  [Website](https://usepattern.sh) · [npm](https://www.npmjs.com/package/pattern-mcp) · [Report an issue](https://github.com/donaldrichard19-LVD/pattern-mcp/issues/new/choose)
12
13
 
@@ -31,7 +32,7 @@ whether to:
31
32
 
32
33
  Pattern is designed for agents to use **while they are building**.
33
34
 
34
- It exposes three tools:
35
+ It exposes four tools:
35
36
 
36
37
  - `recommend_component` — evaluates a UI component need and returns a
37
38
  structured recommendation.
@@ -41,6 +42,9 @@ It exposes three tools:
41
42
  - `record_component_decision` — records what the agent actually did so
42
43
  future recommendations in the same project can take that decision into
43
44
  account.
45
+ - `read_ledger` — lists past `recommend_component` judgments for a
46
+ `project_id`, including any that were served from the ledger cache (see
47
+ [Per-project judgment ledger](#per-project-judgment-ledger)).
44
48
 
45
49
  ## How it works
46
50
 
@@ -70,6 +74,9 @@ A result can also be:
70
74
  - `custom_build`
71
75
  - `no_candidates_found`
72
76
  - `skip_list`
77
+ - `ledger_cache_hit` — served from a recent, matching prior judgment
78
+ instead of a fresh search+score (see
79
+ [Per-project judgment ledger](#per-project-judgment-ledger)).
73
80
 
74
81
  `no_candidates_found` is kept separate from a low-coverage result. Not
75
82
  finding a candidate is different from finding candidates that don't cover
@@ -78,7 +85,14 @@ the requirements.
78
85
  If `project_id` is supplied, Pattern also checks for past confirmed
79
86
  decisions on that project and factors them in as a consistency signal —
80
87
  never a rule that overrides a genuinely better match found in the current
81
- search.
88
+ search. Separately, `project_id` also enables the judgment ledger: a
89
+ high-confidence prior judgment matching this exact
90
+ component_need/domain/framework/existing_stack, recorded recently enough,
91
+ can be served directly (`ledger_cache_hit`) instead of running a fresh
92
+ search+score. This is the one deliberate exception to "every recommendation
93
+ searches and scores again" — see
94
+ [Per-project judgment ledger](#per-project-judgment-ledger) for the exact
95
+ rules and why it's safe.
82
96
 
83
97
  Every result includes `computed_at`, because coverage is a snapshot of the
84
98
  search at that point in time, not a permanent fact. Every result also
@@ -600,7 +614,8 @@ Anthropic API call.
600
614
  "domain": "Airbnb-style rental marketplace",
601
615
  "action": "custom_built",
602
616
  "source": "custom",
603
- "timestamp": "2026-08-25T14:32:00.000Z"
617
+ "timestamp": "2026-08-25T14:32:00.000Z",
618
+ "time_saved_minutes": 25
604
619
  }
605
620
  ```
606
621
 
@@ -609,6 +624,15 @@ Anthropic API call.
609
624
  - `action` must be `"installed"` or `"custom_built"`.
610
625
  - `source` can be `"shadcn"`, `"21st.dev"`, `"reui"`, or `"custom"`.
611
626
  - `timestamp` is optional. If omitted, Pattern uses the current time.
627
+ - `time_saved_minutes` is optional -- the calling agent's own estimate,
628
+ in minutes, of how much time this decision saved by having Pattern's
629
+ verdict instead of researching candidates and judging fit from scratch.
630
+ This is entirely self-reported. Pattern has no way to measure a
631
+ counterfactual ("how long would this have taken without Pattern?"), so
632
+ unlike `_meta` (Pattern's own real cost/latency for the call that
633
+ produced the verdict), this number is never computed or verified --
634
+ it's just recorded as-given. Omit it rather than guess a number to fill
635
+ the field.
612
636
 
613
637
  ### Output
614
638
 
@@ -620,6 +644,131 @@ Anthropic API call.
620
644
  }
621
645
  ```
622
646
 
647
+ ## Tool: `read_ledger`
648
+
649
+ Lists past `recommend_component` judgments for a `project_id` -- every
650
+ call that reached the API and produced a verdict, not just ones explicitly
651
+ confirmed via `record_component_decision`. Useful for auditing what
652
+ Pattern has already judged for a project, or for understanding why a call
653
+ came back with `served_from_ledger: true`.
654
+
655
+ ### Input
656
+
657
+ ```json
658
+ {
659
+ "project_id": "my-booking-app",
660
+ "component_need": "cancellation",
661
+ "limit": 10
662
+ }
663
+ ```
664
+
665
+ - `project_id` is required.
666
+ - `component_need` is optional -- a simple keyword filter (substring
667
+ match, no embeddings) against stored entries' `component_need`. Omit to
668
+ list everything for the project.
669
+ - `limit` is optional, defaults to 20. Most recent entries first.
670
+
671
+ ### Output
672
+
673
+ ```json
674
+ {
675
+ "project_id": "my-booking-app",
676
+ "entries": [
677
+ {
678
+ "id": "a1b2c3d4-...",
679
+ "timestamp": "2026-08-29T19:50:47.073Z",
680
+ "project_id": "my-booking-app",
681
+ "component_need": "cancellation policy display with refund tiers by date",
682
+ "domain": "Airbnb-style rental marketplace",
683
+ "framework": "React + Tailwind",
684
+ "checklist": ["...", "..."],
685
+ "checklist_source": "extracted",
686
+ "candidates_evaluated": [
687
+ { "source": "ReUI (reui.io)", "name": "Timeline", "url": "https://reui.io/components/timeline", "coverage_pct": 62.5 }
688
+ ],
689
+ "verdict": "use_existing",
690
+ "chosen_candidate": "Timeline",
691
+ "confidence": "low",
692
+ "reason": "scored",
693
+ "coverage": "5/8 (62.5%)",
694
+ "project_conventions_snapshot": "9f3a1c7e2b0d4f5a"
695
+ }
696
+ ]
697
+ }
698
+ ```
699
+
700
+ Each entry holds only distilled fields -- `candidates_evaluated` never
701
+ contains raw HTML, full prop tables, or the per-requirement evidence text
702
+ `recommend_component` itself returns. See
703
+ [Data minimization](#data-minimization) below.
704
+
705
+ ## Per-project judgment ledger
706
+
707
+ Distinct from [per-project decision memory](#per-project-decision-memory)
708
+ below -- that file only gains an entry when `record_component_decision` is
709
+ explicitly called. The ledger instead gains one entry automatically for
710
+ **every** `recommend_component` call that reaches the API with a
711
+ `project_id` and lands on reason `"scored"` or `"no_candidates_found"`.
712
+
713
+ Pattern stores it locally in:
714
+
715
+ ```
716
+ ~/.pattern/ledger.jsonl
717
+ ```
718
+
719
+ Change the location with `PATTERN_LEDGER_PATH`. One JSON object per line
720
+ (append-only, JSONL).
721
+
722
+ ### The cache-hit exception
723
+
724
+ Every other part of Pattern scores fresh every time (see
725
+ [No caching, by design](#no-caching-by-design)). The ledger is the one
726
+ deliberate exception: a later `recommend_component` call with a matching
727
+ `project_id` **can** be served directly from a prior entry, skipping
728
+ search+score entirely, when **all** of the following hold:
729
+
730
+ - `component_need` matches exactly (case-insensitive).
731
+ - `domain` and `framework` match exactly.
732
+ - `existing_stack` hashes to the same value as the stored entry's
733
+ (both omitted counts as a match).
734
+ - The stored entry's `confidence` is `"high"`.
735
+ - The stored entry's `reason` is `"scored"` or `"no_candidates_found"`.
736
+ - The stored entry is no older than `PATTERN_LEDGER_TTL_DAYS` (default
737
+ **30** days, configurable).
738
+
739
+ When served this way, the response has `reason: "ledger_cache_hit"`,
740
+ `served_from_ledger: true`, `ledger_entry_id`, and
741
+ `original_verdict_timestamp` -- so nothing is ever silently passed off as
742
+ freshly verified. `_meta.estimated_cost_usd` and `tokens_used` are
743
+ genuinely `0`: no API call happened. `requirements_checked` is `null` on
744
+ this path -- the ledger never stores per-requirement evidence text (see
745
+ [Data minimization](#data-minimization)), so a cache hit can only replay
746
+ the verdict/confidence/coverage/chosen-candidate, not the original
747
+ per-requirement reasoning.
748
+
749
+ Any mismatch on the criteria above -- a different `domain`, a changed
750
+ `existing_stack`, an entry that's gone stale, or one that wasn't
751
+ high-confidence -- falls through to a normal, fresh search+score call.
752
+
753
+ ### Turning the cache-hit exception off
754
+
755
+ Set `PATTERN_NO_LEDGER_CACHE_HIT` (any truthy value) to restore
756
+ "every `recommend_component` call always scores fresh" without removing
757
+ any ledger code. This disables only the cache-hit short-circuit --
758
+ entries are still written to `ledger.jsonl` and `read_ledger` still works
759
+ either way, so the audit trail keeps growing even with the switch on.
760
+ Unset the variable to re-enable cache hits again at any time.
761
+
762
+ ### Data minimization
763
+
764
+ Nothing written to the ledger ever contains raw search/fetch content.
765
+ Every candidate is reduced to exactly four fields before it's written --
766
+ `source`, `name`, `url`, `coverage_pct` -- enforced at the type level
767
+ (`assertDistilledCandidateShape` in `src/index.ts`), not just by
768
+ convention: a raw or extended object throws rather than silently
769
+ persisting. Run `node scripts/verify-ledger-boundary.mjs` (after
770
+ `npm run build`) to check this boundary directly.
771
+
623
772
  ## Per-project decision memory
624
773
 
625
774
  Pattern stores confirmed decisions locally in:
@@ -644,12 +793,16 @@ The file is organized by project:
644
793
  "domain": "Airbnb-style rental marketplace",
645
794
  "action": "custom_built",
646
795
  "source": "custom",
647
- "timestamp": "2026-08-25T14:32:00.000Z"
796
+ "timestamp": "2026-08-25T14:32:00.000Z",
797
+ "time_saved_minutes": 25
648
798
  }
649
799
  ]
650
800
  }
651
801
  ```
652
802
 
803
+ `time_saved_minutes` is omitted from an entry entirely when the calling
804
+ agent didn't provide one -- it's never backfilled or estimated by Pattern.
805
+
653
806
  Each project keeps its 50 most recent decisions. Older entries are
654
807
  removed as new ones are added.
655
808
 
@@ -668,12 +821,13 @@ sensitive information in them. See [SECURITY.md](./SECURITY.md).
668
821
  A failure to write the decision file is returned as an error from
669
822
  `record_component_decision`.
670
823
 
671
- **No caching, by design.** Project memory does not cache recommendations.
672
- A previous decision is only additional context for a new judgment. Every
673
- `recommend_component` call performs a fresh search and recalculates
674
- coverage. This means Pattern can use past decisions to improve
675
- consistency without letting stale decisions replace current evidence
676
- see [Known limitations](#known-limitations) for more.
824
+ **No caching, by design.** Project memory (this file, `memory.json`) does
825
+ not cache recommendations. A previous decision is only additional context
826
+ for a new judgment. This is unrelated to the
827
+ [judgment ledger](#per-project-judgment-ledger)'s bounded cache-hit
828
+ exception, which lives in a separate file (`ledger.jsonl`) and is always
829
+ flagged (`served_from_ledger: true`) when it happens — see
830
+ [Known limitations](#known-limitations) for more.
677
831
 
678
832
  ## Security and privacy
679
833
 
@@ -692,19 +846,28 @@ Pattern uses the Anthropic API, so `recommend_component` has a cost.
692
846
 
693
847
  A typical single pass costs about $0.06–$0.10 with Sonnet 5 at current
694
848
  pricing. Skip-listed primitives cost $0 because they're handled locally
695
- and never reach the API.
849
+ and never reach the API. A [ledger cache hit](#the-cache-hit-exception)
850
+ also costs $0, for the same reason -- no API call happens.
696
851
 
697
852
  ### The `_meta` field
698
853
 
699
854
  Every `recommend_component` and `extract_requirements` response includes
700
- an internal `_meta` block reporting what that call actually spent:
855
+ an internal `_meta` block reporting what that call actually spent. This
856
+ is not shown to the user automatically -- the calling agent has to
857
+ surface it, the same way it's separately instructed to show
858
+ `install_command` before running it (see
859
+ [above](#installation-commands-are-not-trusted)). Both tool descriptions
860
+ say so explicitly: surface `_meta.estimated_cost_usd` after the call,
861
+ since it's real spend against the user's own API key, not internal
862
+ bookkeeping.
701
863
 
702
864
  ```json
703
865
  {
704
866
  "total_ms": 41516,
705
867
  "breakdown_ms": { "extract": 5006, "search": 3114, "score": 33396 },
706
868
  "tokens_used": { "input": 8400, "output": 620 },
707
- "estimated_cost_usd": 0.14
869
+ "estimated_cost_usd": 0.14,
870
+ "scoring_fetch": { "attempted": true, "succeeded": true, "url": "https://ui.shadcn.com/docs/components/..." }
708
871
  }
709
872
  ```
710
873
 
@@ -719,6 +882,14 @@ an internal `_meta` block reporting what that call actually spent:
719
882
  discounts.
720
883
  - `breakdown_ms` -- how `total_ms` splits across `recommend_component`'s
721
884
  three internal phases.
885
+ - `scoring_fetch` -- whether step 4's single candidate-verification fetch
886
+ (see [Fetch-grounded scoring](#fetch-grounded-scoring-and-reference-verification)
887
+ below) actually happened for this response. `url` is `null` when
888
+ `attempted` is `false` (no real candidate to verify, e.g. `reason:
889
+ "no_candidates_found"` or `"skip_list"`). This is a diagnostic only --
890
+ Pattern never uses it to auto-correct `requirements_checked` after the
891
+ fact, since there's no safe fallback value for an unverified met/not-met
892
+ call the way there is for a reference URL.
722
893
 
723
894
  **How `breakdown_ms` is measured, and its one real caveat.** The bundled
724
895
  call runs extraction, search, and scoring inside a single model turn
@@ -746,6 +917,10 @@ not the wall-clock time you waited. The three ensemble passes run with the
746
917
  2nd and 3rd concurrent, so perceived latency is closer to ~2x one pass,
747
918
  not the ~3x `total_ms` will show. Cost and token spend are genuinely
748
919
  additive across reruns, which is what `_meta` is reporting there.
920
+ `scoring_fetch` is the one exception -- it isn't summed (a fetch either
921
+ happened for the specific pass whose evidence became the returned
922
+ `requirements_checked`, or it didn't), so it reports that winning pass's
923
+ own value, not an aggregate across all three.
749
924
 
750
925
  Three things help keep the cost down without changing the decision process.
751
926
 
@@ -770,18 +945,33 @@ shadcn/ui, 21st.dev, and ReUI are searched in the same turn rather than
770
945
  sequentially, which reduces how much conversation context needs to be
771
946
  sent repeatedly.
772
947
 
773
- ### Reference verification
774
-
775
- Pattern allows up to 2 `web_fetch` calls, used only to verify reference
776
- URLs.
948
+ ### Fetch-grounded scoring and reference verification
949
+
950
+ Pattern allows up to 3 `web_fetch` calls per pass: 1 reserved for scoring,
951
+ 2 reserved for reference verification (1 for Mobbin, 1 for Figma
952
+ Community).
953
+
954
+ Before finalizing coverage, Pattern fetches the best-fitting candidate's
955
+ own real docs/source page once and re-checks the checklist against that
956
+ page, not just the search-result snippet it started with. This exists
957
+ because search-result descriptions can both overstate a component's real
958
+ capabilities and miss real ones it actually has -- both were observed in
959
+ testing on the same case (an invented feature claim and a missed real
960
+ one). If the fetch fails, or there's no confirmed URL to fetch, Pattern
961
+ falls back to search-only evidence and says so in the affected items.
962
+
963
+ Each result's `_meta.scoring_fetch` reports whether this fetch actually
964
+ happened for that response (`{ attempted, succeeded, url }`) -- it's a
965
+ diagnostic, not something Pattern uses to auto-correct individual
966
+ requirement judgments. Unlike a reference URL (which has a safe fallback:
967
+ the category page), there's no safe fallback for an unverified met/not-met
968
+ call, so nothing is silently corrected -- `scoring_fetch` just tells you
969
+ whether the grounding actually ran.
777
970
 
778
971
  A fetch can read up to 15,000 content tokens. `web_fetch` has no separate
779
972
  per-call fee; the cost comes from the content added to the model's
780
973
  context.
781
974
 
782
- Pattern does not use `web_fetch` during requirement scoring. It's
783
- reserved for verifying reference links.
784
-
785
975
  ### Choosing a cheaper model
786
976
 
787
977
  You can change the model with:
@@ -1047,22 +1237,32 @@ pipeline is still fully bundled; nothing about this evaluation changed.
1047
1237
 
1048
1238
  ### No caching, by design
1049
1239
 
1050
- Every recommendation searches and scores again.
1240
+ Every recommendation searches and scores again -- with one bounded
1241
+ exception (see below).
1051
1242
 
1052
1243
  This means a recommendation can change as component libraries change.
1053
1244
  For example, a later shadcn/ui release can introduce a component that
1054
1245
  changes a previous `custom_build` result.
1055
1246
 
1056
- Do not persist a recommendation across sessions or builds at the
1057
- calling-agent layer.
1058
-
1059
- If you add caching, keep it session-scoped.
1247
+ Do not build a second, unbounded cache of recommendations at the
1248
+ calling-agent layer on top of Pattern's own. If you add caching there,
1249
+ keep it session-scoped.
1060
1250
 
1061
1251
  [Project decision memory](#per-project-decision-memory) does not change
1062
1252
  this. It provides context from previous decisions, but every
1063
1253
  `recommend_component` call still performs a fresh search and scoring
1064
1254
  pass.
1065
1255
 
1256
+ The one deliberate exception is the
1257
+ [judgment ledger's cache-hit path](#the-cache-hit-exception): a later
1258
+ call matching an exact, recent, high-confidence prior judgment can be
1259
+ served without a fresh search+score. It's bounded (exact
1260
+ component_need/domain/framework/conventions match, a staleness TTL) and
1261
+ always self-identifies via `served_from_ledger: true` and
1262
+ `reason: "ledger_cache_hit"` -- so a calling agent that wants a guaranteed
1263
+ fresh check on every call should look for that flag and treat it the same
1264
+ as any other verdict it wants to double-check.
1265
+
1066
1266
  ### The skip-list is still evolving
1067
1267
 
1068
1268
  The primitive skip-list is a starting point and has not yet been
package/dist/index.js CHANGED
@@ -2,23 +2,35 @@
2
2
  /**
3
3
  * Pattern
4
4
  *
5
- * MCP server exposing two tools. `recommend_component` judges whether a UI
5
+ * MCP server exposing tools built around one judgment: whether a UI
6
6
  * component need should be met with an existing shadcn/ui, 21st.dev, or
7
7
  * ReUI (reui.io) component, or requires a custom build guided by a
8
- * real-app reference from Mobbin. `record_component_decision` appends a
9
- * confirmed decision to local per-project memory (see MEMORY_PATH below),
10
- * which recommend_component can optionally read back (via project_id) as
11
- * consistency context for a future call -- never as a cached verdict;
12
- * coverage is still scored fresh every time.
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";
@@ -100,6 +112,30 @@ const LOG_PATH = process.env.PATTERN_LOG_PATH ?? join(homedir(), ".pattern", "ca
100
112
  // of what's in this file (see README's "no verdict caching" rule).
101
113
  const MEMORY_PATH = process.env.PATTERN_MEMORY_PATH ?? join(homedir(), ".pattern", "memory.json");
102
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;
103
139
  // $/1M tokens, checked against the Anthropic pricing page rather than
104
140
  // recalled from training data (rates drift). Both current and legacy
105
141
  // Haiku 4.5 model-id spellings are listed since PATTERN_MODEL is
@@ -338,6 +374,7 @@ async function streamAnthropicMessage(body) {
338
374
  const TOOL_NAME = "recommend_component";
339
375
  const RECORD_DECISION_TOOL_NAME = "record_component_decision";
340
376
  const EXTRACT_REQUIREMENTS_TOOL_NAME = "extract_requirements";
377
+ const READ_LEDGER_TOOL_NAME = "read_ledger";
341
378
  const INPUT_SCHEMA = {
342
379
  type: "object",
343
380
  properties: {
@@ -367,8 +404,14 @@ const INPUT_SCHEMA = {
367
404
  "belongs to. When provided, past decisions confirmed via " +
368
405
  "record_component_decision for this same project_id are surfaced to " +
369
406
  "the model as a consistency signal (never a rule -- a genuinely " +
370
- "better match found in this search still wins). Omit to skip memory " +
371
- "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.",
372
415
  },
373
416
  checklist: {
374
417
  type: "array",
@@ -428,9 +471,35 @@ const RECORD_DECISION_INPUT_SCHEMA = {
428
471
  type: "string",
429
472
  description: "Optional. ISO 8601 timestamp of the decision. Defaults to the current time if omitted.",
430
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
+ },
431
482
  },
432
483
  required: ["project_id", "component_need", "action", "source"],
433
484
  };
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
+ };
434
503
  // Shared between buildSystemPrompt's own step 2 and
435
504
  // buildExtractionSystemPrompt (the extract_requirements tool's standalone
436
505
  // prompt) -- the extraction *instructions* are one piece of text reused
@@ -466,7 +535,9 @@ Search shadcn/ui, 21st.dev, and ReUI (reui.io) for components matching the need,
466
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.
467
536
 
468
537
  4. SCORE COVERAGE AGAINST THE CHECKLIST
469
- 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.
470
541
 
471
542
  5. APPLY VERDICT THRESHOLDS
472
543
  coverage >= 80% -> verdict "use_existing", confidence "high"
@@ -592,10 +663,13 @@ async function runSinglePass(input) {
592
663
  };
593
664
  }
594
665
  // Coverage still computes fresh below regardless of what this finds --
595
- // memory only ever adds context to the user message, it never short-
596
- // circuits search/scoring or gets treated as a cached verdict. No
597
- // project_id -> no lookup at all, not a shared/global fallback (see
598
- // 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.
599
673
  const pastDecisions = input.project_id ? getPastDecisions(input.project_id) : [];
600
674
  const pastDecisionsBlock = pastDecisions.length === 0
601
675
  ? ""
@@ -668,13 +742,20 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${checklistBlock}${p
668
742
  {
669
743
  type: "web_fetch_20250910",
670
744
  name: "web_fetch",
671
- // Exactly one fetch per reference source (Mobbin, Figma
672
- // Community) -- step 6 fetches the search result page to look
673
- // for a deep link to the specific screen/flow already
674
- // identified, never more than once per source. Not reserved
675
- // from the web_search budget above; this is a separate tool
676
- // with its own separate cap.
677
- max_uses: 2,
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,
678
759
  // Category/browse pages can be large, and all we need from them
679
760
  // is a permalink, not the full page -- caps token cost of a
680
761
  // fetch that turns out not to have a deep link after all.
@@ -804,6 +885,7 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${checklistBlock}${p
804
885
  // other enforce* functions above.
805
886
  parsed.checklist_source = checklistSource;
806
887
  parsed._meta = buildMeta(data.timings, data.usage);
888
+ parsed._meta.scoring_fetch = findScoringFetch(fetchCallDetails);
807
889
  // Same "server-side, not just prompt instruction" policy as the rest of
808
890
  // this file: a past_decision_signal is only trusted when this call
809
891
  // actually had past-decision context to consider. Strips a fabricated
@@ -1007,6 +1089,12 @@ function recordDecision(input) {
1007
1089
  action: input.action,
1008
1090
  source: input.source,
1009
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,
1010
1098
  };
1011
1099
  const memory = readMemory();
1012
1100
  const existing = memory[input.project_id] ?? [];
@@ -1022,6 +1110,93 @@ export function getPastDecisions(projectId) {
1022
1110
  const memory = readMemory();
1023
1111
  return memory[projectId] ?? [];
1024
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
+ }
1025
1200
  // Orchestrates the ensemble: run once, and only pay for 2 more full
1026
1201
  // pipeline passes when the single-run result landed close enough to a
1027
1202
  // verdict threshold that a single item's judgment swinging could flip
@@ -1053,7 +1228,75 @@ function aggregateMeta(passes) {
1053
1228
  estimated_cost_usd: Math.round(metas.reduce((sum, m) => sum + m.estimated_cost_usd, 0) * 10000) / 10000,
1054
1229
  };
1055
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
+ }
1056
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
+ }
1057
1300
  // Session cap and local logging both apply only to calls that actually
1058
1301
  // reach the API -- skip-list hits never do, so both are excluded here
1059
1302
  // on the same condition rather than counted/logged and refunded.
@@ -1075,6 +1318,9 @@ async function judgeComponent(input) {
1075
1318
  first.result.ensemble = { triggered: false };
1076
1319
  if (reachesApi)
1077
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
+ }
1078
1324
  return JSON.stringify(first.result);
1079
1325
  }
1080
1326
  console.error(JSON.stringify({
@@ -1111,7 +1357,16 @@ async function judgeComponent(input) {
1111
1357
  if (majorityCount < passes.length)
1112
1358
  base.confidence = "low";
1113
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;
1114
1367
  base._meta = aggregateMeta(passes) ?? base._meta;
1368
+ if (base._meta)
1369
+ base._meta.scoring_fetch = winningScoringFetch;
1115
1370
  console.error(JSON.stringify({
1116
1371
  diagnostic: "ensemble_decision",
1117
1372
  runs: verdicts,
@@ -1123,6 +1378,9 @@ async function judgeComponent(input) {
1123
1378
  // reachable for calls that passed the skip-list check above -- always
1124
1379
  // reachesApi === true here, no guard needed.
1125
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
+ }
1126
1384
  return JSON.stringify(base);
1127
1385
  }
1128
1386
  // The model's stated `coverage` string doesn't always match its own
@@ -1177,6 +1435,38 @@ export function parseCoveragePercent(coverage) {
1177
1435
  }
1178
1436
  return null;
1179
1437
  }
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
+ }
1180
1470
  export function enforceVerdictThreshold(parsed) {
1181
1471
  if (parsed.reason !== "scored")
1182
1472
  return;
@@ -1275,6 +1565,20 @@ export const DOMAIN_FOR_SOURCE_KEYWORD = {
1275
1565
  mobbin: "mobbin.com",
1276
1566
  figma: "figma.com",
1277
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
+ }
1278
1582
  // Figma Community's own URL structure makes a "/community/file/<id>/<slug>"
1279
1583
  // URL inherently specific to one file -- unlike Mobbin's "/explore/..."
1280
1584
  // category pages, there's no browse-vs-specific gap to resolve here.
@@ -1438,7 +1742,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1438
1742
  "Omit it to keep today's default behavior unchanged. The response " +
1439
1743
  "always includes checklist_source ('extracted' | 'provided') and " +
1440
1744
  "an internal _meta block (timing/token/cost accounting) -- neither " +
1441
- "affects the verdict itself.",
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.",
1442
1750
  inputSchema: INPUT_SCHEMA,
1443
1751
  },
1444
1752
  {
@@ -1454,7 +1762,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1454
1762
  "heuristic based on how specific component_need is, not a " +
1455
1763
  "calibrated signal -- treat 'low' as a hint to reread the input, " +
1456
1764
  "not a hard error. Cheaper and faster than recommend_component " +
1457
- "since it makes no search calls at all.",
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.",
1458
1768
  inputSchema: EXTRACT_REQUIREMENTS_INPUT_SCHEMA,
1459
1769
  },
1460
1770
  {
@@ -1467,9 +1777,27 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1467
1777
  "calls with the same project_id will see this decision as a " +
1468
1778
  "consistency signal, not a binding rule. Use a stable project_id " +
1469
1779
  "(e.g. the project's directory path or name) so decisions are " +
1470
- "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.",
1471
1784
  inputSchema: RECORD_DECISION_INPUT_SCHEMA,
1472
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
+ },
1473
1801
  ],
1474
1802
  }));
1475
1803
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -1538,13 +1866,37 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1538
1866
  };
1539
1867
  }
1540
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
+ }
1541
1885
  throw new Error(`Unknown tool: ${request.params.name}`);
1542
1886
  });
1543
1887
  async function main() {
1544
1888
  const transport = new StdioServerTransport();
1545
1889
  await server.connect(transport);
1546
1890
  }
1547
- main().catch((err) => {
1548
- console.error("Fatal error starting pattern-mcp:", err);
1549
- process.exit(1);
1550
- });
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pattern-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "MCP tool that judges whether a UI component need should be met with an existing shadcn/ui, 21st.dev, or ReUI component or requires a custom build, using field/requirement coverage scored against real component code.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",