@sendinel/mcp-server 1.0.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 +113 -0
- package/build/anthropic-model.d.ts +5 -0
- package/build/anthropic-model.js +6 -0
- package/build/audit.d.ts +21 -0
- package/build/audit.js +38 -0
- package/build/auth.d.ts +10 -0
- package/build/auth.js +57 -0
- package/build/byod-client.d.ts +30 -0
- package/build/byod-client.js +95 -0
- package/build/crypto.d.ts +2 -0
- package/build/crypto.js +27 -0
- package/build/db.d.ts +38 -0
- package/build/db.js +70 -0
- package/build/index.d.ts +1 -0
- package/build/index.js +97 -0
- package/build/lib/action-approvals.d.ts +30 -0
- package/build/lib/action-approvals.js +154 -0
- package/build/lib/schedule.d.ts +36 -0
- package/build/lib/schedule.js +63 -0
- package/build/lib/social-engine/archetypes.d.ts +27 -0
- package/build/lib/social-engine/archetypes.js +160 -0
- package/build/lib/social-engine/index.d.ts +5 -0
- package/build/lib/social-engine/index.js +6 -0
- package/build/lib/social-engine/parse.d.ts +7 -0
- package/build/lib/social-engine/parse.js +83 -0
- package/build/lib/social-engine/platform-constraints.d.ts +27 -0
- package/build/lib/social-engine/platform-constraints.js +70 -0
- package/build/lib/social-engine/prompt-builder.d.ts +13 -0
- package/build/lib/social-engine/prompt-builder.js +80 -0
- package/build/lib/social-engine/types.d.ts +60 -0
- package/build/lib/social-engine/types.js +19 -0
- package/build/lib/url-validation.d.ts +3 -0
- package/build/lib/url-validation.js +51 -0
- package/build/lib/voice.d.ts +32 -0
- package/build/lib/voice.js +140 -0
- package/build/lib/webhook-events.d.ts +15 -0
- package/build/lib/webhook-events.js +120 -0
- package/build/plan-limits.d.ts +8 -0
- package/build/plan-limits.js +9 -0
- package/build/project.d.ts +1 -0
- package/build/project.js +9 -0
- package/build/server.d.ts +18 -0
- package/build/server.js +235 -0
- package/build/tools/ab-testing.d.ts +2 -0
- package/build/tools/ab-testing.js +204 -0
- package/build/tools/advisor.d.ts +23 -0
- package/build/tools/advisor.js +762 -0
- package/build/tools/analytics.d.ts +33 -0
- package/build/tools/analytics.js +1105 -0
- package/build/tools/approvals.d.ts +2 -0
- package/build/tools/approvals.js +32 -0
- package/build/tools/automations.d.ts +2 -0
- package/build/tools/automations.js +344 -0
- package/build/tools/campaigns.d.ts +2 -0
- package/build/tools/campaigns.js +1335 -0
- package/build/tools/compound.d.ts +2 -0
- package/build/tools/compound.js +312 -0
- package/build/tools/contacts.d.ts +2 -0
- package/build/tools/contacts.js +1483 -0
- package/build/tools/content.d.ts +2 -0
- package/build/tools/content.js +68 -0
- package/build/tools/data-proposals.d.ts +2 -0
- package/build/tools/data-proposals.js +155 -0
- package/build/tools/data.d.ts +2 -0
- package/build/tools/data.js +707 -0
- package/build/tools/delivery-ops.d.ts +2 -0
- package/build/tools/delivery-ops.js +387 -0
- package/build/tools/drafts.d.ts +2 -0
- package/build/tools/drafts.js +204 -0
- package/build/tools/forms.d.ts +2 -0
- package/build/tools/forms.js +46 -0
- package/build/tools/gdpr.d.ts +2 -0
- package/build/tools/gdpr.js +61 -0
- package/build/tools/org.d.ts +2 -0
- package/build/tools/org.js +71 -0
- package/build/tools/segments.d.ts +2 -0
- package/build/tools/segments.js +384 -0
- package/build/tools/sites.d.ts +2 -0
- package/build/tools/sites.js +182 -0
- package/build/tools/sms.d.ts +2 -0
- package/build/tools/sms.js +489 -0
- package/build/tools/social-posts.d.ts +2 -0
- package/build/tools/social-posts.js +380 -0
- package/build/tools/templates.d.ts +2 -0
- package/build/tools/templates.js +282 -0
- package/build/tools/warmup.d.ts +2 -0
- package/build/tools/warmup.js +57 -0
- package/build/tools/webhooks.d.ts +2 -0
- package/build/tools/webhooks.js +127 -0
- package/package.json +63 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { db, requestContext } from '../db.js';
|
|
2
|
+
import { getProjectId } from '../project.js';
|
|
3
|
+
import { fireWebhookEvent } from './webhook-events.js';
|
|
4
|
+
/**
|
|
5
|
+
* Action approval tiers:
|
|
6
|
+
*
|
|
7
|
+
* Tier 0 (autonomous) — No approval needed. Reads, low-risk writes.
|
|
8
|
+
* Tier 1 (gated) — Requires dashboard/Slack approval. Sends to real people, bulk ops.
|
|
9
|
+
* Tier 2 (protected) — Requires dashboard approval + typed confirmation. Irreversible data ops.
|
|
10
|
+
*/
|
|
11
|
+
const GATED_ACTIONS = new Set([
|
|
12
|
+
'launch_campaign', // sends real emails
|
|
13
|
+
'create_campaign_with_content', // can activate + send in one call
|
|
14
|
+
'enroll_segment', // bulk enrollment
|
|
15
|
+
'import_subscribers', // bulk data ingestion
|
|
16
|
+
'subscribe_webhook', // outbound data channel
|
|
17
|
+
'send_transactional', // sends a real email
|
|
18
|
+
]);
|
|
19
|
+
const PROTECTED_ACTIONS = new Set([
|
|
20
|
+
'delete_subscriber_data', // GDPR delete — irreversible
|
|
21
|
+
'merge_subscribers', // data consolidation — irreversible
|
|
22
|
+
'delete_segment', // affects campaign targeting
|
|
23
|
+
'identify_inactive', // bulk suppression (when not dry_run)
|
|
24
|
+
'promote_ab_winner', // deletes losing variants
|
|
25
|
+
]);
|
|
26
|
+
export function getApprovalTier(action, params) {
|
|
27
|
+
// CRITICAL: Protected actions ALWAYS require approval, even in autonomous mode.
|
|
28
|
+
// These are irreversible (GDPR delete, merge, etc.) — no opt-out.
|
|
29
|
+
if (PROTECTED_ACTIONS.has(action)) {
|
|
30
|
+
// Exception: identify_inactive in dry_run mode is safe (read-only report)
|
|
31
|
+
if (action === 'identify_inactive' && params?.dry_run !== false)
|
|
32
|
+
return 'autonomous';
|
|
33
|
+
return 'protected';
|
|
34
|
+
}
|
|
35
|
+
// Autonomous mode: user explicitly opted out of approval gates for gated actions
|
|
36
|
+
// (request-scoped under HTTP — env is only trustworthy for single-project stdio)
|
|
37
|
+
const mode = requestContext()?.approvalMode ?? process.env.SENDINEL_APPROVAL_MODE ?? 'standard';
|
|
38
|
+
if (mode === 'autonomous')
|
|
39
|
+
return 'autonomous';
|
|
40
|
+
// Special cases: some gated actions are safe in certain modes
|
|
41
|
+
if (action === 'create_campaign_with_content' && !params?.activate)
|
|
42
|
+
return 'autonomous';
|
|
43
|
+
if (action === 'send_transactional' && params?.category === 'test')
|
|
44
|
+
return 'autonomous';
|
|
45
|
+
if (GATED_ACTIONS.has(action))
|
|
46
|
+
return 'gated';
|
|
47
|
+
return 'autonomous';
|
|
48
|
+
}
|
|
49
|
+
/** Build a human-readable summary of the action for the approval screen */
|
|
50
|
+
export function buildApprovalSummary(action, params) {
|
|
51
|
+
const summaries = {
|
|
52
|
+
launch_campaign: (p) => `Launch campaign "${p.name || p.campaign_name || 'unknown'}" — will send emails to real contacts`,
|
|
53
|
+
create_campaign_with_content: (p) => `Create + activate campaign "${p.name}" with ${p.steps?.length ?? 0} email(s) and enroll contacts`,
|
|
54
|
+
enroll_segment: (p) => `Enroll all contacts from site into campaign ${p.campaign_id}`,
|
|
55
|
+
import_subscribers: (p) => `Import subscribers (up to ${p.contacts?.length ?? 'unknown'} records)`,
|
|
56
|
+
subscribe_webhook: (p) => `Register webhook endpoint: ${p.url} — will receive event data`,
|
|
57
|
+
send_transactional: (p) => `Send transactional email to ${p.to}`,
|
|
58
|
+
delete_subscriber_data: (p) => `GDPR delete subscriber ${p.contact_id} — permanently and irreversibly removes all data`,
|
|
59
|
+
merge_subscribers: (p) => `Merge ${p.source_ids?.length ?? 0} subscribers into ${p.target_id} — source subscribers will be deleted`,
|
|
60
|
+
delete_segment: (p) => `Delete segment ${p.segment_id} — campaigns using this segment lose their targeting`,
|
|
61
|
+
identify_inactive: (p) => `Suppress contacts inactive for ${p.days ?? 90} days — they will stop receiving emails`,
|
|
62
|
+
promote_ab_winner: (p) => `Promote A/B test winner "${p.winner}" — losing variants will be deleted`,
|
|
63
|
+
};
|
|
64
|
+
const fn = summaries[action];
|
|
65
|
+
return fn ? fn(params) : `Execute ${action} with provided parameters`;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Request human approval for an action. Creates a pending approval token
|
|
69
|
+
* and returns instructions for the agent to wait.
|
|
70
|
+
*/
|
|
71
|
+
export async function requestApproval(action, params, apiKeyId) {
|
|
72
|
+
const tier = getApprovalTier(action, params);
|
|
73
|
+
const summary = buildApprovalSummary(action, params);
|
|
74
|
+
const expiresAt = new Date(Date.now() + 30 * 60 * 1000).toISOString();
|
|
75
|
+
const { data, error } = await db()
|
|
76
|
+
.from('action_approvals')
|
|
77
|
+
.insert({
|
|
78
|
+
project_id: getProjectId(),
|
|
79
|
+
api_key_id: apiKeyId ?? null,
|
|
80
|
+
action,
|
|
81
|
+
tier,
|
|
82
|
+
params,
|
|
83
|
+
summary,
|
|
84
|
+
expires_at: expiresAt,
|
|
85
|
+
})
|
|
86
|
+
.select('id')
|
|
87
|
+
.single();
|
|
88
|
+
if (error)
|
|
89
|
+
throw new Error(`Failed to create approval request: ${error.message}`);
|
|
90
|
+
fireWebhookEvent('approval.requested', {
|
|
91
|
+
approval_id: data.id,
|
|
92
|
+
kind: action,
|
|
93
|
+
resource_id: params.resource_id ?? params.campaign_id ?? params.contact_id ?? null,
|
|
94
|
+
requested_by: apiKeyId ?? null,
|
|
95
|
+
}, getProjectId()).catch(() => { });
|
|
96
|
+
return {
|
|
97
|
+
requires_approval: true,
|
|
98
|
+
approval_id: data.id,
|
|
99
|
+
tier,
|
|
100
|
+
action,
|
|
101
|
+
summary,
|
|
102
|
+
expires_at: expiresAt,
|
|
103
|
+
approve_via: 'Dashboard → Pending Approvals, or Slack /sendinel approve',
|
|
104
|
+
message: `This action requires human approval. A notification has been sent to your dashboard. Approval ID: ${data.id}. Check status with: poll this approval_id, or wait for the webhook callback (event: approval.decided). Token expires in 30 minutes.`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Check if an approval token has been approved.
|
|
109
|
+
* Returns the approval if approved, null if still pending, throws if rejected/expired.
|
|
110
|
+
*/
|
|
111
|
+
export async function checkApproval(approvalId, options = {}) {
|
|
112
|
+
const { data, error } = await db()
|
|
113
|
+
.from('action_approvals')
|
|
114
|
+
.select('status, decided_by, decided_via, expires_at, used_at')
|
|
115
|
+
.eq('id', approvalId)
|
|
116
|
+
.eq('project_id', getProjectId())
|
|
117
|
+
.maybeSingle();
|
|
118
|
+
if (error || !data)
|
|
119
|
+
return { approved: false, status: 'not_found' };
|
|
120
|
+
// Check expiry
|
|
121
|
+
if (data.status === 'pending' && new Date(data.expires_at) < new Date()) {
|
|
122
|
+
await db()
|
|
123
|
+
.from('action_approvals')
|
|
124
|
+
.update({ status: 'expired' })
|
|
125
|
+
.eq('id', approvalId);
|
|
126
|
+
return { approved: false, status: 'expired' };
|
|
127
|
+
}
|
|
128
|
+
if (data.status !== 'approved' || !options.consume) {
|
|
129
|
+
if (data.used_at)
|
|
130
|
+
return { approved: false, status: 'consumed' };
|
|
131
|
+
return {
|
|
132
|
+
approved: data.status === 'approved',
|
|
133
|
+
status: data.status,
|
|
134
|
+
decided_by: data.decided_by,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const { data: claimed, error: claimError } = await db()
|
|
138
|
+
.from('action_approvals')
|
|
139
|
+
.update({ status: 'used', used_at: new Date().toISOString() })
|
|
140
|
+
.eq('id', approvalId)
|
|
141
|
+
.eq('project_id', getProjectId())
|
|
142
|
+
.eq('status', 'approved')
|
|
143
|
+
.is('used_at', null)
|
|
144
|
+
.select('decided_by')
|
|
145
|
+
.maybeSingle();
|
|
146
|
+
if (claimError || !claimed) {
|
|
147
|
+
return { approved: false, status: 'consumed' };
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
approved: true,
|
|
151
|
+
status: 'approved',
|
|
152
|
+
decided_by: claimed.decided_by,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export declare const SCHEDULE_WINDOWS: readonly ["today", "tomorrow", "7d", "30d"];
|
|
2
|
+
export type ScheduleWindow = (typeof SCHEDULE_WINDOWS)[number];
|
|
3
|
+
export type ScheduleCampaign = {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
type: string;
|
|
7
|
+
};
|
|
8
|
+
export type ScheduleEnrollmentRow = {
|
|
9
|
+
next_send_at: string | null;
|
|
10
|
+
campaigns: ScheduleCampaign | ScheduleCampaign[] | null;
|
|
11
|
+
};
|
|
12
|
+
export type ScheduleCampaignGroup = {
|
|
13
|
+
campaign_id: string;
|
|
14
|
+
campaign_name: string;
|
|
15
|
+
campaign_type: string;
|
|
16
|
+
total_sends_scheduled: number;
|
|
17
|
+
sends_in_window: {
|
|
18
|
+
date: string;
|
|
19
|
+
total_sends_scheduled: number;
|
|
20
|
+
}[];
|
|
21
|
+
earliest_send: string | null;
|
|
22
|
+
latest_send: string | null;
|
|
23
|
+
};
|
|
24
|
+
export declare function normalizeScheduleWindow(value?: string | null): ScheduleWindow;
|
|
25
|
+
export declare function startOfUtcDay(date: Date): Date;
|
|
26
|
+
export declare function getScheduleWindowBounds(window?: ScheduleWindow, now?: Date): {
|
|
27
|
+
window: "tomorrow";
|
|
28
|
+
start: Date;
|
|
29
|
+
end: Date;
|
|
30
|
+
} | {
|
|
31
|
+
window: "today" | "7d" | "30d";
|
|
32
|
+
start: Date;
|
|
33
|
+
end: Date;
|
|
34
|
+
};
|
|
35
|
+
export declare function isoDate(value: string | Date): string;
|
|
36
|
+
export declare function groupScheduleByCampaign(rows: ScheduleEnrollmentRow[]): ScheduleCampaignGroup[];
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export const SCHEDULE_WINDOWS = ['today', 'tomorrow', '7d', '30d'];
|
|
2
|
+
export function normalizeScheduleWindow(value) {
|
|
3
|
+
return SCHEDULE_WINDOWS.includes(value) ? value : 'today';
|
|
4
|
+
}
|
|
5
|
+
export function startOfUtcDay(date) {
|
|
6
|
+
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
7
|
+
}
|
|
8
|
+
function addUtcDays(date, days) {
|
|
9
|
+
const next = new Date(date);
|
|
10
|
+
next.setUTCDate(next.getUTCDate() + days);
|
|
11
|
+
return next;
|
|
12
|
+
}
|
|
13
|
+
export function getScheduleWindowBounds(window = 'today', now = new Date()) {
|
|
14
|
+
const today = startOfUtcDay(now);
|
|
15
|
+
if (window === 'tomorrow') {
|
|
16
|
+
const start = addUtcDays(today, 1);
|
|
17
|
+
return { window, start, end: addUtcDays(start, 1) };
|
|
18
|
+
}
|
|
19
|
+
const days = window === '7d' ? 7 : window === '30d' ? 30 : 1;
|
|
20
|
+
return { window, start: today, end: addUtcDays(today, days) };
|
|
21
|
+
}
|
|
22
|
+
export function isoDate(value) {
|
|
23
|
+
return new Date(value).toISOString().slice(0, 10);
|
|
24
|
+
}
|
|
25
|
+
function getCampaign(row) {
|
|
26
|
+
if (Array.isArray(row.campaigns))
|
|
27
|
+
return row.campaigns[0] ?? null;
|
|
28
|
+
return row.campaigns ?? null;
|
|
29
|
+
}
|
|
30
|
+
export function groupScheduleByCampaign(rows) {
|
|
31
|
+
const groups = new Map();
|
|
32
|
+
for (const row of rows) {
|
|
33
|
+
if (!row.next_send_at)
|
|
34
|
+
continue;
|
|
35
|
+
const campaign = getCampaign(row);
|
|
36
|
+
if (!campaign?.id)
|
|
37
|
+
continue;
|
|
38
|
+
const existing = groups.get(campaign.id) ?? {
|
|
39
|
+
campaign_id: campaign.id,
|
|
40
|
+
campaign_name: campaign.name,
|
|
41
|
+
campaign_type: campaign.type,
|
|
42
|
+
total_sends_scheduled: 0,
|
|
43
|
+
sends_in_window: [],
|
|
44
|
+
earliest_send: null,
|
|
45
|
+
latest_send: null,
|
|
46
|
+
dateCounts: new Map(),
|
|
47
|
+
};
|
|
48
|
+
existing.total_sends_scheduled += 1;
|
|
49
|
+
existing.earliest_send = !existing.earliest_send || row.next_send_at < existing.earliest_send ? row.next_send_at : existing.earliest_send;
|
|
50
|
+
existing.latest_send = !existing.latest_send || row.next_send_at > existing.latest_send ? row.next_send_at : existing.latest_send;
|
|
51
|
+
const date = isoDate(row.next_send_at);
|
|
52
|
+
existing.dateCounts.set(date, (existing.dateCounts.get(date) ?? 0) + 1);
|
|
53
|
+
groups.set(campaign.id, existing);
|
|
54
|
+
}
|
|
55
|
+
return [...groups.values()]
|
|
56
|
+
.map(({ dateCounts, ...group }) => ({
|
|
57
|
+
...group,
|
|
58
|
+
sends_in_window: [...dateCounts.entries()]
|
|
59
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
60
|
+
.map(([date, total_sends_scheduled]) => ({ date, total_sends_scheduled })),
|
|
61
|
+
}))
|
|
62
|
+
.sort((a, b) => (a.earliest_send ?? '').localeCompare(b.earliest_send ?? ''));
|
|
63
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { SocialBrief, SocialFormat, SocialPostPlatform } from "./types.js";
|
|
2
|
+
export interface SocialArchetype {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
/** Fill-in-the-blank hook pattern the model adapts, never copies verbatim. */
|
|
6
|
+
hook_formula: string;
|
|
7
|
+
/** Ordered roles; each maps to hard rules in the prompt builder. */
|
|
8
|
+
structure: Array<"hook" | "pain" | "proof" | "solution" | "story" | "steps" | "question" | "cta">;
|
|
9
|
+
/** The psychological mechanism this archetype rides. */
|
|
10
|
+
neurodesign_trigger: string;
|
|
11
|
+
reference_creators: string[];
|
|
12
|
+
/** Voice notes layered into the prompt for this archetype only. */
|
|
13
|
+
voice_notes: string;
|
|
14
|
+
best_for: {
|
|
15
|
+
goals: string[];
|
|
16
|
+
platforms: SocialPostPlatform[];
|
|
17
|
+
formats: SocialFormat[];
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export declare const SOCIAL_ARCHETYPES: SocialArchetype[];
|
|
21
|
+
export declare function getArchetype(id: string): SocialArchetype | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* Deterministic archetype selection: goal keywords dominate, platform and
|
|
24
|
+
* format fit break ties, declaration order breaks exact ties. An explicit
|
|
25
|
+
* brief.archetype_id always wins (the user picked).
|
|
26
|
+
*/
|
|
27
|
+
export declare function selectArchetype(brief: SocialBrief): SocialArchetype;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Creator archetypes (#1360) — mirrors Synchronex's creator-archetypes layer.
|
|
2
|
+
// Mirror of packages/dashboard/lib/social-engine/archetypes.ts for the MCP
|
|
3
|
+
// server package (imports adapted to Node16 ".js" paths + local types).
|
|
4
|
+
//
|
|
5
|
+
// An archetype is what makes a post read like a specific creator instead of
|
|
6
|
+
// generic marketing: a hook formula, a role structure with hard rules, a
|
|
7
|
+
// neurodesign trigger, and reference creators the model can pattern-match.
|
|
8
|
+
// Selection is deterministic (scored against the brief) so the same brief
|
|
9
|
+
// picks the same archetype — an explicit archetype_id on the brief wins.
|
|
10
|
+
export const SOCIAL_ARCHETYPES = [
|
|
11
|
+
{
|
|
12
|
+
id: "contrarian_operator",
|
|
13
|
+
name: "Contrarian Operator",
|
|
14
|
+
hook_formula: "Everyone {common_practice}. We {opposite}. Here's why.",
|
|
15
|
+
structure: ["hook", "pain", "proof", "solution", "cta"],
|
|
16
|
+
neurodesign_trigger: "pattern interrupt — violated expectation forces a second look",
|
|
17
|
+
reference_creators: ["Jason Fried", "Amanda Goetz"],
|
|
18
|
+
voice_notes: "Confident, not combative. Disagree with the practice, never with the reader.",
|
|
19
|
+
best_for: {
|
|
20
|
+
goals: ["position", "differentiat", "opinion", "philosophy", "why we"],
|
|
21
|
+
platforms: ["linkedin", "x", "twitter", "threads"],
|
|
22
|
+
formats: ["text", "single_image"],
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "numbers_prover",
|
|
27
|
+
name: "Numbers Prover",
|
|
28
|
+
hook_formula: "{specific_number} {unexpected_outcome}. Here's what did it.",
|
|
29
|
+
structure: ["hook", "proof", "solution", "cta"],
|
|
30
|
+
neurodesign_trigger: "specificity = credibility — a concrete number reads as evidence, not claim",
|
|
31
|
+
reference_creators: ["Nathan Barry", "Pieter Levels"],
|
|
32
|
+
voice_notes: "Let the numbers carry it. No adjectives where a number can sit instead.",
|
|
33
|
+
best_for: {
|
|
34
|
+
goals: ["result", "milestone", "metric", "growth", "revenue", "launch result", "case study"],
|
|
35
|
+
platforms: ["x", "twitter", "linkedin", "bluesky"],
|
|
36
|
+
formats: ["text", "single_image"],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "behind_the_builder",
|
|
41
|
+
name: "Behind the Builder",
|
|
42
|
+
hook_formula: "What building {thing} actually looked like this week:",
|
|
43
|
+
structure: ["hook", "story", "solution", "cta"],
|
|
44
|
+
neurodesign_trigger: "parasocial authenticity — process beats polish for trust",
|
|
45
|
+
reference_creators: ["Arvid Kahl", "Marc Lou"],
|
|
46
|
+
voice_notes: "First person, present tense where possible. Include one unglamorous detail.",
|
|
47
|
+
best_for: {
|
|
48
|
+
goals: ["build in public", "behind", "progress", "update", "journey", "shipped"],
|
|
49
|
+
platforms: ["x", "twitter", "threads", "instagram", "tiktok"],
|
|
50
|
+
formats: ["text", "single_image", "video_script"],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "teacher_frameworker",
|
|
55
|
+
name: "Teacher Frameworker",
|
|
56
|
+
hook_formula: "The {n}-step way to {outcome} (without {common_pain}):",
|
|
57
|
+
structure: ["hook", "pain", "steps", "cta"],
|
|
58
|
+
neurodesign_trigger: "competence + saveability — frameworks get bookmarked, bookmarks compound reach",
|
|
59
|
+
reference_creators: ["Justin Welsh", "Katelyn Bourgoin"],
|
|
60
|
+
voice_notes: "Each step must be doable today. Number them. No step longer than two lines.",
|
|
61
|
+
best_for: {
|
|
62
|
+
goals: ["teach", "how to", "guide", "educat", "tips", "framework", "process"],
|
|
63
|
+
platforms: ["linkedin", "instagram", "pinterest", "youtube"],
|
|
64
|
+
formats: ["carousel", "text"],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "storyteller_arc",
|
|
69
|
+
name: "Storyteller Arc",
|
|
70
|
+
hook_formula: "{time_marker}, {low_point}. Then {turn}.",
|
|
71
|
+
structure: ["hook", "story", "solution", "cta"],
|
|
72
|
+
neurodesign_trigger: "narrative transportation — a story suspends counter-arguing",
|
|
73
|
+
reference_creators: ["Steven Bartlett", "Ali Abdaal"],
|
|
74
|
+
voice_notes: "One story, one lesson. Cut every detail that doesn't serve the turn.",
|
|
75
|
+
best_for: {
|
|
76
|
+
goals: ["story", "lesson", "mistake", "failure", "learned", "customer story"],
|
|
77
|
+
platforms: ["linkedin", "instagram", "facebook", "threads"],
|
|
78
|
+
formats: ["text", "carousel", "video_script"],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: "straight_shooter",
|
|
83
|
+
name: "Straight Shooter",
|
|
84
|
+
hook_formula: "{thing} is live. It does {concrete_job}.",
|
|
85
|
+
structure: ["hook", "solution", "proof", "cta"],
|
|
86
|
+
neurodesign_trigger: "clarity premium — when everyone hypes, plain statements stand out",
|
|
87
|
+
reference_creators: ["Basecamp/37signals", "Linear"],
|
|
88
|
+
voice_notes: "Announce like you'd tell a colleague. One real use case beats three benefits.",
|
|
89
|
+
best_for: {
|
|
90
|
+
goals: ["announce", "release", "launch", "new feature", "ship", "available"],
|
|
91
|
+
platforms: ["x", "twitter", "linkedin", "facebook", "google_business", "bluesky"],
|
|
92
|
+
formats: ["text", "single_image"],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: "provocateur_question",
|
|
97
|
+
name: "Provocateur",
|
|
98
|
+
hook_formula: "Hot take: {uncomfortable_claim}.",
|
|
99
|
+
structure: ["hook", "pain", "question"],
|
|
100
|
+
neurodesign_trigger: "open loop + identity stakes — people reply to defend how they work",
|
|
101
|
+
reference_creators: ["Adam Singer", "GergelyOrosz"],
|
|
102
|
+
voice_notes: "End on the question, not a CTA. The comments are the conversion.",
|
|
103
|
+
best_for: {
|
|
104
|
+
goals: ["engage", "discussion", "debate", "community", "poll", "conversation"],
|
|
105
|
+
platforms: ["x", "twitter", "threads", "linkedin", "reddit", "facebook"],
|
|
106
|
+
formats: ["text"],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: "curator_listicle",
|
|
111
|
+
name: "Curator",
|
|
112
|
+
hook_formula: "{n} {things} that {outcome} (#{standout} is underrated):",
|
|
113
|
+
structure: ["hook", "steps", "cta"],
|
|
114
|
+
neurodesign_trigger: "completeness + curiosity gap — the teased item makes skipping feel costly",
|
|
115
|
+
reference_creators: ["Sahil Bloom", "Lenny Rachitsky"],
|
|
116
|
+
voice_notes: "Every item must stand alone. Lead with the strongest, close with the most surprising.",
|
|
117
|
+
best_for: {
|
|
118
|
+
goals: ["roundup", "list", "resources", "tools", "ideas", "examples"],
|
|
119
|
+
platforms: ["x", "twitter", "linkedin", "instagram", "pinterest"],
|
|
120
|
+
formats: ["carousel", "text"],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
];
|
|
124
|
+
const ARCHETYPE_BY_ID = new Map(SOCIAL_ARCHETYPES.map((a) => [a.id, a]));
|
|
125
|
+
export function getArchetype(id) {
|
|
126
|
+
return ARCHETYPE_BY_ID.get(id);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Deterministic archetype selection: goal keywords dominate, platform and
|
|
130
|
+
* format fit break ties, declaration order breaks exact ties. An explicit
|
|
131
|
+
* brief.archetype_id always wins (the user picked).
|
|
132
|
+
*/
|
|
133
|
+
export function selectArchetype(brief) {
|
|
134
|
+
if (brief.archetype_id) {
|
|
135
|
+
const chosen = ARCHETYPE_BY_ID.get(brief.archetype_id);
|
|
136
|
+
if (chosen)
|
|
137
|
+
return chosen;
|
|
138
|
+
}
|
|
139
|
+
const goal = `${brief.goal} ${brief.style ?? ""}`.toLowerCase();
|
|
140
|
+
let best = SOCIAL_ARCHETYPES[0];
|
|
141
|
+
let bestScore = -1;
|
|
142
|
+
for (const archetype of SOCIAL_ARCHETYPES) {
|
|
143
|
+
let score = 0;
|
|
144
|
+
for (const stem of archetype.best_for.goals) {
|
|
145
|
+
if (goal.includes(stem))
|
|
146
|
+
score += 4;
|
|
147
|
+
}
|
|
148
|
+
for (const platform of brief.platforms) {
|
|
149
|
+
if (archetype.best_for.platforms.includes(platform))
|
|
150
|
+
score += 1;
|
|
151
|
+
}
|
|
152
|
+
if (archetype.best_for.formats.includes(brief.format))
|
|
153
|
+
score += 2;
|
|
154
|
+
if (score > bestScore) {
|
|
155
|
+
best = archetype;
|
|
156
|
+
bestScore = score;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return best;
|
|
160
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { SOCIAL_ARCHETYPES, getArchetype, selectArchetype, type SocialArchetype } from "./archetypes.js";
|
|
2
|
+
export { PLATFORM_CONSTRAINTS, assembleCaption, buildPlatformRuleBlock, getPlatformConstraints, type PlatformConstraints } from "./platform-constraints.js";
|
|
3
|
+
export { buildSocialSystemPrompt, buildSocialUserPrompt, type SocialPromptInput } from "./prompt-builder.js";
|
|
4
|
+
export { parseSocialResponse } from "./parse.js";
|
|
5
|
+
export { SOCIAL_POST_PLATFORMS, type CarouselFrame, type MediaPlan, type SocialBrief, type SocialFormat, type SocialGenerationRecord, type SocialPostIR, type SocialPostPlatform, type SocialVariant, type VariantLabel, } from "./types.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Mirror of packages/dashboard/lib/social-engine/index.ts for the MCP server.
|
|
2
|
+
export { SOCIAL_ARCHETYPES, getArchetype, selectArchetype } from "./archetypes.js";
|
|
3
|
+
export { PLATFORM_CONSTRAINTS, assembleCaption, buildPlatformRuleBlock, getPlatformConstraints } from "./platform-constraints.js";
|
|
4
|
+
export { buildSocialSystemPrompt, buildSocialUserPrompt } from "./prompt-builder.js";
|
|
5
|
+
export { parseSocialResponse } from "./parse.js";
|
|
6
|
+
export { SOCIAL_POST_PLATFORMS, } from "./types.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SocialPostIR, SocialPostPlatform } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Parse one platform segment's model output into a validated IR.
|
|
4
|
+
* Throws on unusable output (no parseable variants) — the caller decides
|
|
5
|
+
* whether to retry or surface the failure.
|
|
6
|
+
*/
|
|
7
|
+
export declare function parseSocialResponse(raw: string, platform: SocialPostPlatform, archetypeId: string): SocialPostIR;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Response parsing + gen-time enforcement (#1360). Takes the raw model text
|
|
2
|
+
// for ONE platform segment and returns a validated SocialPostIR: variants
|
|
3
|
+
// normalized to A/B/C, captions assembled under platform budgets (hashtag
|
|
4
|
+
// trim, then flagged sentence-boundary truncation), media plan sanitized.
|
|
5
|
+
// Mirror of packages/dashboard/lib/social-engine/parse.ts.
|
|
6
|
+
import { assembleCaption } from "./platform-constraints.js";
|
|
7
|
+
const VARIANT_LABELS = ["A", "B", "C"];
|
|
8
|
+
function cleanString(value, max) {
|
|
9
|
+
return typeof value === "string" ? value.trim().slice(0, max) : "";
|
|
10
|
+
}
|
|
11
|
+
function sanitizeMediaPlan(raw) {
|
|
12
|
+
if (!raw || typeof raw !== "object")
|
|
13
|
+
return undefined;
|
|
14
|
+
const plan = raw;
|
|
15
|
+
const kind = plan.kind;
|
|
16
|
+
if (kind !== "single_image" && kind !== "carousel" && kind !== "video_script")
|
|
17
|
+
return undefined;
|
|
18
|
+
const result = { kind };
|
|
19
|
+
const direction = cleanString(plan.image_direction, 500);
|
|
20
|
+
if (direction)
|
|
21
|
+
result.image_direction = direction;
|
|
22
|
+
if (Array.isArray(plan.frames)) {
|
|
23
|
+
const frames = plan.frames
|
|
24
|
+
.filter((frame) => Boolean(frame) && typeof frame === "object")
|
|
25
|
+
.slice(0, 10)
|
|
26
|
+
.map((frame, index) => ({
|
|
27
|
+
order: typeof frame.order === "number" ? frame.order : index + 1,
|
|
28
|
+
headline: cleanString(frame.headline, 120),
|
|
29
|
+
support_text: cleanString(frame.support_text, 300),
|
|
30
|
+
visual_direction: cleanString(frame.visual_direction, 300),
|
|
31
|
+
}))
|
|
32
|
+
.filter((frame) => frame.headline);
|
|
33
|
+
if (frames.length)
|
|
34
|
+
result.frames = frames;
|
|
35
|
+
}
|
|
36
|
+
return result.image_direction || result.frames ? result : undefined;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Parse one platform segment's model output into a validated IR.
|
|
40
|
+
* Throws on unusable output (no parseable variants) — the caller decides
|
|
41
|
+
* whether to retry or surface the failure.
|
|
42
|
+
*/
|
|
43
|
+
export function parseSocialResponse(raw, platform, archetypeId) {
|
|
44
|
+
let parsed;
|
|
45
|
+
try {
|
|
46
|
+
parsed = JSON.parse(raw.replace(/```json?\n?|```/g, "").trim());
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new Error(`Unparseable model output for ${platform}`);
|
|
50
|
+
}
|
|
51
|
+
const rawVariants = Array.isArray(parsed.variants) ? parsed.variants : [];
|
|
52
|
+
const warnings = [];
|
|
53
|
+
const variants = rawVariants
|
|
54
|
+
.filter((v) => Boolean(v) && typeof v === "object")
|
|
55
|
+
.slice(0, 3)
|
|
56
|
+
.map((v, index) => {
|
|
57
|
+
const parts = {
|
|
58
|
+
hook: cleanString(v.hook, 400),
|
|
59
|
+
body: cleanString(v.body, 4000),
|
|
60
|
+
cta: cleanString(v.cta, 300),
|
|
61
|
+
hashtags: Array.isArray(v.hashtags) ? v.hashtags.filter((t) => typeof t === "string").slice(0, 15) : [],
|
|
62
|
+
};
|
|
63
|
+
const { caption, warnings: captionWarnings } = assembleCaption(parts, platform);
|
|
64
|
+
warnings.push(...captionWarnings.map((w) => `variant ${VARIANT_LABELS[index]}: ${w}`));
|
|
65
|
+
return {
|
|
66
|
+
label: VARIANT_LABELS[index],
|
|
67
|
+
angle: cleanString(v.angle, 200),
|
|
68
|
+
...parts,
|
|
69
|
+
caption,
|
|
70
|
+
};
|
|
71
|
+
})
|
|
72
|
+
.filter((v) => v.caption.length > 0);
|
|
73
|
+
if (!variants.length) {
|
|
74
|
+
throw new Error(`Model returned no usable variants for ${platform}`);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
platform,
|
|
78
|
+
archetype_id: archetypeId,
|
|
79
|
+
variants,
|
|
80
|
+
media_plan: sanitizeMediaPlan(parsed.media_plan),
|
|
81
|
+
warnings,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { SocialPostPlatform, SocialVariant } from "./types.js";
|
|
2
|
+
export interface PlatformConstraints {
|
|
3
|
+
/** Hard caption ceiling (publish fails or truncates beyond it). */
|
|
4
|
+
maxChars: number;
|
|
5
|
+
/** Soft target the prompt aims for — leaves headroom under maxChars. */
|
|
6
|
+
targetChars: number;
|
|
7
|
+
hashtags: {
|
|
8
|
+
min: number;
|
|
9
|
+
max: number;
|
|
10
|
+
};
|
|
11
|
+
emojiOk: boolean;
|
|
12
|
+
lineBreaks: boolean;
|
|
13
|
+
toneNote: string;
|
|
14
|
+
}
|
|
15
|
+
export declare const PLATFORM_CONSTRAINTS: Record<SocialPostPlatform, PlatformConstraints>;
|
|
16
|
+
export declare function getPlatformConstraints(platform: SocialPostPlatform): PlatformConstraints;
|
|
17
|
+
/** Prose rule block for the prompt — derived from the structured constraints. */
|
|
18
|
+
export declare function buildPlatformRuleBlock(platform: SocialPostPlatform): string;
|
|
19
|
+
/**
|
|
20
|
+
* Gen-time enforcement: assemble the publishable caption from variant parts,
|
|
21
|
+
* repairing deterministically when over budget — hashtags trimmed first, then
|
|
22
|
+
* sentence-boundary truncation as a last resort (flagged, never silent).
|
|
23
|
+
*/
|
|
24
|
+
export declare function assembleCaption(variant: Pick<SocialVariant, "hook" | "body" | "cta" | "hashtags">, platform: SocialPostPlatform): {
|
|
25
|
+
caption: string;
|
|
26
|
+
warnings: string[];
|
|
27
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Platform constraints (#1360) — structured per-platform rules, enforced at
|
|
2
|
+
// generation time: they shape the prompt AND validate/repair the parsed
|
|
3
|
+
// output. Single source of truth for the prose rules the prompt uses.
|
|
4
|
+
// Mirror of packages/dashboard/lib/social-engine/platform-constraints.ts.
|
|
5
|
+
export const PLATFORM_CONSTRAINTS = {
|
|
6
|
+
x: { maxChars: 280, targetChars: 240, hashtags: { min: 0, max: 2 }, emojiOk: false, lineBreaks: true, toneNote: "Lead with the hook. No fluff. Conversational and direct." },
|
|
7
|
+
twitter: { maxChars: 280, targetChars: 240, hashtags: { min: 0, max: 2 }, emojiOk: false, lineBreaks: true, toneNote: "Lead with the hook. No fluff. Conversational and direct." },
|
|
8
|
+
linkedin: { maxChars: 1300, targetChars: 1000, hashtags: { min: 3, max: 5 }, emojiOk: false, lineBreaks: true, toneNote: "Professional but human. Bold first line, no greeting. Line breaks for readability. Thought-leadership framing." },
|
|
9
|
+
instagram: { maxChars: 2200, targetChars: 1500, hashtags: { min: 5, max: 10 }, emojiOk: true, lineBreaks: true, toneNote: "Conversational and warm. Hook on line 1 — the reader sees it before 'more'. Soft CTA at the end." },
|
|
10
|
+
facebook: { maxChars: 500, targetChars: 400, hashtags: { min: 0, max: 2 }, emojiOk: true, lineBreaks: true, toneNote: "Community-focused. Pose a question or invite discussion. Link in post is fine." },
|
|
11
|
+
tiktok: { maxChars: 150, targetChars: 120, hashtags: { min: 2, max: 3 }, emojiOk: true, lineBreaks: false, toneNote: "Casual, energetic hook. Trending language is acceptable. No hashtag stuffing." },
|
|
12
|
+
threads: { maxChars: 500, targetChars: 400, hashtags: { min: 0, max: 2 }, emojiOk: true, lineBreaks: true, toneNote: "Casual and conversational — a hot take or behind-the-scenes note." },
|
|
13
|
+
reddit: { maxChars: 4000, targetChars: 1200, hashtags: { min: 0, max: 0 }, emojiOk: false, lineBreaks: true, toneNote: "No sales tone. Community-first. Be genuinely useful; write like a member, not a brand." },
|
|
14
|
+
bluesky: { maxChars: 300, targetChars: 260, hashtags: { min: 0, max: 1 }, emojiOk: false, lineBreaks: true, toneNote: "Link-forward culture. Share the insight, let the link do the rest." },
|
|
15
|
+
youtube: { maxChars: 5000, targetChars: 1500, hashtags: { min: 0, max: 3 }, emojiOk: false, lineBreaks: true, toneNote: "Description format: hook sentence, 2–3 paragraph summary, keywords embedded naturally, subscribe CTA at the end." },
|
|
16
|
+
pinterest: { maxChars: 500, targetChars: 400, hashtags: { min: 0, max: 4 }, emojiOk: false, lineBreaks: false, toneNote: "Inspirational and keyword-rich for SEO. Describe what the reader will learn or make." },
|
|
17
|
+
google_business: { maxChars: 1500, targetChars: 1000, hashtags: { min: 0, max: 0 }, emojiOk: false, lineBreaks: true, toneNote: "Professional and factual. Local-business tone. Highlight value, include a CTA." },
|
|
18
|
+
};
|
|
19
|
+
export function getPlatformConstraints(platform) {
|
|
20
|
+
return PLATFORM_CONSTRAINTS[platform];
|
|
21
|
+
}
|
|
22
|
+
/** Prose rule block for the prompt — derived from the structured constraints. */
|
|
23
|
+
export function buildPlatformRuleBlock(platform) {
|
|
24
|
+
const c = PLATFORM_CONSTRAINTS[platform];
|
|
25
|
+
const hashtagRule = c.hashtags.max === 0
|
|
26
|
+
? "No hashtags."
|
|
27
|
+
: `${c.hashtags.min}–${c.hashtags.max} hashtags${c.hashtags.min === 0 ? " at most" : ""}.`;
|
|
28
|
+
return [
|
|
29
|
+
`Platform: ${platform}.`,
|
|
30
|
+
`Hard limit ${c.maxChars} characters for the FULL caption (hook + body + cta + hashtags); aim for ~${c.targetChars}.`,
|
|
31
|
+
hashtagRule,
|
|
32
|
+
c.emojiOk ? "Emojis allowed where natural." : "No emojis.",
|
|
33
|
+
c.lineBreaks ? "Use line breaks for rhythm." : "Single block of text, no line breaks.",
|
|
34
|
+
c.toneNote,
|
|
35
|
+
].join(" ");
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Gen-time enforcement: assemble the publishable caption from variant parts,
|
|
39
|
+
* repairing deterministically when over budget — hashtags trimmed first, then
|
|
40
|
+
* sentence-boundary truncation as a last resort (flagged, never silent).
|
|
41
|
+
*/
|
|
42
|
+
export function assembleCaption(variant, platform) {
|
|
43
|
+
const c = PLATFORM_CONSTRAINTS[platform];
|
|
44
|
+
const warnings = [];
|
|
45
|
+
const joiner = c.lineBreaks ? "\n\n" : " ";
|
|
46
|
+
let hashtags = (variant.hashtags ?? [])
|
|
47
|
+
.map((tag) => tag.trim())
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.map((tag) => (tag.startsWith("#") ? tag : `#${tag}`))
|
|
50
|
+
.slice(0, c.hashtags.max);
|
|
51
|
+
const assemble = (tags) => {
|
|
52
|
+
const parts = [variant.hook, variant.body, variant.cta].map((part) => (part ?? "").trim()).filter(Boolean);
|
|
53
|
+
const text = parts.join(joiner);
|
|
54
|
+
return tags.length ? `${text}${joiner}${tags.join(" ")}` : text;
|
|
55
|
+
};
|
|
56
|
+
let caption = assemble(hashtags);
|
|
57
|
+
while (caption.length > c.maxChars && hashtags.length > c.hashtags.min) {
|
|
58
|
+
hashtags = hashtags.slice(0, -1);
|
|
59
|
+
caption = assemble(hashtags);
|
|
60
|
+
if (!warnings.length)
|
|
61
|
+
warnings.push(`${platform}: trimmed hashtags to fit ${c.maxChars} chars`);
|
|
62
|
+
}
|
|
63
|
+
if (caption.length > c.maxChars) {
|
|
64
|
+
const slice = caption.slice(0, c.maxChars);
|
|
65
|
+
const lastStop = Math.max(slice.lastIndexOf(". "), slice.lastIndexOf("! "), slice.lastIndexOf("? "), slice.lastIndexOf("\n"));
|
|
66
|
+
caption = lastStop > c.maxChars * 0.5 ? slice.slice(0, lastStop + 1).trimEnd() : slice.trimEnd();
|
|
67
|
+
warnings.push(`${platform}: caption exceeded ${c.maxChars} chars and was truncated — review before publishing`);
|
|
68
|
+
}
|
|
69
|
+
return { caption, warnings };
|
|
70
|
+
}
|