@sellable/mcp 0.1.315 → 0.1.319

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.
@@ -47,7 +47,7 @@ export const linkedinToolDefinitions = [
47
47
  },
48
48
  {
49
49
  name: "fetch_linkedin_profile",
50
- description: "Fetch LinkedIn profile details. Defaults to the cheaper 'main' payload same envelope shape with most-recent role, headline, about, location, education, top skills, certifications, languages, projects, etc. Pass full=true ONLY when you need the long-tail experience history (positions 6+) or complete skill list (skills 3+) for deep research. Most personalization, qualification, and outreach use cases work with main; reach for full only when the user explicitly asks for full work history or comprehensive skill audit.",
50
+ description: "Fetch LinkedIn profile details. Defaults to compact context (fetchMinimal=true) that strips images/media/provider metadata and returns identity, headline, about, current role, company, limited experience, education, skills, certifications, languages, and projects. Pass fetchMinimal=false only when you need raw provider fields or media URLs. Pass full=true ONLY when you need the long-tail experience history (positions 6+) or complete skill list (skills 3+) for deep research; full still returns compact output unless fetchMinimal=false.",
51
51
  inputSchema: {
52
52
  type: "object",
53
53
  properties: {
@@ -60,6 +60,11 @@ export const linkedinToolDefinitions = [
60
60
  description: "Opt into the full payload (~38% more expensive, slightly slower). Set true only when you specifically need the long-tail experience history or full skill list. Default false (main payload).",
61
61
  default: false,
62
62
  },
63
+ fetchMinimal: {
64
+ type: "boolean",
65
+ description: "Return compact agent context by stripping images/media/provider metadata and limiting long lists. Default true. Set false only when raw provider fields or media URLs are needed.",
66
+ default: true,
67
+ },
63
68
  },
64
69
  required: ["linkedinUrl"],
65
70
  },
@@ -187,6 +192,348 @@ function collectImageUrls(value, urls = new Set()) {
187
192
  function pickString(value) {
188
193
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
189
194
  }
195
+ function isRecord(value) {
196
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
197
+ }
198
+ function pickRecord(value, keys) {
199
+ if (!isRecord(value))
200
+ return undefined;
201
+ for (const key of keys) {
202
+ const candidate = value[key];
203
+ if (isRecord(candidate))
204
+ return candidate;
205
+ }
206
+ return undefined;
207
+ }
208
+ function pickStringFrom(value, keys, maxLength = 1200) {
209
+ if (!isRecord(value))
210
+ return undefined;
211
+ for (const key of keys) {
212
+ const candidate = value[key];
213
+ const picked = typeof candidate === "string" || typeof candidate === "number"
214
+ ? compactString(candidate, maxLength)
215
+ : undefined;
216
+ if (picked)
217
+ return picked;
218
+ }
219
+ return undefined;
220
+ }
221
+ function pickNumberFrom(value, keys) {
222
+ if (!isRecord(value))
223
+ return undefined;
224
+ for (const key of keys) {
225
+ const candidate = value[key];
226
+ if (typeof candidate === "number" && Number.isFinite(candidate)) {
227
+ return candidate;
228
+ }
229
+ if (typeof candidate === "string" && candidate.trim()) {
230
+ const parsed = Number(candidate.replace(/,/g, ""));
231
+ if (Number.isFinite(parsed))
232
+ return parsed;
233
+ }
234
+ }
235
+ return undefined;
236
+ }
237
+ function pickBooleanFrom(value, keys) {
238
+ if (!isRecord(value))
239
+ return undefined;
240
+ for (const key of keys) {
241
+ const candidate = value[key];
242
+ if (typeof candidate === "boolean")
243
+ return candidate;
244
+ }
245
+ return undefined;
246
+ }
247
+ function compactString(value, maxLength = 1200) {
248
+ if (typeof value !== "string" && typeof value !== "number") {
249
+ return undefined;
250
+ }
251
+ const compacted = String(value)
252
+ .replace(/[ \t]+\n/g, "\n")
253
+ .replace(/\n{3,}/g, "\n\n")
254
+ .trim();
255
+ if (!compacted)
256
+ return undefined;
257
+ return compacted.length > maxLength
258
+ ? `${compacted.slice(0, maxLength - 3).trim()}...`
259
+ : compacted;
260
+ }
261
+ function stripEmpty(value) {
262
+ if (Array.isArray(value)) {
263
+ return value
264
+ .map((item) => stripEmpty(item))
265
+ .filter((item) => {
266
+ if (item == null)
267
+ return false;
268
+ if (Array.isArray(item))
269
+ return item.length > 0;
270
+ if (isRecord(item))
271
+ return Object.keys(item).length > 0;
272
+ return true;
273
+ });
274
+ }
275
+ if (!isRecord(value))
276
+ return value;
277
+ const entries = Object.entries(value)
278
+ .map(([key, item]) => [key, stripEmpty(item)])
279
+ .filter(([, item]) => {
280
+ if (item == null || item === "")
281
+ return false;
282
+ if (Array.isArray(item))
283
+ return item.length > 0;
284
+ if (isRecord(item))
285
+ return Object.keys(item).length > 0;
286
+ return true;
287
+ });
288
+ return Object.fromEntries(entries);
289
+ }
290
+ function asArray(value) {
291
+ if (Array.isArray(value))
292
+ return value;
293
+ return value == null ? [] : [value];
294
+ }
295
+ function pickProfileArray(profile, keys) {
296
+ for (const key of keys) {
297
+ const value = profile[key];
298
+ if (Array.isArray(value))
299
+ return value;
300
+ }
301
+ return [];
302
+ }
303
+ function datePartText(value) {
304
+ if (typeof value === "string")
305
+ return compactString(value, 80);
306
+ if (!isRecord(value))
307
+ return undefined;
308
+ const text = pickStringFrom(value, ["text", "date"], 80);
309
+ if (text)
310
+ return text;
311
+ const year = pickNumberFrom(value, ["year"]);
312
+ const month = pickStringFrom(value, ["month"], 20) || pickNumberFrom(value, ["month"]);
313
+ if (!year)
314
+ return undefined;
315
+ return month ? `${month} ${year}` : String(year);
316
+ }
317
+ function dateRangeFromItem(item) {
318
+ const explicit = pickStringFrom(item, ["dateRange", "date_range", "period"], 120);
319
+ if (explicit)
320
+ return explicit;
321
+ const startText = datePartText(item.startDate) ||
322
+ datePartText(stripEmpty({
323
+ month: item.start_month ?? item.startMonth,
324
+ year: item.start_year ?? item.startYear,
325
+ }));
326
+ const endText = datePartText(item.endDate) ||
327
+ datePartText(stripEmpty({
328
+ month: item.end_month ?? item.endMonth,
329
+ year: item.end_year ?? item.endYear,
330
+ }));
331
+ return [startText, endText].filter(Boolean).join(" - ") || undefined;
332
+ }
333
+ function compactExperienceItem(value) {
334
+ if (!isRecord(value))
335
+ return {};
336
+ const companyRecord = pickRecord(value, ["company"]);
337
+ return stripEmpty({
338
+ title: pickStringFrom(value, ["title", "position", "job_title"], 160),
339
+ companyName: pickStringFrom(value, ["companyName", "company_name", "company"], 160) ||
340
+ pickStringFrom(companyRecord, ["name"], 160),
341
+ companyLinkedinUrl: pickStringFrom(value, [
342
+ "companyLinkedinUrl",
343
+ "companyLinkedInUrl",
344
+ "company_linkedin_url",
345
+ "companyLink",
346
+ "company_public_url",
347
+ ], 240),
348
+ dateRange: dateRangeFromItem(value),
349
+ duration: pickStringFrom(value, ["duration", "current_job_duration"], 80),
350
+ location: pickStringFrom(value, ["location"], 180),
351
+ description: pickStringFrom(value, ["description", "summary"], 700),
352
+ isCurrent: pickBooleanFrom(value, ["isCurrent", "is_current"]),
353
+ });
354
+ }
355
+ function compactEducationItem(value) {
356
+ if (!isRecord(value))
357
+ return {};
358
+ return stripEmpty({
359
+ schoolName: pickStringFrom(value, ["schoolName", "school_name", "school"], 180),
360
+ schoolLinkedinUrl: pickStringFrom(value, ["schoolLinkedinUrl", "schoolLinkedInUrl", "school_linkedin_url"], 240),
361
+ degree: pickStringFrom(value, ["degree"], 180),
362
+ fieldOfStudy: pickStringFrom(value, ["fieldOfStudy", "field_of_study"], 180),
363
+ dateRange: dateRangeFromItem(value),
364
+ });
365
+ }
366
+ function compactStringList(value, limit) {
367
+ return asArray(value)
368
+ .map((item) => {
369
+ if (typeof item === "string" || typeof item === "number") {
370
+ return compactString(item, 120);
371
+ }
372
+ return pickStringFrom(item, ["name", "title", "skill"], 120);
373
+ })
374
+ .filter((item) => Boolean(item))
375
+ .slice(0, limit);
376
+ }
377
+ function compactLanguages(value) {
378
+ return asArray(value)
379
+ .map((item) => {
380
+ if (typeof item === "string")
381
+ return { name: compactString(item, 80) };
382
+ if (!isRecord(item))
383
+ return {};
384
+ return stripEmpty({
385
+ name: pickStringFrom(item, ["name", "language"], 80),
386
+ proficiency: pickStringFrom(item, ["proficiency"], 120),
387
+ });
388
+ })
389
+ .filter((item) => Object.keys(item).length > 0)
390
+ .slice(0, 10);
391
+ }
392
+ function compactNamedItems(value) {
393
+ return asArray(value)
394
+ .map((item) => {
395
+ if (typeof item === "string")
396
+ return { name: compactString(item, 160) };
397
+ if (!isRecord(item))
398
+ return {};
399
+ return stripEmpty({
400
+ name: pickStringFrom(item, ["name", "title"], 180),
401
+ organization: pickStringFrom(item, ["organization", "authority", "issuer", "companyName"], 180),
402
+ dateRange: dateRangeFromItem(item),
403
+ description: pickStringFrom(item, ["description"], 500),
404
+ url: pickStringFrom(item, ["url", "link"], 240),
405
+ });
406
+ })
407
+ .filter((item) => Object.keys(item).length > 0)
408
+ .slice(0, 5);
409
+ }
410
+ function firstCurrentRole(profile) {
411
+ const current = pickProfileArray(profile, [
412
+ "currentPosition",
413
+ "current_position",
414
+ ])
415
+ .map(compactExperienceItem)
416
+ .find((item) => Object.keys(item).length > 0);
417
+ if (current)
418
+ return current;
419
+ const firstExperience = pickProfileArray(profile, [
420
+ "experience",
421
+ "experiences",
422
+ ])
423
+ .map(compactExperienceItem)
424
+ .find((item) => Object.keys(item).length > 0);
425
+ if (firstExperience)
426
+ return firstExperience;
427
+ return stripEmpty({
428
+ title: pickStringFrom(profile, ["job_title", "jobTitle", "title"], 160),
429
+ companyName: pickStringFrom(profile, ["company", "companyName"], 160),
430
+ dateRange: dateRangeFromItem(stripEmpty({
431
+ start_month: profile.current_company_join_month ?? profile.currentCompanyJoinMonth,
432
+ start_year: profile.current_company_join_year ?? profile.currentCompanyJoinYear,
433
+ })),
434
+ duration: pickStringFrom(profile, ["current_job_duration", "currentJobDuration"], 80),
435
+ });
436
+ }
437
+ function compactCompanyFromProfile(profile, currentRole) {
438
+ return stripEmpty({
439
+ name: pickStringFrom(profile, ["company", "companyName"], 180) ||
440
+ currentRole.companyName,
441
+ linkedinUrl: pickStringFrom(profile, [
442
+ "company_linkedin_url",
443
+ "companyLinkedinUrl",
444
+ "companyLinkedInUrl",
445
+ "currentCompanyLinkedinUrl",
446
+ ], 240) || currentRole.companyLinkedinUrl,
447
+ website: pickStringFrom(profile, ["company_website", "companyWebsite", "website"], 240),
448
+ domain: pickStringFrom(profile, ["company_domain", "companyDomain", "currentCompanyDomain"], 120),
449
+ industry: pickStringFrom(profile, ["company_industry", "industry"], 160),
450
+ employeeCount: pickNumberFrom(profile, [
451
+ "company_employee_count",
452
+ "companyEmployeeCount",
453
+ "employeeCount",
454
+ ]),
455
+ employeeRange: pickStringFrom(profile, ["company_employee_range", "companyEmployeeRange", "employeeCountRange"], 80),
456
+ description: pickStringFrom(profile, ["company_description", "companyDescription"], 700),
457
+ founded: pickStringFrom(profile, ["company_year_founded", "founded"], 80) ||
458
+ pickNumberFrom(profile, ["company_year_founded", "founded"]),
459
+ });
460
+ }
461
+ export function compactLinkedInProfile(profile) {
462
+ if (!isRecord(profile))
463
+ return {};
464
+ const currentRole = firstCurrentRole(profile);
465
+ const experience = pickProfileArray(profile, ["experience", "experiences"])
466
+ .map(compactExperienceItem)
467
+ .filter((item) => Object.keys(item).length > 0)
468
+ .slice(0, 5);
469
+ const education = pickProfileArray(profile, ["education", "educations"])
470
+ .map(compactEducationItem)
471
+ .filter((item) => Object.keys(item).length > 0)
472
+ .slice(0, 3);
473
+ const skills = compactStringList(profile.topSkills ?? profile.skills, 12);
474
+ return stripEmpty({
475
+ id: pickStringFrom(profile, ["id", "profile_id"], 180),
476
+ publicIdentifier: pickStringFrom(profile, ["publicIdentifier", "public_id", "publicIdentifier"], 120),
477
+ linkedinUrl: pickStringFrom(profile, ["linkedinUrl", "linkedin_url", "profile_url"], 240),
478
+ firstName: pickStringFrom(profile, ["firstName", "first_name"], 120),
479
+ lastName: pickStringFrom(profile, ["lastName", "last_name"], 120),
480
+ fullName: pickStringFrom(profile, ["fullName", "full_name", "name"], 180) ||
481
+ [
482
+ pickStringFrom(profile, ["firstName", "first_name"], 120),
483
+ pickStringFrom(profile, ["lastName", "last_name"], 120),
484
+ ]
485
+ .filter(Boolean)
486
+ .join(" "),
487
+ headline: pickStringFrom(profile, ["headline"], 320),
488
+ about: pickStringFrom(profile, ["about", "summary"], 1600),
489
+ location: pickStringFrom(profile, ["location"], 180),
490
+ followerCount: pickNumberFrom(profile, ["followerCount", "follower_count"]),
491
+ connectionCount: pickNumberFrom(profile, [
492
+ "connectionsCount",
493
+ "connection_count",
494
+ "connectionCount",
495
+ ]),
496
+ isPremium: pickBooleanFrom(profile, ["premium", "is_premium", "isPremium"]),
497
+ isCreator: pickBooleanFrom(profile, ["is_creator", "isCreator"]),
498
+ isVerified: pickBooleanFrom(profile, [
499
+ "verified",
500
+ "is_verified",
501
+ "isVerified",
502
+ ]),
503
+ currentRole,
504
+ company: compactCompanyFromProfile(profile, currentRole),
505
+ experience,
506
+ education,
507
+ skills,
508
+ languages: compactLanguages(profile.languages),
509
+ certifications: compactNamedItems(profile.certifications),
510
+ projects: compactNamedItems(profile.projects),
511
+ });
512
+ }
513
+ function canonicalizeLinkedInProfileResponse(raw) {
514
+ if (raw &&
515
+ typeof raw === "object" &&
516
+ raw.status === "success" &&
517
+ (raw.data || raw.person || raw.profile?.person)) {
518
+ const canonical = raw.data ?? raw.person ?? raw.profile?.person;
519
+ const { status, data, person, profile, ...rest } = raw;
520
+ return { status, profile: canonical, ...rest };
521
+ }
522
+ return raw;
523
+ }
524
+ function compactLinkedInProfileResponse(raw) {
525
+ const normalized = canonicalizeLinkedInProfileResponse(raw);
526
+ if (normalized &&
527
+ typeof normalized === "object" &&
528
+ normalized.status === "success" &&
529
+ normalized.profile) {
530
+ return {
531
+ status: normalized.status,
532
+ profile: compactLinkedInProfile(normalized.profile),
533
+ };
534
+ }
535
+ return normalized;
536
+ }
190
537
  function serializePostMedia(post) {
191
538
  const media = post.media && typeof post.media === "object"
192
539
  ? post.media
@@ -295,23 +642,9 @@ export async function fetchLinkedInProfile(linkedinUrl, options = {}) {
295
642
  params.set("full", "true");
296
643
  }
297
644
  const raw = await api.get(`/api/v1/scrape/linkedin/profile?${params.toString()}`);
298
- // Server returns the same profile object triplicated as `.data`,
299
- // `.person`, AND `.profile.person` for backwards-compat with web
300
- // callers (signal-discovery, profile-enrichment, canonical-cache, etc).
301
- // For the MCP layer, that ~3xs the JSON size and routinely breaches
302
- // Claude Code's max-token cap on tool results — which triggers the
303
- // harness's "save to file + read in chunks via python3" overflow
304
- // fallback. Strip the duplicates here so the agent gets one clean
305
- // copy of the data under a single `profile` key.
306
- if (raw &&
307
- typeof raw === "object" &&
308
- raw.status === "success" &&
309
- (raw.data || raw.person || raw.profile?.person)) {
310
- const canonical = raw.data ?? raw.person ?? raw.profile?.person;
311
- const { status, data, person, profile, ...rest } = raw;
312
- return { status, profile: canonical, ...rest };
313
- }
314
- return raw;
645
+ return options.fetchMinimal === false
646
+ ? canonicalizeLinkedInProfileResponse(raw)
647
+ : compactLinkedInProfileResponse(raw);
315
648
  }
316
649
  export async function fetchCompany(companyUrl) {
317
650
  const api = getApi();
@@ -3,6 +3,7 @@ export type CampaignModelQualityInput = {
3
3
  host?: string | null;
4
4
  model?: string | null;
5
5
  reasoningEffort?: string | null;
6
+ metadataSource?: string | null;
6
7
  };
7
8
  export type CampaignModelQualityStatus = "ok" | "warn" | "unknown";
8
9
  export type CampaignModelQualityResult = {
@@ -10,6 +11,7 @@ export type CampaignModelQualityResult = {
10
11
  host: CampaignModelHost;
11
12
  model: string | null;
12
13
  reasoningEffort: string | null;
14
+ metadataSource: string | null;
13
15
  recommendedModel: string;
14
16
  recommendedReasoningEffort: string;
15
17
  minimumSummary: string;
@@ -34,8 +36,7 @@ export type CampaignModelQualityConfig = {
34
36
  };
35
37
  warningCopy: {
36
38
  ok: string;
37
- staleCodexMetadata: string;
38
- unknownSettings: string;
39
+ skipped: string;
39
40
  belowMinimum: string;
40
41
  };
41
42
  };
@@ -1,8 +1,5 @@
1
- import { existsSync, readFileSync } from "fs";
2
- import { join } from "path";
3
- import { resolveSkillsDir } from "../skills.js";
4
1
  const DEFAULT_MODEL_QUALITY_CONFIG = {
5
- version: 1,
2
+ version: 2,
6
3
  hosts: {
7
4
  claude: {
8
5
  label: "Claude Code",
@@ -36,20 +33,24 @@ const DEFAULT_MODEL_QUALITY_CONFIG = {
36
33
  },
37
34
  },
38
35
  warningCopy: {
39
- ok: "Campaign model settings meet the configured minimum: {minimumSummary}.",
40
- staleCodexMetadata: "Codex host metadata appears stale: it can report GPT-5/default or GPT-5/high even when the UI is set to GPT 5.5 Extra High. Treating this as acceptable; continue without asking the user to switch.",
41
- unknownSettings: "Model settings were not provided by the host. Best campaigns need at least {minimumSummary}. Confirm the user is on one of those settings, or ask them to switch before continuing.",
42
- belowMinimum: "Best campaigns need at least {minimumSummary}. Current settings look below that: {currentSettings}. Please switch before continuing, or explicitly say to continue anyway.",
36
+ ok: "Active host model metadata meets the configured campaign floor: {currentSettings}.",
37
+ skipped: "Active host model metadata was not available from a trusted runtime source. Continue without asking the user to switch models.",
38
+ belowMinimum: "Active host metadata reports {currentSettings}, which is below the configured campaign floor: {minimumSummary}. Ask the user to switch before continuing, or explicitly say to continue anyway.",
43
39
  },
44
40
  };
45
- let cachedConfig = null;
41
+ const TRUSTED_METADATA_SOURCE_KEYWORDS = [
42
+ "codex_turn_metadata",
43
+ "claude_runtime_metadata",
44
+ "claude_session_context",
45
+ "active_turn_metadata",
46
+ "user_confirmed",
47
+ ];
46
48
  const normalize = (value) => String(value ?? "")
47
49
  .trim()
48
50
  .toLowerCase();
49
51
  const normalizeHost = (host) => {
50
52
  const normalized = normalize(host);
51
53
  if (normalized.includes("claude") ||
52
- normalized.includes("clod") ||
53
54
  normalized.includes("opus") ||
54
55
  normalized.includes("sonnet") ||
55
56
  normalized.includes("haiku")) {
@@ -62,12 +63,6 @@ const normalizeHost = (host) => {
62
63
  }
63
64
  return "unknown";
64
65
  };
65
- const normalizeHostFromInput = (host, model) => {
66
- const explicitHost = normalizeHost(host);
67
- if (explicitHost !== "unknown")
68
- return explicitHost;
69
- return normalizeHost(model);
70
- };
71
66
  const normalizeReasoning = (reasoning) => normalize(reasoning).replace(/[_\s-]+/g, "");
72
67
  const compareVersion = (candidate, minimum) => {
73
68
  const candidateParts = candidate.split(".").map((part) => Number(part));
@@ -83,50 +78,12 @@ const compareVersion = (candidate, minimum) => {
83
78
  }
84
79
  return 0;
85
80
  };
86
- const extractModelVersions = (model) => Array.from(normalize(model).matchAll(/\b(\d+(?:\.\d+){0,2})\b/g)).map((match) => match[1]);
87
- function modelContainsOtherHostFamily(model, currentHostConfig, config) {
88
- const normalizedModel = normalize(model).replace(/[_-]+/g, " ");
89
- if (!normalizedModel)
90
- return false;
91
- return Object.values(config.hosts)
92
- .filter((hostConfig) => hostConfig !== currentHostConfig)
93
- .some((hostConfig) => hostConfig.familyKeywords.some((keyword) => normalizedModel.includes(normalize(keyword))));
94
- }
95
- function mergeConfig(rawConfig) {
96
- return {
97
- ...DEFAULT_MODEL_QUALITY_CONFIG,
98
- ...rawConfig,
99
- hosts: {
100
- claude: {
101
- ...DEFAULT_MODEL_QUALITY_CONFIG.hosts.claude,
102
- ...(rawConfig.hosts?.claude || {}),
103
- },
104
- codex: {
105
- ...DEFAULT_MODEL_QUALITY_CONFIG.hosts.codex,
106
- ...(rawConfig.hosts?.codex || {}),
107
- },
108
- },
109
- warningCopy: {
110
- ...DEFAULT_MODEL_QUALITY_CONFIG.warningCopy,
111
- ...(rawConfig.warningCopy || {}),
112
- },
113
- };
114
- }
81
+ const extractModelVersions = (model) => {
82
+ const normalized = normalize(model).replace(/(?<=\d)[_-](?=\d)/g, ".");
83
+ return Array.from(normalized.matchAll(/\b(\d+(?:\.\d+){0,2})\b/g)).map((match) => match[1]);
84
+ };
115
85
  export function getCampaignModelQualityConfig() {
116
- if (cachedConfig)
117
- return cachedConfig;
118
- const configPath = join(resolveSkillsDir(), "create-campaign", "core", "model-quality.json");
119
- if (!existsSync(configPath)) {
120
- cachedConfig = DEFAULT_MODEL_QUALITY_CONFIG;
121
- return cachedConfig;
122
- }
123
- try {
124
- cachedConfig = mergeConfig(JSON.parse(readFileSync(configPath, "utf-8")));
125
- }
126
- catch {
127
- cachedConfig = DEFAULT_MODEL_QUALITY_CONFIG;
128
- }
129
- return cachedConfig;
86
+ return DEFAULT_MODEL_QUALITY_CONFIG;
130
87
  }
131
88
  export function getCampaignModelMinimumSummary(config = getCampaignModelQualityConfig()) {
132
89
  return `${config.hosts.claude.minimumModel} with ${config.hosts.claude.minimumReasoningEffort} reasoning or ${config.hosts.codex.minimumModel} with ${config.hosts.codex.minimumReasoningEffort} reasoning`;
@@ -148,12 +105,12 @@ function modelMeetsMinimum(model, hostConfig, options = {}) {
148
105
  hostConfig.familyKeywords.every((keyword) => normalizedModel.includes(normalize(keyword)));
149
106
  if (!familyMatches)
150
107
  return false;
151
- const versions = extractModelVersions(normalizedModel);
108
+ const versions = extractModelVersions(model);
152
109
  if (!hostConfig.minimumVersion)
153
110
  return true;
154
111
  return versions.some((version) => compareVersion(version, hostConfig.minimumVersion) >= 0);
155
112
  }
156
- function findAcceptedHostConfig(host, model, config) {
113
+ function findHostConfig(host, model, config) {
157
114
  const candidates = host === "unknown"
158
115
  ? [
159
116
  ["claude", config.hosts.claude],
@@ -161,104 +118,66 @@ function findAcceptedHostConfig(host, model, config) {
161
118
  ]
162
119
  : [[host, config.hosts[host]]];
163
120
  return candidates.find(([, hostConfig]) => modelMeetsMinimum(model, hostConfig, {
164
- familyKnownFromHost: host !== "unknown" &&
165
- !modelContainsOtherHostFamily(model, hostConfig, config),
121
+ familyKnownFromHost: host !== "unknown",
166
122
  }));
167
123
  }
168
- function looksLikeCodexStaleMetadata(host, model, reasoningEffort, config) {
169
- if (host !== "codex")
170
- return false;
171
- if (!model || !reasoningEffort)
172
- return false;
173
- const normalizedModel = normalize(model).replace(/[_-]+/g, " ");
174
- const normalizedReasoning = normalizeReasoning(reasoningEffort);
175
- if (!normalizedModel.includes("gpt"))
176
- return false;
177
- const versions = extractModelVersions(normalizedModel);
178
- const looksLikeBaseGpt5 = versions.some((version) => version === "5" || version === "5.0");
179
- if (!looksLikeBaseGpt5)
180
- return false;
181
- return (["default", "auto", "standard"].includes(normalizedReasoning) ||
182
- acceptsReasoning(reasoningEffort, config.hosts.codex));
124
+ function isTrustedMetadataSource(source) {
125
+ const normalized = normalize(source).replace(/[\s-]+/g, "_");
126
+ return TRUSTED_METADATA_SOURCE_KEYWORDS.some((keyword) => normalized.includes(keyword));
183
127
  }
184
128
  export function evaluateCampaignModelQuality(input = {}) {
185
129
  const config = getCampaignModelQualityConfig();
186
- const host = normalizeHostFromInput(input.host, input.model);
130
+ const host = normalizeHost([input.host, input.model].filter(Boolean).join(" "));
187
131
  const model = input.model?.trim() || null;
188
132
  const reasoningEffort = input.reasoningEffort?.trim() || null;
133
+ const metadataSource = input.metadataSource?.trim() || null;
189
134
  const recommendationHost = host === "claude" ? "claude" : "codex";
190
135
  const recommendedHostConfig = config.hosts[recommendationHost];
191
136
  const minimumSummary = getCampaignModelMinimumSummary(config);
192
- const recommendedModel = recommendedHostConfig.minimumModel;
193
- const recommendedReasoningEffort = recommendedHostConfig.recommendedReasoningEffort;
194
- if (!model && !reasoningEffort) {
137
+ const currentSettings = [
138
+ model ? `model "${model}"` : "unknown model",
139
+ reasoningEffort ? `reasoning "${reasoningEffort}"` : "unknown reasoning",
140
+ metadataSource ? `source "${metadataSource}"` : "unknown source",
141
+ ].join(", ");
142
+ const base = {
143
+ host,
144
+ model,
145
+ reasoningEffort,
146
+ metadataSource,
147
+ recommendedModel: recommendedHostConfig.minimumModel,
148
+ recommendedReasoningEffort: recommendedHostConfig.recommendedReasoningEffort,
149
+ minimumSummary,
150
+ metadataStale: false,
151
+ };
152
+ if (!isTrustedMetadataSource(metadataSource) || !model || !reasoningEffort) {
195
153
  return {
154
+ ...base,
196
155
  status: "unknown",
197
- host,
198
- model,
199
- reasoningEffort,
200
- recommendedModel,
201
- recommendedReasoningEffort,
202
- minimumSummary,
203
- confirmationRequired: true,
204
- metadataStale: false,
205
- message: formatCopy(config.warningCopy.unknownSettings, {
206
- minimumSummary,
207
- currentSettings: "unknown",
208
- }),
156
+ confirmationRequired: false,
157
+ message: config.warningCopy.skipped,
209
158
  };
210
159
  }
211
- const acceptedHostConfig = findAcceptedHostConfig(host, model, config);
160
+ const acceptedHostConfig = findHostConfig(host, model, config);
212
161
  const ok = Boolean(acceptedHostConfig && acceptsReasoning(reasoningEffort, acceptedHostConfig[1]));
213
162
  if (ok) {
214
163
  return {
164
+ ...base,
215
165
  status: "ok",
216
166
  host: acceptedHostConfig?.[0] ?? host,
217
- model,
218
- reasoningEffort,
219
- recommendedModel: acceptedHostConfig?.[1].minimumModel ?? recommendedModel,
167
+ recommendedModel: acceptedHostConfig?.[1].minimumModel ?? base.recommendedModel,
220
168
  recommendedReasoningEffort: acceptedHostConfig?.[1].recommendedReasoningEffort ??
221
- recommendedReasoningEffort,
222
- minimumSummary,
169
+ base.recommendedReasoningEffort,
223
170
  confirmationRequired: false,
224
- metadataStale: false,
225
171
  message: formatCopy(config.warningCopy.ok, {
226
172
  minimumSummary,
227
- currentSettings: "current settings",
173
+ currentSettings,
228
174
  }),
229
175
  };
230
176
  }
231
- if (looksLikeCodexStaleMetadata(host, model, reasoningEffort, config)) {
232
- return {
233
- status: "ok",
234
- host,
235
- model,
236
- reasoningEffort,
237
- recommendedModel,
238
- recommendedReasoningEffort,
239
- minimumSummary,
240
- confirmationRequired: false,
241
- metadataStale: true,
242
- message: formatCopy(config.warningCopy.staleCodexMetadata, {
243
- minimumSummary,
244
- currentSettings: "stale Codex host metadata",
245
- }),
246
- };
247
- }
248
- const currentSettings = [
249
- model ? `model "${model}"` : "unknown model",
250
- reasoningEffort ? `reasoning "${reasoningEffort}"` : "unknown reasoning",
251
- ].join(", ");
252
177
  return {
178
+ ...base,
253
179
  status: "warn",
254
- host,
255
- model,
256
- reasoningEffort,
257
- recommendedModel,
258
- recommendedReasoningEffort,
259
- minimumSummary,
260
180
  confirmationRequired: true,
261
- metadataStale: false,
262
181
  message: formatCopy(config.warningCopy.belowMinimum, {
263
182
  minimumSummary,
264
183
  currentSettings,