@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.
- package/README.md +25 -4
- package/dist/generated/column-schema-manifest.js +1 -1
- package/dist/server.js +1 -0
- package/dist/tools/blueprint-commit.js +1 -1
- package/dist/tools/bootstrap.d.ts +5 -0
- package/dist/tools/bootstrap.js +10 -5
- package/dist/tools/inbox.d.ts +308 -0
- package/dist/tools/inbox.js +544 -0
- package/dist/tools/linkedin.d.ts +76 -0
- package/dist/tools/linkedin.js +351 -18
- package/dist/tools/model-quality.d.ts +3 -2
- package/dist/tools/model-quality.js +49 -130
- package/dist/tools/readiness.js +44 -13
- package/dist/tools/registry.d.ts +15 -0
- package/package.json +1 -1
- package/skills/building-gtm-tables/SKILL.md +26 -0
- package/skills/building-gtm-tables/references/column-type-catalog.md +15 -1
- package/skills/building-gtm-tables/references/common-blueprints.fixtures.ts +54 -5
- package/skills/building-gtm-tables/references/common-blueprints.md +9 -0
- package/skills/create-campaign/SKILL.md +77 -32
- package/skills/create-campaign/core/model-quality.json +4 -5
- package/skills/create-campaign-v2/references/approval-gate-framing.md +6 -1
- package/skills/research/config.json +9 -0
package/dist/tools/linkedin.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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:
|
|
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: "
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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) =>
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
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 =
|
|
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
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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,
|