@coppica/persuasion-mcp 1.0.1
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/LICENSE +21 -0
- package/README.md +108 -0
- package/data/LICENSE.md +8 -0
- package/data/catalog.json +1 -0
- package/data/categories.json +1 -0
- package/data/trait-evidence.json +187 -0
- package/dist/data.d.ts +81 -0
- package/dist/data.js +207 -0
- package/dist/format.d.ts +28 -0
- package/dist/format.js +50 -0
- package/dist/hookcheck/index.d.ts +98 -0
- package/dist/hookcheck/index.js +294 -0
- package/dist/http.d.ts +12 -0
- package/dist/http.js +62 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/dist/roles.d.ts +68 -0
- package/dist/roles.js +188 -0
- package/dist/schemas.d.ts +7 -0
- package/dist/schemas.js +13 -0
- package/dist/scorers/claim-framework.d.ts +52 -0
- package/dist/scorers/claim-framework.js +363 -0
- package/dist/scorers/flow-framework.d.ts +69 -0
- package/dist/scorers/flow-framework.js +708 -0
- package/dist/scorers/hook-framework.d.ts +58 -0
- package/dist/scorers/hook-framework.js +447 -0
- package/dist/scorers/string-utils.d.ts +1 -0
- package/dist/scorers/string-utils.js +10 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +71 -0
- package/dist/stats.d.ts +22 -0
- package/dist/stats.js +112 -0
- package/dist/tools/catalog.d.ts +2 -0
- package/dist/tools/catalog.js +84 -0
- package/dist/tools/craft.d.ts +2 -0
- package/dist/tools/craft.js +129 -0
- package/dist/tools/diagnose.d.ts +5 -0
- package/dist/tools/diagnose.js +269 -0
- package/dist/tools/plan.d.ts +4 -0
- package/dist/tools/plan.js +99 -0
- package/dist/tools/testing.d.ts +2 -0
- package/dist/tools/testing.js +145 -0
- package/dist/tools/why.d.ts +2 -0
- package/dist/tools/why.js +63 -0
- package/dist/voice.d.ts +33 -0
- package/dist/voice.js +139 -0
- package/package.json +73 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HOOK CHECK: the replacement for scoreHook (see docs/hook-check.md).
|
|
3
|
+
*
|
|
4
|
+
* Asks three things of an opening line: does it do its job in this slot (which of the nine reader
|
|
5
|
+
* questions it answers), could a competitor send it unchanged (swap test against the brief), and
|
|
6
|
+
* what is it a bet on. Reports hard facts and replicated evidence instead of a 0-10 score.
|
|
7
|
+
*
|
|
8
|
+
* Pure module: no I/O, no AI calls. The calling model supplies the labels (which question the
|
|
9
|
+
* line answers, quoting the words); this code verifies them and does the rest.
|
|
10
|
+
*
|
|
11
|
+
* Every message here is read by a model and echoed to a writer, so each one is a full, plain
|
|
12
|
+
* sentence. The fix logic keys off flags, never off the wording, so the wording can change freely.
|
|
13
|
+
*/
|
|
14
|
+
import { ROLES } from '../roles.js';
|
|
15
|
+
import { detectAISmell } from '../scorers/hook-framework.js';
|
|
16
|
+
import { smellSentences, joinList, endSentence } from '../voice.js';
|
|
17
|
+
import { safeSlice } from '../scorers/string-utils.js';
|
|
18
|
+
// ── slots (reviewed 2026-10-04) ──────────────────────────────────────────────
|
|
19
|
+
export const SLOTS = ['email_subject', 'ad_opening', 'landing_page_headline', 'first_line', 'video_hook', 'social_post_open', 'sales_letter_headline'];
|
|
20
|
+
/** label takes an article ("an email subject line"); job completes "Its job is to ..."; visibleNote completes "Keep in mind that ...". */
|
|
21
|
+
export const SLOT_INFO = {
|
|
22
|
+
email_subject: { label: 'email subject line', job: 'earn the open', requires: [['DISRUPT', 'COMPEL'], ['IDENTIFY']], visibleChars: 40, visibleNote: 'only about 40 characters show in a mobile inbox', headlineLike: true },
|
|
23
|
+
ad_opening: { label: 'ad opening', job: 'stop the scroll, flag the right reader and give them a reason to click', requires: [['DISRUPT'], ['IDENTIFY'], ['AGITATE', 'ELEVATE', 'COMPEL']], visibleChars: 125, visibleNote: 'only about 125 characters show before "See more"', headlineLike: true },
|
|
24
|
+
landing_page_headline: { label: 'landing page headline', job: 'promise the outcome to the right reader, carrying on from whatever brought them here', requires: [['ELEVATE', 'AGITATE', 'REFRAME'], ['IDENTIFY']], maxWords: 14, headlineLike: true },
|
|
25
|
+
first_line: { label: 'first line of the body', job: 'pay off the headline and earn the second line', requires: [['DISRUPT', 'IDENTIFY', 'AGITATE', 'REFRAME', 'PROVE', 'ELEVATE', 'BOND']], headlineLike: false },
|
|
26
|
+
video_hook: { label: 'video hook', job: 'stop the scroll in the first three seconds', requires: [['DISRUPT'], ['IDENTIFY']], maxWords: 9, spoken: true, headlineLike: false },
|
|
27
|
+
social_post_open: { label: 'social post opening', job: 'earn the tap on "see more"', requires: [['DISRUPT'], ['IDENTIFY']], visibleChars: 140, visibleNote: 'only about 140 characters show before "see more" on a phone', headlineLike: true },
|
|
28
|
+
sales_letter_headline: { label: 'sales letter headline', job: 'stop the right reader and promise the big idea', requires: [['DISRUPT', 'ELEVATE', 'AGITATE'], ['IDENTIFY']], headlineLike: true },
|
|
29
|
+
};
|
|
30
|
+
export const article = (s) => (/^[aeiou]/i.test(s) ? `an ${s}` : `a ${s}`);
|
|
31
|
+
function requirements(slot, awareness) {
|
|
32
|
+
const req = SLOT_INFO[slot].requires.map((g) => [...g]);
|
|
33
|
+
if (awareness === 'most_aware')
|
|
34
|
+
for (const g of req)
|
|
35
|
+
if (g.includes('DISRUPT') && !g.includes('COMPEL'))
|
|
36
|
+
g.push('COMPEL'); // the offer is the hook
|
|
37
|
+
return req;
|
|
38
|
+
}
|
|
39
|
+
/** What a set of requirement groups asks for, in words: '"A?" and "B?", plus one of "C?", "D?" or "E?"' */
|
|
40
|
+
export function questionsNeeded(groups) {
|
|
41
|
+
const q = (r) => `"${ROLES[r].question}"`;
|
|
42
|
+
const singles = groups.filter((g) => g.length === 1).map((g) => q(g[0]));
|
|
43
|
+
const choices = groups.filter((g) => g.length > 1).map((g) => `${g.length === 2 ? 'either' : 'one of'} ${joinList(g.map(q), 'or')}`);
|
|
44
|
+
if (!singles.length)
|
|
45
|
+
return joinList(choices);
|
|
46
|
+
return choices.length ? `${joinList(singles)}, plus ${joinList(choices)}` : joinList(singles);
|
|
47
|
+
}
|
|
48
|
+
// ── evidence: traits that replicated in all three Upworthy splits (data/trait-evidence.json) ──
|
|
49
|
+
const EVIDENCE_SOURCE = 'The evidence comes from the Upworthy Research Archive, thousands of real headline tests run on news stories between 2013 and 2015, ' +
|
|
50
|
+
'each one measuring clicks with the image held the same. The effects are small, and they come from news headlines and not ads, so treat them as a nudge and never as a verdict.';
|
|
51
|
+
const TRAIT_EVIDENCE = [
|
|
52
|
+
{ name: 'question', test: (s) => s.includes('?'), text: 'Headlines phrased as a question won only 39% of 4,217 real head-to-head tests, so consider saying it as a statement.', good: false },
|
|
53
|
+
{ name: 'exclamation', test: (s) => s.includes('!'), text: 'Headlines with an exclamation mark won only 40% of 761 tests.', good: false },
|
|
54
|
+
{ name: 'emphasis_caps', test: (s) => /\b[A-Z]{3,}\b|\*\w+\*/.test(s), text: 'Headlines with a word in CAPS or *emphasis* won only 42% of 1,776 tests.', good: false },
|
|
55
|
+
{ name: 'number', test: (s) => /\d/.test(s.replace(/#\s?1\b|\bno\.?\s?1\b/gi, '')), text: 'Headlines with a number in them won 54% of 3,316 tests.', good: true },
|
|
56
|
+
{ name: 'this_these', test: (s) => /\b(this|these|here's|here is)\b/i.test(s), text: 'Headlines that point at something with "this", "these" or "here\'s" won 56% of 5,522 tests.', good: true },
|
|
57
|
+
{ name: 'time_word', test: (s) => /\b(day|days|week|weeks|month|months|year|years|minute|minutes|hour|hours|today)\b/i.test(s), text: 'Headlines with a time word, like "days" or "today", won 54% of 2,158 tests.', good: true },
|
|
58
|
+
];
|
|
59
|
+
// ── text helpers ─────────────────────────────────────────────────────────────
|
|
60
|
+
const STOP = new Set('a an the and or of to in on for with by is are be as at it its from their they them who will was were has have had his her he she we i me so if but not no do does did just about into than up out off over can all one my our your you yours this that these those what when why how there here'.split(' '));
|
|
61
|
+
/** Marketing-generic words: never count as "only you could say this". */
|
|
62
|
+
const GENERIC = new Set(('sale sales deal deals offer offers discount discounts save saving savings percent best great good amazing awesome incredible new now today tonight ' +
|
|
63
|
+
'free shop buy get order product products service services solution solutions platform tool tools app quality premium easy simple fast quick ' +
|
|
64
|
+
'year years day days week weeks month months time limited exclusive biggest ultimate everything more miss dont don black friday cyber monday holiday season special big huge top ' +
|
|
65
|
+
'number help helps make makes need want find start try learn discover join click link finally really actually only every everyone people customer customers ' +
|
|
66
|
+
'business businesses company team teams work life world way ways thing things result results better worse love perfect proven guaranteed').split(' '));
|
|
67
|
+
const norm = (s) => s.toLowerCase().replace(/[‘’]/g, "'").replace(/[“”]/g, '"').replace(/\s+/g, ' ').trim();
|
|
68
|
+
function contentTokens(s) {
|
|
69
|
+
return (norm(s).match(/[a-z][a-z']+/g) ?? [])
|
|
70
|
+
.map((w) => w.replace(/'s$/, '').replace(/'/g, ''))
|
|
71
|
+
.filter((w) => w.length > 2 && !STOP.has(w))
|
|
72
|
+
.map((w) => (w.length > 4 && w.endsWith('ies') ? w.slice(0, -3) + 'y' : w.length > 4 && w.endsWith('s') && !w.endsWith('ss') ? w.slice(0, -1) : w));
|
|
73
|
+
}
|
|
74
|
+
const specificTokens = (s) => new Set(contentTokens(s).filter((w) => !GENERIC.has(w)));
|
|
75
|
+
/** normalized token -> the word as written, for readable output */
|
|
76
|
+
function surfaceWords(s) {
|
|
77
|
+
const m = new Map();
|
|
78
|
+
for (const raw of s.match(/[A-Za-z][A-Za-z'’]+/g) ?? []) {
|
|
79
|
+
const [t] = contentTokens(raw);
|
|
80
|
+
if (t && !m.has(t))
|
|
81
|
+
m.set(t, raw);
|
|
82
|
+
}
|
|
83
|
+
return m;
|
|
84
|
+
}
|
|
85
|
+
const numbersIn = (s) => new Set((s.match(/\d[\d,.]*/g) ?? []).map((n) => n.replace(/[,.]$/, '').replace(/,/g, '')));
|
|
86
|
+
const quoted = (ws) => joinList(ws.map((w) => `"${w}"`));
|
|
87
|
+
// ── swap test ────────────────────────────────────────────────────────────────
|
|
88
|
+
/** Brand and product names: the explicit brand, plus capitalized words inside the product
|
|
89
|
+
* description that are not its first word (a first word may just be sentence case). */
|
|
90
|
+
function brandTokens(product, brand) {
|
|
91
|
+
const out = new Set(brand ? contentTokens(brand) : []);
|
|
92
|
+
const words = product.match(/[A-Za-z][A-Za-z'’]+/g) ?? [];
|
|
93
|
+
words.forEach((w, i) => { if (/^[A-Z]/.test(w) && (i > 0 || /[a-z][A-Z]/.test(w) || product.trim().split(/\s+/).length <= 3))
|
|
94
|
+
for (const t of contentTokens(w))
|
|
95
|
+
out.add(t); });
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The swap test (Thesis II): could a competitor send this line unchanged?
|
|
100
|
+
* only_you: names the brand, or a fact from your proof. on_target: names this reader's situation or the
|
|
101
|
+
* product's specifics (a direct competitor could match it). concrete_but_swappable: only a number or offer
|
|
102
|
+
* term. generic: nothing specific. unknown: no brief.
|
|
103
|
+
*/
|
|
104
|
+
export function swapTest(text, brief, context) {
|
|
105
|
+
const line = specificTokens(text);
|
|
106
|
+
const lineNums = numbersIn(text.replace(/#\s?1\b/g, ''));
|
|
107
|
+
const surface = surfaceWords(text);
|
|
108
|
+
const show = (ws) => ws.map((w) => surface.get(w) ?? w);
|
|
109
|
+
const hasBrief = !!brief && Object.values(brief).some((v) => v && v.trim());
|
|
110
|
+
if (hasBrief) {
|
|
111
|
+
const brand = brandTokens(brief.product ?? '', brief.brand);
|
|
112
|
+
const proof = specificTokens(brief.proof ?? '');
|
|
113
|
+
const proofNums = numbersIn(brief.proof ?? '');
|
|
114
|
+
const target = specificTokens(`${brief.brand ?? ''} ${brief.product ?? ''} ${brief.audience ?? ''}`);
|
|
115
|
+
const offer = specificTokens(brief.offer ?? '');
|
|
116
|
+
const mine = [...show([...line].filter((w) => brand.has(w) || proof.has(w))), ...[...lineNums].filter((n) => proofNums.has(n))];
|
|
117
|
+
if (mine.length)
|
|
118
|
+
return { level: 'only_you', specifics: mine, note: `No competitor could send it, because it names ${quoted(mine)}.` };
|
|
119
|
+
const onTarget = show([...line].filter((w) => target.has(w)));
|
|
120
|
+
if (onTarget.length)
|
|
121
|
+
return { level: 'on_target', specifics: onTarget, note: `It speaks to this reader and this offer with ${quoted(onTarget)}, but a direct competitor could still send it. Add the brand name or a fact from your proof and it becomes yours alone.` };
|
|
122
|
+
const offerHits = show([...line].filter((w) => offer.has(w)));
|
|
123
|
+
if (lineNums.size || offerHits.length) {
|
|
124
|
+
return { level: 'concrete_but_swappable', specifics: [...lineNums, ...offerHits], note: "It's concrete, but any competitor with the same offer could send it word for word. Nothing in it names this product, this reader or your proof." };
|
|
125
|
+
}
|
|
126
|
+
return { level: 'generic', specifics: [], note: 'Any competitor could send this word for word, because nothing in it belongs to this product, this reader, this offer or your proof.' };
|
|
127
|
+
}
|
|
128
|
+
if (context && context.trim()) {
|
|
129
|
+
const ctx = specificTokens(context);
|
|
130
|
+
const shared = show([...line].filter((w) => ctx.has(w)));
|
|
131
|
+
return { level: 'unknown', specifics: shared, note: shared.length
|
|
132
|
+
? `It shares ${quoted(shared.slice(0, 4))} with the rest of the copy. Pass the brief, meaning the product, audience, offer and proof, and the swap test can tell you whether a competitor could send it.`
|
|
133
|
+
: 'It shares nothing specific with the rest of the copy. Pass the brief, meaning the product, audience, offer and proof, so the swap test can run.' };
|
|
134
|
+
}
|
|
135
|
+
return { level: 'unknown', specifics: [], note: "The swap test didn't run because there's no brief. Pass the product, audience, offer and proof." };
|
|
136
|
+
}
|
|
137
|
+
/** Completes 'Answer "<question>" by ...' */
|
|
138
|
+
const ROLE_FIX = {
|
|
139
|
+
DISRUPT: "adding one surprising, specific detail, or a tension they didn't see coming",
|
|
140
|
+
IDENTIFY: 'naming the reader or something that is theirs, like "your boots" or "bakery owners"',
|
|
141
|
+
AGITATE: 'naming what the problem is costing them',
|
|
142
|
+
REFRAME: "showing them the problem from an angle they haven't considered",
|
|
143
|
+
PROVE: 'putting the proof right next to the claim',
|
|
144
|
+
ELEVATE: 'showing them, specifically, what life looks like after',
|
|
145
|
+
RESOLVE: "removing the main reason they'd hesitate",
|
|
146
|
+
COMPEL: 'giving them an honest reason to act now, like the offer or the deadline',
|
|
147
|
+
BOND: "sounding like a specific person they'd trust",
|
|
148
|
+
};
|
|
149
|
+
const SUPERLATIVE = /(#\s?1\b|\bnumber one\b|\bthe best\b|\bbest[- ]in[- ]class\b|\bleading\b|\bworld'?s (best|first|most|leading)\b|\btop[- ]rated\b|\bunmatched\b|\brevolutionary\b|\bbiggest\b|\bultimate\b|\bgreatest\b)/i;
|
|
150
|
+
const BACKED = /\b(according to|rated by|ranked by|verified|audited|source:|survey|study)\b|\d[\d,.]*\s?(%|customers|users|reviews|stars|brands|teams)/i;
|
|
151
|
+
const RESULT_CLAIM = /\b(\d+x|doubles?|triples?|increases?|boosts?|cuts?|saves?|earns?|grows?|loses?|gains?)\b/i;
|
|
152
|
+
// ── the check ────────────────────────────────────────────────────────────────
|
|
153
|
+
export function checkHook(input) {
|
|
154
|
+
const { text, slot } = input;
|
|
155
|
+
const info = SLOT_INFO[slot];
|
|
156
|
+
const needs = requirements(slot, input.awareness);
|
|
157
|
+
const nText = norm(text);
|
|
158
|
+
const hardFails = [];
|
|
159
|
+
const warnings = [];
|
|
160
|
+
const believability = [];
|
|
161
|
+
// 1. the job: verify each label's quote is really in the line (and visible)
|
|
162
|
+
const verified = [];
|
|
163
|
+
const unverified = [];
|
|
164
|
+
const cutoff = info.visibleChars;
|
|
165
|
+
const cpLen = Array.from(text).length;
|
|
166
|
+
let cutFail = false;
|
|
167
|
+
for (const a of input.answers) {
|
|
168
|
+
const w = norm(a.words);
|
|
169
|
+
const at = w ? nText.indexOf(w) : -1;
|
|
170
|
+
if (at < 0) {
|
|
171
|
+
unverified.push(a);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (cutoff && cpLen > cutoff && at + w.length > cutoff) {
|
|
175
|
+
cutFail = true;
|
|
176
|
+
hardFails.push(`The words that answer "${ROLES[a.question].question}" ("${a.words}") fall past the cutoff, and since ${info.visibleNote}, most readers never see them.`);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
verified.push(a);
|
|
180
|
+
}
|
|
181
|
+
const answered = [...new Set(verified.map((a) => a.question))];
|
|
182
|
+
const missing = needs.filter((g) => !g.some((r) => answered.includes(r)));
|
|
183
|
+
// 2. swap test
|
|
184
|
+
const swap = swapTest(text, input.brief, input.context);
|
|
185
|
+
// 3. hard facts
|
|
186
|
+
let visible = null;
|
|
187
|
+
if (cutoff)
|
|
188
|
+
visible = cpLen > cutoff ? Array.from(text).slice(0, cutoff).join('') + '…' : text;
|
|
189
|
+
if (cutoff && cpLen > cutoff && !cutFail) {
|
|
190
|
+
const w = `It runs past the cutoff, and since ${info.visibleNote}, readers see only "${visible}". Make sure the point lands before that.`;
|
|
191
|
+
warnings.push(w);
|
|
192
|
+
believability.push(w);
|
|
193
|
+
}
|
|
194
|
+
const words = text.trim().split(/\s+/).filter(Boolean).length;
|
|
195
|
+
if (info.spoken && words > (info.maxWords ?? 9))
|
|
196
|
+
warnings.push(`At ${words} words it takes about ${(words / 2.7).toFixed(1)} seconds to say. The hook has to land in the first three seconds, which is about ${info.maxWords} words.`);
|
|
197
|
+
else if (info.maxWords && words > info.maxWords)
|
|
198
|
+
warnings.push(`It runs ${words} words. Headlines in this spot usually land in ${info.maxWords} or fewer.`);
|
|
199
|
+
const smell = smellSentences(detectAISmell(text).violations);
|
|
200
|
+
hardFails.push(...smell);
|
|
201
|
+
const superlative = SUPERLATIVE.test(text) && !BACKED.test(text);
|
|
202
|
+
const bareResult = RESULT_CLAIM.test(text) && !/\d/.test(text) && !BACKED.test(text);
|
|
203
|
+
if (superlative) {
|
|
204
|
+
const w = 'It makes a superlative claim, like "best", "#1" or "biggest", with no proof in the same line, and readers discount those on sight.';
|
|
205
|
+
warnings.push(w);
|
|
206
|
+
believability.push(w);
|
|
207
|
+
}
|
|
208
|
+
if (bareResult) {
|
|
209
|
+
const w = 'It claims a result without a number, a source or a quote to back it up.';
|
|
210
|
+
warnings.push(w);
|
|
211
|
+
believability.push(w);
|
|
212
|
+
}
|
|
213
|
+
if (input.follows && input.follows.trim()) {
|
|
214
|
+
const prev = specificTokens(input.follows);
|
|
215
|
+
const shared = [...specificTokens(text)].filter((w) => prev.has(w));
|
|
216
|
+
if (!shared.length && (slot === 'first_line' || slot === 'landing_page_headline')) {
|
|
217
|
+
warnings.push(`It picks up nothing specific from what the reader just saw ("${safeSlice(input.follows, 80)}"). Readers feel that break, and that is when they leave.`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// 4. evidence (headline-like slots only)
|
|
221
|
+
const traits = info.headlineLike ? TRAIT_EVIDENCE.filter((t) => t.test(text)) : [];
|
|
222
|
+
const evidence = traits.map((t) => t.text);
|
|
223
|
+
const evidenceAgainst = traits.filter((t) => !t.good).map((t) => t.text);
|
|
224
|
+
// verdict
|
|
225
|
+
const labeled = input.answers.length > 0;
|
|
226
|
+
const jobMet = labeled && missing.length === 0;
|
|
227
|
+
// Unproven claims cannot pass: they are what readers discount first.
|
|
228
|
+
const unbacked = superlative || bareResult;
|
|
229
|
+
let verdict;
|
|
230
|
+
if (hardFails.length)
|
|
231
|
+
verdict = jobMet ? 'partly' : 'doesnt';
|
|
232
|
+
// Passing needs the swap test to have actually run (a brief) and found something specific.
|
|
233
|
+
else if (jobMet && !unbacked && (swap.level === 'only_you' || swap.level === 'on_target'))
|
|
234
|
+
verdict = 'does_its_job';
|
|
235
|
+
else if (missing.length === needs.length || (swap.level === 'generic' && !jobMet))
|
|
236
|
+
verdict = 'doesnt'; // answers none of what this slot needs
|
|
237
|
+
else
|
|
238
|
+
verdict = 'partly';
|
|
239
|
+
// The swap test gets its own sentence, so the bet names only the questions.
|
|
240
|
+
const bet = answered.length
|
|
241
|
+
? `It's betting on ${joinList(answered.map((r) => ROLES[r].gloss))}.`
|
|
242
|
+
: "It doesn't answer any of the questions this spot needs.";
|
|
243
|
+
// one best fix, highest-leverage first
|
|
244
|
+
let fix;
|
|
245
|
+
if (cutFail)
|
|
246
|
+
fix = `Move the words that answer the question into the first ${cutoff} characters.`;
|
|
247
|
+
else if (smell.length)
|
|
248
|
+
fix = 'Replace the stock phrasing with something only this product could say.';
|
|
249
|
+
else if (missing.length)
|
|
250
|
+
fix = `Answer "${ROLES[missing[0][0]].question}" by ${ROLE_FIX[missing[0][0]]}.`;
|
|
251
|
+
else if (unbacked)
|
|
252
|
+
fix = 'Back the claim up in the same line, with who ranked it, a number or a source, or swap it for a specific fact a reader could check.';
|
|
253
|
+
else if (swap.level === 'generic' || swap.level === 'concrete_but_swappable')
|
|
254
|
+
fix = "Add something specific, like the reader's situation, the product itself or a proof number.";
|
|
255
|
+
else if (swap.level === 'unknown')
|
|
256
|
+
fix = 'Pass the brief, meaning the brand, product, offer, audience and proof, so the swap test can run.';
|
|
257
|
+
else if (traits.some((t) => t.name === 'question'))
|
|
258
|
+
fix = 'Try it as a statement, since question headlines lose most head-to-head tests.';
|
|
259
|
+
else
|
|
260
|
+
fix = 'Nothing needs fixing before you test it.';
|
|
261
|
+
return { text, slot, verdict, needs, answered, missing, unverified, swap, visible, hardFails, warnings, believability, evidence, evidenceAgainst, bet, fix };
|
|
262
|
+
}
|
|
263
|
+
/** Completes a heading like '"Your bakery posted 3 times..." ___' */
|
|
264
|
+
export const VERDICT_LABEL = { does_its_job: 'does its job', partly: 'partly does its job', doesnt: "doesn't do its job" };
|
|
265
|
+
export { EVIDENCE_SOURCE };
|
|
266
|
+
/** The body of a checked line as one paragraph: the bet, what is missing, the swap test, the problems. */
|
|
267
|
+
export function describeCheck(c, opts = {}) {
|
|
268
|
+
const parts = [c.bet];
|
|
269
|
+
if (c.missing.length)
|
|
270
|
+
parts.push(endSentence(`It still has to answer ${questionsNeeded(c.missing)}`));
|
|
271
|
+
parts.push(c.swap.note, ...c.hardFails, ...c.warnings);
|
|
272
|
+
if (opts.evidence !== false)
|
|
273
|
+
parts.push(...c.evidence);
|
|
274
|
+
return parts.join(' ');
|
|
275
|
+
}
|
|
276
|
+
/** Compare checked lines: drop hard failures, describe each bet, recommend a test. */
|
|
277
|
+
export function compareHooks(checks, names) {
|
|
278
|
+
const good = checks.map((c, i) => ({ c, n: names[i] })).filter((x) => x.c.verdict === 'does_its_job');
|
|
279
|
+
const betKey = (c) => c.answered.slice().sort().join('+');
|
|
280
|
+
const betOn = (c) => c.bet.replace(/^It's betting on /, 'is betting on ').replace(/\.$/, '');
|
|
281
|
+
if (good.length >= 2) {
|
|
282
|
+
const [a, ...rest] = good;
|
|
283
|
+
const b = rest.find((x) => betKey(x.c) !== betKey(a.c));
|
|
284
|
+
if (b)
|
|
285
|
+
return `Test ${a.n} against ${b.n}. They make different bets. ${a.n} ${betOn(a.c)}, and ${b.n} ${betOn(b.c)}. Whichever wins tells you which question matters more to this reader.`;
|
|
286
|
+
return `${joinList(good.map((x) => x.n))} make the same bet, so testing one against the other won't teach you much. Keep ${a.n}, and write a challenger that answers a different question.`;
|
|
287
|
+
}
|
|
288
|
+
if (good.length === 1)
|
|
289
|
+
return `Use ${good[0].n}. If you want to learn something, test it against a line that answers a different question.`;
|
|
290
|
+
const partly = checks.map((c, i) => ({ c, n: names[i] })).filter((x) => x.c.verdict === 'partly');
|
|
291
|
+
if (partly.length)
|
|
292
|
+
return `None of these does its job yet. ${joinList(partly.map((x) => x.n))} ${partly.length === 1 ? 'comes' : 'come'} closest, so apply the fix below and check again.`;
|
|
293
|
+
return 'None of these does its job yet. Start with the fixes below.';
|
|
294
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** What a JSON-RPC body asks for, without its arguments, so it is safe to log. */
|
|
2
|
+
export declare function describeRequest(body: unknown): {
|
|
3
|
+
method: string;
|
|
4
|
+
tool?: string;
|
|
5
|
+
}[];
|
|
6
|
+
/**
|
|
7
|
+
* Answer one MCP request. Pass `parsedBody` if the caller already read the body (to log it, say),
|
|
8
|
+
* since a Request body can only be read once.
|
|
9
|
+
*/
|
|
10
|
+
export declare function handleMcpRequest(request: Request, opts?: {
|
|
11
|
+
parsedBody?: unknown;
|
|
12
|
+
}): Promise<Response>;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hosted endpoint: stateless Streamable HTTP, built on web-standard Request and Response so it
|
|
3
|
+
* runs anywhere that speaks fetch (a Next.js route, a Vercel or Cloudflare function, Node 18+).
|
|
4
|
+
*
|
|
5
|
+
* Every request gets its own server and transport. There is no session to keep, and data.ts loads
|
|
6
|
+
* the catalog once per process, so a fresh server costs only the tool registration. Responses are
|
|
7
|
+
* plain JSON, not an SSE stream, because every tool answers in one shot.
|
|
8
|
+
*/
|
|
9
|
+
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
|
10
|
+
import { buildServer } from './server.js';
|
|
11
|
+
const CORS = {
|
|
12
|
+
'Access-Control-Allow-Origin': '*',
|
|
13
|
+
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
|
14
|
+
'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID',
|
|
15
|
+
'Access-Control-Expose-Headers': 'Mcp-Session-Id, Mcp-Protocol-Version',
|
|
16
|
+
};
|
|
17
|
+
function withCors(res) {
|
|
18
|
+
const headers = new Headers(res.headers);
|
|
19
|
+
for (const [k, v] of Object.entries(CORS))
|
|
20
|
+
headers.set(k, v);
|
|
21
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
22
|
+
}
|
|
23
|
+
const ABOUT = `This is the Persuasion Taxonomy MCP server, from Coppica. It's free and needs no key.
|
|
24
|
+
|
|
25
|
+
To use it, add this URL to any MCP client as a remote server (Streamable HTTP):
|
|
26
|
+
https://taxonomy.coppica.com/mcp
|
|
27
|
+
|
|
28
|
+
Setup for Claude, ChatGPT, Cursor and others: https://github.com/Otha-Labs/persuasion-mcp
|
|
29
|
+
`;
|
|
30
|
+
/** What a JSON-RPC body asks for, without its arguments, so it is safe to log. */
|
|
31
|
+
export function describeRequest(body) {
|
|
32
|
+
const items = Array.isArray(body) ? body : [body];
|
|
33
|
+
return items.flatMap((m) => {
|
|
34
|
+
if (!m || typeof m !== 'object' || typeof m.method !== 'string')
|
|
35
|
+
return [];
|
|
36
|
+
const { method, params } = m;
|
|
37
|
+
return [{ method, ...(method === 'tools/call' && typeof params?.name === 'string' ? { tool: params.name } : {}) }];
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Answer one MCP request. Pass `parsedBody` if the caller already read the body (to log it, say),
|
|
42
|
+
* since a Request body can only be read once.
|
|
43
|
+
*/
|
|
44
|
+
export async function handleMcpRequest(request, opts = {}) {
|
|
45
|
+
if (request.method === 'OPTIONS')
|
|
46
|
+
return new Response(null, { status: 204, headers: CORS });
|
|
47
|
+
if (request.method !== 'POST') {
|
|
48
|
+
// Stateless server: no stream to resume and no session to end. A browser that opens the URL
|
|
49
|
+
// gets a short note on how to connect instead of a protocol error.
|
|
50
|
+
return new Response(ABOUT, { status: 405, headers: { ...CORS, Allow: 'POST, OPTIONS', 'Content-Type': 'text/plain; charset=utf-8' } });
|
|
51
|
+
}
|
|
52
|
+
const server = buildServer();
|
|
53
|
+
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
54
|
+
await server.connect(transport);
|
|
55
|
+
try {
|
|
56
|
+
return withCors(await transport.handleRequest(request, opts));
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
await transport.close().catch(() => { });
|
|
60
|
+
await server.close().catch(() => { });
|
|
61
|
+
}
|
|
62
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/roles.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The nine reader questions (Thesis VIII of The 18 Theses) and the framework
|
|
3
|
+
* tables built on them. Role names, questions and "if missing" lines are copied
|
|
4
|
+
* from persuasion-taxonomy (app/the-nine-roles/roles-data.ts). The glosses, gaps,
|
|
5
|
+
* the click goal, the coherence pairs and the symptom map are written here; the
|
|
6
|
+
* last three were reviewed and approved by Elijah on 2026-10-04.
|
|
7
|
+
*
|
|
8
|
+
* Every string in this file reaches a model, and through it a writer. Keep it in
|
|
9
|
+
* the voice of a working copywriter: plain words, full sentences, no em dashes.
|
|
10
|
+
*/
|
|
11
|
+
export declare const ROLE_NAMES: readonly ["DISRUPT", "IDENTIFY", "AGITATE", "REFRAME", "PROVE", "ELEVATE", "RESOLVE", "COMPEL", "BOND"];
|
|
12
|
+
export type Role = (typeof ROLE_NAMES)[number];
|
|
13
|
+
export interface RoleInfo {
|
|
14
|
+
num: number;
|
|
15
|
+
name: Role;
|
|
16
|
+
question: string;
|
|
17
|
+
ifMissing: string;
|
|
18
|
+
/** Completes "Right now, ..." when the copy leaves this question unanswered. */
|
|
19
|
+
gap: string;
|
|
20
|
+
/** Completes "It's betting on ..." and "This question is about ...". */
|
|
21
|
+
gloss: string;
|
|
22
|
+
}
|
|
23
|
+
export declare const ROLES: Record<Role, RoleInfo>;
|
|
24
|
+
/** The question in quotes, with its role name, for the places a model needs both. */
|
|
25
|
+
export declare const roleLabel: (r: Role) => string;
|
|
26
|
+
export declare const GOALS: readonly ["purchase", "signup", "click", "engagement"];
|
|
27
|
+
export type Goal = (typeof GOALS)[number];
|
|
28
|
+
/** Each requirement is satisfied by ANY of its roles. purchase/signup/engagement
|
|
29
|
+
* mirror the analyzer's sales/lead_gen/nurture EXPECTED_ROLES; click was added here (reviewed 2026-10-04). */
|
|
30
|
+
export declare const GOAL_REQUIREMENTS: Record<Goal, Role[][]>;
|
|
31
|
+
/** "For a signup, ..." */
|
|
32
|
+
export declare const GOAL_NOUN: Record<Goal, string>;
|
|
33
|
+
/** "a landing page that ..." */
|
|
34
|
+
export declare const GOAL_CLAUSE: Record<Goal, string>;
|
|
35
|
+
export declare function expectedRoles(goal: Goal): Set<Role>;
|
|
36
|
+
export declare const FORMATS: readonly ["ad", "landing_page", "sales_letter", "email", "email_subject", "social_post", "video_script", "headline"];
|
|
37
|
+
export type Format = (typeof FORMATS)[number];
|
|
38
|
+
export declare const FORMAT_LABEL: Record<Format, string>;
|
|
39
|
+
/** Corpus channel groups where evidence for a technique counts toward "fits this format". */
|
|
40
|
+
export declare const FORMAT_CHANNELS: Record<Format, string[]>;
|
|
41
|
+
export declare const AWARENESS: readonly ["unaware", "problem_aware", "solution_aware", "product_aware", "most_aware"];
|
|
42
|
+
export type Awareness = (typeof AWARENESS)[number];
|
|
43
|
+
export declare const AWARENESS_WEIGHT: Record<Awareness, {
|
|
44
|
+
heavy: Role[];
|
|
45
|
+
note: string;
|
|
46
|
+
}>;
|
|
47
|
+
/** Coherence pairs (reviewed 2026-10-04). Readers check the nine answers against each other; a mismatch is what
|
|
48
|
+
* they feel as "something's off". These are questions for the calling model to ask. */
|
|
49
|
+
export declare const COHERENCE_CHECKS: {
|
|
50
|
+
roles: [Role, Role];
|
|
51
|
+
check: string;
|
|
52
|
+
}[];
|
|
53
|
+
export declare const SYMPTOMS: readonly ["low_click_through", "high_bounce", "reads_but_no_action", "starts_but_abandons", "leads_dont_buy", "buys_once_no_return", "low_open_rate", "opens_no_clicks"];
|
|
54
|
+
export type Symptom = (typeof SYMPTOMS)[number];
|
|
55
|
+
export interface SymptomInfo {
|
|
56
|
+
/** Completes "Why it isn't converting: ..." */
|
|
57
|
+
label: string;
|
|
58
|
+
/** why + check read as one paragraph: what is going wrong, then how to see it for yourself. */
|
|
59
|
+
suspects: {
|
|
60
|
+
role: Role | 'COHERENCE';
|
|
61
|
+
why: string;
|
|
62
|
+
check: string;
|
|
63
|
+
}[];
|
|
64
|
+
ruleOutFirst: string;
|
|
65
|
+
}
|
|
66
|
+
/** Symptom map (reviewed 2026-10-04), derived from each role's "if missing" consequence. */
|
|
67
|
+
export declare const SYMPTOM_MAP: Record<Symptom, SymptomInfo>;
|
|
68
|
+
export declare function normalizeRole(v: unknown): unknown;
|