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.
- package/README.md +227 -27
- package/dist/index.js +382 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/pattern-mcp)
|
|
6
6
|
[](./LICENSE)
|
|
7
7
|
|
|
8
|
-
Pattern is an MCP server that
|
|
9
|
-
|
|
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
|
|
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
|
|
672
|
-
A previous decision is only additional context
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
###
|
|
774
|
-
|
|
775
|
-
Pattern allows up to
|
|
776
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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).
|
|
371
|
-
"
|
|
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.
|
|
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
|
|
596
|
-
// circuits search/scoring or gets
|
|
597
|
-
// project_id -> no lookup at all, not a
|
|
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
|
-
//
|
|
672
|
-
//
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
|
|
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
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
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.
|
|
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",
|