@koda-sl/baker-cli 0.158.2 → 0.160.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/dist/cli.js CHANGED
@@ -3501,7 +3501,7 @@ registerSchema({
3501
3501
  name: { type: "string", description: "Action name (short, action-verb, \u22646 words)", required: true },
3502
3502
  description: {
3503
3503
  type: "string",
3504
- description: "Context-complete description: what / why / where / done-when",
3504
+ description: "Context-complete description in markdown: what / why / where / done-when. Use `##` headings for those sections, `-` bullets for lists of items, and `**bold**` for the figures that drive the decision. Anything longer than a couple of sentences needs headings \u2014 it is read in a narrow panel.",
3505
3505
  required: false
3506
3506
  },
3507
3507
  tags: {
@@ -4154,7 +4154,11 @@ registerSchema({
4154
4154
  args: {
4155
4155
  id: { type: "string", description: "Action ID or tempId (temp_*) of a task staged in this chat", required: true },
4156
4156
  name: { type: "string", description: "New name", required: false },
4157
- description: { type: "string", description: "New description", required: false },
4157
+ description: {
4158
+ type: "string",
4159
+ description: "New description, in markdown \u2014 `##` headings per section, `-` bullets, `**bold**` for the decisive figures. REPLACES the whole description, so carry over anything still true.",
4160
+ required: false
4161
+ },
4158
4162
  tags: {
4159
4163
  type: "string",
4160
4164
  description: "Comma-separated tag slugs \u2014 REPLACES existing tags ('' clears)",
@@ -4176,7 +4180,7 @@ var updateCommand = defineCommand17({
4176
4180
  id: { type: "positional", description: "Action ID or tempId (temp_*)", required: false },
4177
4181
  "action-id": { type: "string", description: "Action ID or tempId (temp_*)", required: false },
4178
4182
  name: { type: "string", description: "New name", required: false },
4179
- description: { type: "string", description: "New description", required: false },
4183
+ description: { type: "string", description: "New description (markdown)", required: false },
4180
4184
  tags: { type: "string", description: "Comma-separated tag slugs \u2014 REPLACES existing ('' clears)", required: false },
4181
4185
  priority: { type: "string", description: `Priority: ${ACTION_PRIORITIES.join("|")}|none`, required: false }
4182
4186
  },
@@ -4840,7 +4844,10 @@ var callAdSchema = z13.object({
4840
4844
  var appAdSchema = z13.object({
4841
4845
  format: z13.literal("app"),
4842
4846
  headlines: z13.array(z13.object({ text: z13.string().min(1).max(30) })).min(1),
4843
- descriptions: z13.array(z13.object({ text: z13.string().min(1).max(90) })).min(1)
4847
+ descriptions: z13.array(z13.object({ text: z13.string().min(1).max(90) })).min(1),
4848
+ // An App campaign ad carries its images on its own content (`AppAdInfo.images`), not as
4849
+ // campaign-level asset links — same shape as a responsive display ad's marketing images.
4850
+ images: z13.array(refSchema).optional()
4844
4851
  });
4845
4852
  var videoAdSchema = z13.object({
4846
4853
  format: z13.literal("video"),
@@ -6179,6 +6186,443 @@ import { defineCommand as defineCommand22 } from "citty";
6179
6186
  // src/commands/ads/google/write-shared.ts
6180
6187
  import { readFileSync as readFileSync2 } from "fs";
6181
6188
 
6189
+ // src/engine/copy-tells/patterns.ts
6190
+ var PHRASE_TELLS = {
6191
+ // §33 — the fake-candid pause before an ordinary claim. The single loudest
6192
+ // tell in AI-written Meta primary text. Anchored to a sentence start by the
6193
+ // detector, so "take a look at" and "honestly, I agree" mid-sentence are safe.
6194
+ "rhetorical-opener": [
6195
+ "honestly\\?",
6196
+ "honestly,",
6197
+ "here's the thing",
6198
+ "here is the thing",
6199
+ "the thing is",
6200
+ "let's be honest",
6201
+ "let's be real",
6202
+ "real talk",
6203
+ "look,",
6204
+ "listen,",
6205
+ "la verdad es que",
6206
+ "sinceramente,",
6207
+ "seamos honestos",
6208
+ "seamos sinceros",
6209
+ "mira,",
6210
+ "escucha,",
6211
+ "te lo digo claro"
6212
+ ],
6213
+ // §9 — negative parallelism. Our landing doctrine already names this in prose;
6214
+ // this is the detector that was missing.
6215
+ "negation-pivot": [
6216
+ "it's not just",
6217
+ "it is not just",
6218
+ "it's not merely",
6219
+ "not only .{3,40} but also",
6220
+ "less about .{3,40} more about",
6221
+ "isn't about .{3,40} it's about",
6222
+ "no se trata solo de",
6223
+ "no se trata \xFAnicamente de",
6224
+ "no solo .{3,40} sino",
6225
+ "no es solo .{3,40} es",
6226
+ "m\xE1s que .{3,40}, es"
6227
+ ],
6228
+ // §3 — present participle hung off a comma to fake depth. Detector requires
6229
+ // the preceding comma, so a sentence-initial participle is not a tell.
6230
+ "participle-tail": [
6231
+ "ensuring",
6232
+ "highlighting",
6233
+ "underscoring",
6234
+ "emphasizing",
6235
+ "showcasing",
6236
+ "fostering",
6237
+ "empowering",
6238
+ "reflecting",
6239
+ "symbolizing",
6240
+ "contributing to",
6241
+ "encompassing",
6242
+ "cultivating",
6243
+ "permitiendo",
6244
+ "garantizando",
6245
+ "impulsando",
6246
+ "reflejando",
6247
+ "consolidando",
6248
+ "potenciando"
6249
+ ],
6250
+ // §8 — elaborate constructions standing in for "is" and "has".
6251
+ "copula-avoidance": [
6252
+ "serves as",
6253
+ "stands as",
6254
+ "boasts",
6255
+ "represents a",
6256
+ "marks a",
6257
+ "se erige como",
6258
+ "se posiciona como",
6259
+ "cuenta con",
6260
+ "se consolida como",
6261
+ "constituye un"
6262
+ ],
6263
+ // §27 — pretending to cut through noise before restating an ordinary point.
6264
+ "authority-trope": [
6265
+ "the real question is",
6266
+ "at its core",
6267
+ "what really matters",
6268
+ "the heart of the matter",
6269
+ "the deeper issue",
6270
+ "in reality,",
6271
+ "la verdadera pregunta",
6272
+ "en el fondo,",
6273
+ "lo que realmente importa",
6274
+ "en esencia,"
6275
+ ],
6276
+ // §28 — announcing the writing instead of doing it.
6277
+ signposting: [
6278
+ "let's dive in",
6279
+ "let's dive into",
6280
+ "let's explore",
6281
+ "let's break (?:this|it) down",
6282
+ "here's what you need to know",
6283
+ "here is what you need to know",
6284
+ "now let's look at",
6285
+ "without further ado",
6286
+ "vamos a ver",
6287
+ "vamos a analizar",
6288
+ "profundicemos",
6289
+ "esto es lo que necesitas saber",
6290
+ "te lo explicamos paso a paso"
6291
+ ],
6292
+ // §20 — chatbot correspondence pasted in as content. Always a defect: it means
6293
+ // the agent shipped its own conversational frame into client-facing copy.
6294
+ //
6295
+ // This is the only tell the landing gate fires at ONE occurrence, so every
6296
+ // entry has to be unambiguous. The delivery openers are therefore bound to the
6297
+ // noun the assistant hands over ("here is a summary", "aquí tienes el
6298
+ // resumen"): a bare "here is a…" / "aquí tienes…" is ordinary marketing copy.
6299
+ "chat-artifact": [
6300
+ "i hope this helps",
6301
+ "let me know if",
6302
+ "would you like me to",
6303
+ "want me to",
6304
+ "here (?:is|are) (?:an? |the )?(?:overview|summary|breakdown|rundown|outline|draft|revised version)",
6305
+ "here's (?:an? |the )?(?:overview|summary|breakdown|rundown|outline|draft|revised version)",
6306
+ "certainly!",
6307
+ "great question",
6308
+ "you're absolutely right",
6309
+ "espero que (?:te sirva|esto ayude)",
6310
+ "av\xEDsame si",
6311
+ "\xBFquieres que",
6312
+ "aqu\xED tienes (?:un |una |el |la |los |las )?(?:resumen|listado|desglose|borrador|esquema|propuesta|versi\xF3n)",
6313
+ "por supuesto!"
6314
+ ],
6315
+ // §5 — opinions attributed to nobody in particular.
6316
+ "vague-attribution": [
6317
+ "experts agree",
6318
+ "experts say",
6319
+ "experts argue",
6320
+ "studies show",
6321
+ "research shows",
6322
+ "industry reports",
6323
+ "observers have",
6324
+ "some critics",
6325
+ "it is believed that",
6326
+ "los expertos coinciden",
6327
+ "los expertos afirman",
6328
+ "los estudios demuestran",
6329
+ "seg\xFAn varios informes",
6330
+ "se cree que"
6331
+ ],
6332
+ // §25 — vague upbeat endings that assert nothing.
6333
+ "hollow-conclusion": [
6334
+ "the future looks bright",
6335
+ "exciting times",
6336
+ "a step in the right direction",
6337
+ "the possibilities are endless",
6338
+ "the sky's the limit",
6339
+ "el futuro es prometedor",
6340
+ "las posibilidades son infinitas",
6341
+ "un paso en la direcci\xF3n correcta",
6342
+ "el cielo es el l\xEDmite"
6343
+ ],
6344
+ // §23 — words that carry no load.
6345
+ "filler-phrase": [
6346
+ "in order to",
6347
+ "due to the fact that",
6348
+ "at this point in time",
6349
+ "has the ability to",
6350
+ "have the ability to",
6351
+ "it is important to note",
6352
+ "it is worth noting",
6353
+ "con el fin de",
6354
+ "debido al hecho de que",
6355
+ "en este momento del tiempo",
6356
+ "tiene la capacidad de",
6357
+ "cabe destacar",
6358
+ "cabe se\xF1alar",
6359
+ "es importante se\xF1alar"
6360
+ ],
6361
+ // §32 — ordinary claims dressed as reusable aphorisms.
6362
+ "aphorism-formula": [
6363
+ "the language of",
6364
+ "the currency of",
6365
+ "the architecture of",
6366
+ "the backbone of",
6367
+ "el lenguaje de",
6368
+ "la moneda de",
6369
+ "la columna vertebral de"
6370
+ ]
6371
+ };
6372
+ var SENTENCE_INITIAL_TELLS = /* @__PURE__ */ new Set(["rhetorical-opener"]);
6373
+ var COMMA_ANCHORED_TELLS = /* @__PURE__ */ new Set(["participle-tail"]);
6374
+ var STACCATO_MAX_WORDS = 4;
6375
+ var STACCATO_RUN = 3;
6376
+ var SYNONYM_OVERLAP = 0.6;
6377
+ var HEADLINE_SET_FLOOR = 3;
6378
+ var CYCLED_VERBS = [
6379
+ ["boost", "increase", "grow", "raise", "lift", "improve", "enhance", "maximize", "drive"],
6380
+ ["cut", "reduce", "lower", "slash", "shrink", "minimize", "decrease"],
6381
+ ["get", "obtain", "receive", "secure", "unlock", "claim"],
6382
+ ["fast", "quick", "rapid", "swift", "speedy", "instant"],
6383
+ ["aumenta", "incrementa", "mejora", "impulsa", "potencia", "maximiza", "multiplica"],
6384
+ ["reduce", "recorta", "baja", "disminuye", "minimiza"],
6385
+ ["consigue", "obt\xE9n", "logra", "alcanza", "desbloquea"],
6386
+ ["r\xE1pido", "veloz", "inmediato", "al instante"]
6387
+ ];
6388
+
6389
+ // src/engine/copy-tells/detect.ts
6390
+ var SAMPLE_MAX = 60;
6391
+ function sample(text) {
6392
+ const clean = text.replace(/\s+/g, " ").trim();
6393
+ return clean.length <= SAMPLE_MAX ? clean : `${clean.slice(0, SAMPLE_MAX)}\u2026`;
6394
+ }
6395
+ var DELIBERATE_REGEX = /\.\{\d+,\d+\}|\(\?:[^()]*\)|\\?\?/g;
6396
+ function escapeLiteral(text) {
6397
+ return text.replace(/[.*+^$?()|[\]{}\\]/g, (ch) => `\\${ch}`);
6398
+ }
6399
+ function toPattern(phrase) {
6400
+ let out = "";
6401
+ let last = 0;
6402
+ for (const m of phrase.matchAll(DELIBERATE_REGEX)) {
6403
+ const start = m.index ?? 0;
6404
+ out += escapeLiteral(phrase.slice(last, start)) + m[0];
6405
+ last = start + m[0].length;
6406
+ }
6407
+ return out + escapeLiteral(phrase.slice(last));
6408
+ }
6409
+ var WORD_START = "(?<![\\p{L}\\p{N}])";
6410
+ function matcherFor(id, phrase) {
6411
+ const body = toPattern(phrase);
6412
+ if (SENTENCE_INITIAL_TELLS.has(id)) return new RegExp(`(?:^|[.!?\xBF\xA1]\\s+|\\n)\\s*${body}`, "giu");
6413
+ if (COMMA_ANCHORED_TELLS.has(id)) return new RegExp(`,\\s+${body}\\b`, "giu");
6414
+ return new RegExp(`${WORD_START}${body}`, "giu");
6415
+ }
6416
+ var FALSE_RANGE_PATTERNS = [
6417
+ /\bfrom\s+([^,.;]{3,30}?)\s+to\s+([^,.;]{3,30}?)(?=[,.;]|$)/gi,
6418
+ /\bdesde\s+([^,.;]{3,30}?)\s+hasta\s+([^,.;]{3,30}?)(?=[,.;]|$)/gi
6419
+ ];
6420
+ function countFalseRanges(text) {
6421
+ let count = 0;
6422
+ let first = "";
6423
+ for (const re of FALSE_RANGE_PATTERNS) {
6424
+ for (const m of text.matchAll(re)) {
6425
+ const left = m[1] ?? "";
6426
+ const right = m[2] ?? "";
6427
+ if (/\d/.test(left) || /\d/.test(right)) continue;
6428
+ count++;
6429
+ if (!first) first = m[0] ?? "";
6430
+ }
6431
+ }
6432
+ return { count, first };
6433
+ }
6434
+ function countStaccato(text) {
6435
+ const sentences = text.split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean);
6436
+ let run = 0;
6437
+ let count = 0;
6438
+ let first = "";
6439
+ let runStart = 0;
6440
+ for (const [i, s] of sentences.entries()) {
6441
+ const words2 = s.split(/\s+/).filter(Boolean).length;
6442
+ if (words2 > 0 && words2 <= STACCATO_MAX_WORDS) {
6443
+ if (run === 0) runStart = i;
6444
+ run++;
6445
+ if (run === STACCATO_RUN) {
6446
+ count++;
6447
+ if (!first) first = sentences.slice(runStart, i + 1).join(" ");
6448
+ }
6449
+ } else {
6450
+ run = 0;
6451
+ }
6452
+ }
6453
+ return { count, first };
6454
+ }
6455
+ function countTitleCaseHeadings(text) {
6456
+ let count = 0;
6457
+ let first = "";
6458
+ for (const m of text.matchAll(/^\s{0,3}#{1,6}\s+(.+)$/gm)) {
6459
+ const heading = (m[1] ?? "").trim();
6460
+ const words2 = heading.split(/\s+/).filter((w) => new RegExp("\\p{L}", "u").test(w));
6461
+ if (words2.length < 4) continue;
6462
+ const capitalized = words2.filter((w) => new RegExp("^\\p{Lu}", "u").test(w)).length;
6463
+ if (capitalized / words2.length < 0.8) continue;
6464
+ count++;
6465
+ if (!first) first = heading;
6466
+ }
6467
+ return { count, first };
6468
+ }
6469
+ function countInlineHeaderList(text) {
6470
+ const matches = [...text.matchAll(/^\s*(?:[-*+]|\d+\.)\s+\*\*[^*\n]{2,40}:?\*\*:?\s/gm)];
6471
+ if (matches.length < 3) return { count: 0, first: "" };
6472
+ return { count: matches.length, first: (matches[0]?.[0] ?? "").trim() };
6473
+ }
6474
+ var EMOJI = /[\u{1F300}-\u{1FAFF}]|[\u{2600}-\u{27BF}]|[\u{1F000}-\u{1F2FF}]|\u{FE0F}/gu;
6475
+ var CURLY = /[‘’“”]/g;
6476
+ function countPhraseTell(text, id, phrases) {
6477
+ let count = 0;
6478
+ let first = "";
6479
+ for (const phrase of phrases) {
6480
+ for (const m of text.matchAll(matcherFor(id, phrase))) {
6481
+ count++;
6482
+ if (!first) first = m[0] ?? "";
6483
+ }
6484
+ }
6485
+ return { count, first };
6486
+ }
6487
+ function countPattern(text, re) {
6488
+ const matches = text.match(re) ?? [];
6489
+ return { count: matches.length, first: matches.join("") };
6490
+ }
6491
+ function detectCopyTells(text) {
6492
+ if (!text.trim()) return [];
6493
+ const results = [
6494
+ ...Object.entries(PHRASE_TELLS).map(([id, phrases]) => [
6495
+ id,
6496
+ countPhraseTell(text, id, phrases)
6497
+ ]),
6498
+ ["false-range", countFalseRanges(text)],
6499
+ ["staccato-drama", countStaccato(text)],
6500
+ ["title-case-heading", countTitleCaseHeadings(text)],
6501
+ ["inline-header-list", countInlineHeaderList(text)],
6502
+ ["curly-quote", countPattern(text, CURLY)],
6503
+ ["emoji", countPattern(text, EMOJI)]
6504
+ ];
6505
+ return results.filter(([, result]) => result.count > 0).map(([id, result]) => ({ id, count: result.count, sample: sample(result.first) }));
6506
+ }
6507
+ function words(headline) {
6508
+ return headline.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter(Boolean);
6509
+ }
6510
+ function canonical(word) {
6511
+ for (const [i, group] of CYCLED_VERBS.entries()) if (group.includes(word)) return `group:${i}`;
6512
+ return word;
6513
+ }
6514
+ function overlap(a, b) {
6515
+ if (a.size === 0 || b.size === 0) return 0;
6516
+ let shared = 0;
6517
+ for (const w of a) if (b.has(w)) shared++;
6518
+ return shared / Math.max(a.size, b.size);
6519
+ }
6520
+ function detectHeadlineSetTells(headlines) {
6521
+ const usable = headlines.map((h) => h.trim()).filter(Boolean);
6522
+ if (usable.length < HEADLINE_SET_FLOOR) return [];
6523
+ const sets = usable.map((h) => new Set(words(h).map(canonical)));
6524
+ const cycled = [];
6525
+ for (let i = 0; i < sets.length; i++) {
6526
+ for (let j = i + 1; j < sets.length; j++) {
6527
+ if (overlap(sets[i] ?? /* @__PURE__ */ new Set(), sets[j] ?? /* @__PURE__ */ new Set()) >= SYNONYM_OVERLAP) {
6528
+ cycled.push(`"${usable[i]}" / "${usable[j]}"`);
6529
+ }
6530
+ }
6531
+ }
6532
+ if (cycled.length === 0) return [];
6533
+ return [{ id: "synonym-cycling", count: cycled.length, sample: sample(cycled[0] ?? "") }];
6534
+ }
6535
+
6536
+ // src/commands/ads/copy-hints.ts
6537
+ var NOT_APPLICABLE_TO_ADS = /* @__PURE__ */ new Set([
6538
+ "title-case-heading",
6539
+ "staccato-drama",
6540
+ "emoji",
6541
+ "curly-quote",
6542
+ "inline-header-list"
6543
+ ]);
6544
+ var COPY_KEYS = /* @__PURE__ */ new Set([
6545
+ "headline",
6546
+ "headlines",
6547
+ "longheadline",
6548
+ "longheadlines",
6549
+ "shortheadline",
6550
+ "shortheadlines",
6551
+ "description",
6552
+ "descriptions",
6553
+ "primarytext",
6554
+ "primarytexts",
6555
+ "message",
6556
+ "messages",
6557
+ "commentary",
6558
+ "body",
6559
+ "bodies",
6560
+ "text",
6561
+ "calltoaction",
6562
+ "linkdescription",
6563
+ "sitelinktext",
6564
+ "callout",
6565
+ "callouts",
6566
+ "introtext"
6567
+ ]);
6568
+ var HEADLINE_SET_KEYS = /* @__PURE__ */ new Set(["headlines", "longheadlines", "shortheadlines"]);
6569
+ var MAX_HINTS = 5;
6570
+ var SYNONYM_PAIR_FLOOR = 2;
6571
+ function asStrings(value) {
6572
+ const one = (v) => {
6573
+ const text = typeof v === "string" ? v : v !== null && typeof v === "object" ? v.text : "";
6574
+ return typeof text === "string" && text.trim() ? text : void 0;
6575
+ };
6576
+ const values = Array.isArray(value) ? value : [value];
6577
+ return values.map(one).filter((v) => v !== void 0);
6578
+ }
6579
+ function collectCopy(value, depth = 0) {
6580
+ const texts = [];
6581
+ const headlineSets = [];
6582
+ if (depth > 4 || value === null || typeof value !== "object") return { texts, headlineSets };
6583
+ if (Array.isArray(value)) {
6584
+ for (const item of value) {
6585
+ const nested = collectCopy(item, depth + 1);
6586
+ texts.push(...nested.texts);
6587
+ headlineSets.push(...nested.headlineSets);
6588
+ }
6589
+ return { texts, headlineSets };
6590
+ }
6591
+ for (const [key, raw] of Object.entries(value)) {
6592
+ const normalized = key.toLowerCase().replace(/[_-]/g, "");
6593
+ const strings = COPY_KEYS.has(normalized) ? asStrings(raw) : [];
6594
+ if (strings.length > 0) {
6595
+ texts.push(...strings);
6596
+ if (HEADLINE_SET_KEYS.has(normalized) && strings.length > 1) headlineSets.push(strings);
6597
+ continue;
6598
+ }
6599
+ const nested = collectCopy(raw, depth + 1);
6600
+ texts.push(...nested.texts);
6601
+ headlineSets.push(...nested.headlineSets);
6602
+ }
6603
+ return { texts, headlineSets };
6604
+ }
6605
+ function hintFor(tell) {
6606
+ if (tell.id === "synonym-cycling") {
6607
+ return `AI COPY TELL \u2014 ${tell.count} headline pairs are the same claim reworded (${tell.sample}). A cycled set gives the auction nothing to choose between: make each headline a different claim, not a different verb.`;
6608
+ }
6609
+ return `AI COPY TELL (${tell.id}) \u2014 "${tell.sample}". This construction reads as machine-written; rewrite it as a plain, specific claim before publishing.`;
6610
+ }
6611
+ function adCopyHints(payload) {
6612
+ const { texts, headlineSets } = collectCopy(payload);
6613
+ if (texts.length === 0) return [];
6614
+ const tells = detectCopyTells(texts.join("\n")).filter((t) => !NOT_APPLICABLE_TO_ADS.has(t.id));
6615
+ for (const set of headlineSets) {
6616
+ for (const tell of detectHeadlineSetTells(set)) {
6617
+ if (tell.count >= SYNONYM_PAIR_FLOOR) tells.push(tell);
6618
+ }
6619
+ }
6620
+ tells.sort(
6621
+ (a, b) => (b.id === "synonym-cycling" ? 1 : 0) - (a.id === "synonym-cycling" ? 1 : 0) || b.count - a.count
6622
+ );
6623
+ return tells.slice(0, MAX_HINTS).map(hintFor);
6624
+ }
6625
+
6182
6626
  // src/commands/ads/google/draft-status.ts
6183
6627
  var OPERATION_LABELS = {
6184
6628
  create: "Creating",
@@ -6450,7 +6894,7 @@ async function stageGoogleOp(raw, hints) {
6450
6894
  try {
6451
6895
  const chatId = requireChatId();
6452
6896
  const response = await apiPost("/api/ads/google/draft/stage", { chatId, op: preflight.data });
6453
- const allHints = [...noopHints(response.data), ...hints ?? []];
6897
+ const allHints = [...noopHints(response.data), ...hints ?? [], ...adCopyHints(preflight.data)];
6454
6898
  writeJsonEnvelope(allHints.length > 0 ? { ...response, hints: allHints } : response);
6455
6899
  } catch (err) {
6456
6900
  handleGoogleError(err);
@@ -6492,15 +6936,16 @@ async function stageGoogleOps(rawOps, hints) {
6492
6936
  ...skipped.length > 0 ? [
6493
6937
  `${skipped.length} of ${ops.length} op(s) NOT STAGED \u2014 already in the requested state: ${skipped.map((s) => s.reason).join("; ")}`
6494
6938
  ] : [],
6495
- ...hints ?? []
6939
+ ...hints ?? [],
6940
+ ...adCopyHints(ops)
6496
6941
  ];
6497
6942
  writeJsonEnvelope(allHints.length > 0 ? { ...response, hints: allHints } : response);
6498
6943
  } catch (err) {
6499
6944
  handleGoogleError(err);
6500
6945
  }
6501
6946
  }
6502
- async function stageUpdate(kind, customerId, target, payload) {
6503
- await stageGoogleOp({ kind, customerId, target, payload });
6947
+ async function stageUpdate(kind, customerId, target, payload, hints) {
6948
+ await stageGoogleOp({ kind, customerId, target, payload }, hints);
6504
6949
  }
6505
6950
  async function stageTarget(kind, customerId, target) {
6506
6951
  await stageGoogleOp({ kind, customerId, target });
@@ -8377,6 +8822,25 @@ function demandGenContentFromFlags(args, base) {
8377
8822
  }
8378
8823
  return content;
8379
8824
  }
8825
+ function appContentFromFlags(args, base) {
8826
+ const content = {
8827
+ format: "app",
8828
+ ...base !== null && typeof base === "object" && !Array.isArray(base) ? base : {}
8829
+ };
8830
+ const headlines = listFlag(args.headlines);
8831
+ if (headlines) {
8832
+ content.headlines = headlines.map((text) => ({ text }));
8833
+ }
8834
+ const descriptions = listFlag(args.descriptions);
8835
+ if (descriptions) {
8836
+ content.descriptions = descriptions.map((text) => ({ text }));
8837
+ }
8838
+ const images = listFlag(args["image-assets"]);
8839
+ if (images) {
8840
+ content.images = images;
8841
+ }
8842
+ return content;
8843
+ }
8380
8844
  function adContentFromFlags(args, format, fileContent) {
8381
8845
  if (format === "responsiveSearch") {
8382
8846
  return fileContent ?? rsaContentFromFlags(args);
@@ -8390,8 +8854,48 @@ function adContentFromFlags(args, format, fileContent) {
8390
8854
  if (format === "demandGen") {
8391
8855
  return demandGenContentFromFlags(args, fileContent);
8392
8856
  }
8857
+ if (format === "app") {
8858
+ return appContentFromFlags(args, fileContent);
8859
+ }
8393
8860
  return fileContent ?? failWriteValidation(`--format ${format} needs --file with the ad content`);
8394
8861
  }
8862
+ var adContentArgs = {
8863
+ format: {
8864
+ type: "string",
8865
+ description: "responsiveSearch (default) | responsiveDisplay | call | app | video | demandGen"
8866
+ },
8867
+ headlines: { type: "string", description: "Comma-separated headlines (RSA/RDA/demandGen/app)" },
8868
+ descriptions: { type: "string", description: "Comma-separated descriptions (RSA/RDA/demandGen/app)" },
8869
+ path1: { type: "string" },
8870
+ path2: { type: "string" },
8871
+ "long-headline": { type: "string", description: "Long headline (responsiveDisplay)" },
8872
+ "business-name": { type: "string", description: "Business name (responsiveDisplay/demandGen)" },
8873
+ "marketing-images": {
8874
+ type: "string",
8875
+ description: "Comma-separated marketing image asset refs (responsiveDisplay)"
8876
+ },
8877
+ "square-marketing-images": {
8878
+ type: "string",
8879
+ description: "Comma-separated square marketing image asset refs (responsiveDisplay)"
8880
+ },
8881
+ "logo-images": { type: "string", description: "Comma-separated logo image asset refs (responsiveDisplay)" },
8882
+ "video-assets": {
8883
+ type: "string",
8884
+ description: `Comma-separated video asset refs (video format) \u2014 stage the YouTube video as an asset first (assets create --file '{"type":"youtubeVideo",\u2026}')`
8885
+ },
8886
+ "image-assets": { type: "string", description: "Comma-separated image asset refs (demandGen/app)" },
8887
+ "square-image-assets": { type: "string", description: "Comma-separated square image asset refs (demandGen)" },
8888
+ "logo-image-assets": { type: "string", description: "Comma-separated logo image asset refs (demandGen)" },
8889
+ "final-url": { type: "string", description: "Comma-separated final URLs" }
8890
+ };
8891
+ function assertNoContentFlags(args) {
8892
+ const passed = Object.keys(adContentArgs).filter((flag) => flag !== "format" && args[flag] !== void 0);
8893
+ if (passed.length > 0) {
8894
+ failWriteValidation(
8895
+ `${passed.map((flag) => `--${flag}`).join(", ")} only build an ad's content alongside --format (responsiveSearch | responsiveDisplay | call | app | video | demandGen) \u2014 add it, or pass --file with the complete content`
8896
+ );
8897
+ }
8898
+ }
8395
8899
  var adsCommand = defineCommand30({
8396
8900
  meta: { name: "ads", description: "Stage ad create/update/pause/resume/remove" },
8397
8901
  subCommands: {
@@ -8404,33 +8908,7 @@ var adsCommand = defineCommand30({
8404
8908
  ...customerIdArg,
8405
8909
  ...fileArg,
8406
8910
  "ad-group-ref": { type: "string", description: "Ad group ref" },
8407
- format: {
8408
- type: "string",
8409
- description: "responsiveSearch (default) | responsiveDisplay | call | app | video | demandGen"
8410
- },
8411
- headlines: { type: "string", description: "Comma-separated headlines (RSA/RDA/demandGen)" },
8412
- descriptions: { type: "string", description: "Comma-separated descriptions (RSA/RDA/demandGen)" },
8413
- path1: { type: "string" },
8414
- path2: { type: "string" },
8415
- "long-headline": { type: "string", description: "Long headline (responsiveDisplay)" },
8416
- "business-name": { type: "string", description: "Business name (responsiveDisplay/demandGen)" },
8417
- "marketing-images": {
8418
- type: "string",
8419
- description: "Comma-separated marketing image asset refs (responsiveDisplay)"
8420
- },
8421
- "square-marketing-images": {
8422
- type: "string",
8423
- description: "Comma-separated square marketing image asset refs (responsiveDisplay)"
8424
- },
8425
- "logo-images": { type: "string", description: "Comma-separated logo image asset refs (responsiveDisplay)" },
8426
- "video-assets": {
8427
- type: "string",
8428
- description: `Comma-separated video asset refs (video format) \u2014 stage the YouTube video as an asset first (assets create --file '{"type":"youtubeVideo",\u2026}')`
8429
- },
8430
- "image-assets": { type: "string", description: "Comma-separated image asset refs (demandGen)" },
8431
- "square-image-assets": { type: "string", description: "Comma-separated square image asset refs (demandGen)" },
8432
- "logo-image-assets": { type: "string", description: "Comma-separated logo image asset refs (demandGen)" },
8433
- "final-url": { type: "string", description: "Comma-separated final URLs" },
8911
+ ...adContentArgs,
8434
8912
  status: { type: "string" }
8435
8913
  },
8436
8914
  run: async ({ args }) => {
@@ -8446,17 +8924,27 @@ var adsCommand = defineCommand30({
8446
8924
  }
8447
8925
  }),
8448
8926
  update: defineCommand30({
8449
- meta: { name: "update", description: "Stage an ad update" },
8450
- args: { ...customerIdArg, ...fileArg, status: { type: "string" } },
8927
+ meta: {
8928
+ name: "update",
8929
+ description: "Stage an ad update. Any content change needs --format plus EVERY required field of that format (or --file with the complete content) \u2014 a field you send replaces its current value wholesale, so a list you pass must already include the entries you want to keep. Optional asset lists you omit entirely are left untouched on the ad. Status-only changes need neither --format nor content flags."
8930
+ },
8931
+ args: { ...customerIdArg, ...fileArg, ...adContentArgs, status: { type: "string" } },
8451
8932
  run: async ({ args }) => {
8452
8933
  const customerId = requireCustomerId(args);
8453
8934
  const file = loadJsonFileArg(args.file);
8454
- await stageUpdate(
8455
- "google.ad.update",
8456
- customerId,
8457
- requireTarget(args, "ad"),
8458
- mergePayload(file, { status: args.status })
8459
- );
8935
+ const format = args.format;
8936
+ if (!format) {
8937
+ assertNoContentFlags(args);
8938
+ }
8939
+ const payload = mergePayload(file, {
8940
+ status: args.status,
8941
+ ...format ? { content: adContentFromFlags(args, format, file.content) } : {}
8942
+ });
8943
+ await stageUpdate("google.ad.update", customerId, requireTarget(args, "ad"), payload, [
8944
+ ...payload.content !== void 0 ? [
8945
+ "Each content field you sent replaces its current value wholesale at publish \u2014 re-check that every headline, description, final URL and asset you want to KEEP is in this payload, not just the ones you changed."
8946
+ ] : []
8947
+ ]);
8460
8948
  }
8461
8949
  }),
8462
8950
  pause: statusCommand2("google.ad.pause", "ad"),
@@ -10535,7 +11023,7 @@ async function stageOp(raw, hints) {
10535
11023
  chatId,
10536
11024
  op: preflight.data
10537
11025
  });
10538
- const allHints = [...batchNudge(response.data), ...hints ?? []];
11026
+ const allHints = [...batchNudge(response.data), ...hints ?? [], ...adCopyHints(preflight.data)];
10539
11027
  writeJsonEnvelope(allHints.length > 0 ? { ...response, hints: allHints } : response);
10540
11028
  } catch (err) {
10541
11029
  handleLinkedinError(err);
@@ -10562,7 +11050,8 @@ async function stageLinkedinOps(rawOps, hints) {
10562
11050
  try {
10563
11051
  const chatId = requireChatId();
10564
11052
  const response = await apiPost("/api/ads/linkedin/draft/stage-batch", { chatId, ops });
10565
- writeJsonEnvelope(hints && hints.length > 0 ? { ...response, hints } : response);
11053
+ const allHints = [...hints ?? [], ...adCopyHints(ops)];
11054
+ writeJsonEnvelope(allHints.length > 0 ? { ...response, hints: allHints } : response);
10566
11055
  } catch (err) {
10567
11056
  handleLinkedinError(err);
10568
11057
  }
@@ -13648,7 +14137,8 @@ async function stageOp2(raw) {
13648
14137
  try {
13649
14138
  const chatId = requireChatId();
13650
14139
  const response = await apiPost("/api/ads/meta/draft/stage", { chatId, op: preflight.data });
13651
- writeJsonEnvelope(response);
14140
+ const copyHints = adCopyHints(preflight.data);
14141
+ writeJsonEnvelope(copyHints.length > 0 ? { ...response, hints: copyHints } : response);
13652
14142
  } catch (err) {
13653
14143
  handleMetaError(err);
13654
14144
  }
@@ -17507,10 +17997,10 @@ function estSpeechS(text) {
17507
17997
  var OBSERVED_WPS_MIN = 1;
17508
17998
  var OBSERVED_WPS_MAX = 6;
17509
17999
  function estSpeechWindowS(text, startS, endS) {
17510
- const words = wordCount(text);
18000
+ const words2 = wordCount(text);
17511
18001
  const window = (endS ?? 0) - (startS ?? 0);
17512
- if (words > 0 && window > 0.3) {
17513
- const wps = words / window;
18002
+ if (words2 > 0 && window > 0.3) {
18003
+ const wps = words2 / window;
17514
18004
  if (wps >= OBSERVED_WPS_MIN && wps <= OBSERVED_WPS_MAX) return window;
17515
18005
  }
17516
18006
  return estSpeechS(text);
@@ -18362,10 +18852,10 @@ function scrubFloatSentences(text, floatDescs) {
18362
18852
  if (floatDescs.length === 0 || !text) return text;
18363
18853
  const tokenSets = floatDescs.map((d) => new Set(floatTokens(d)));
18364
18854
  const kept = text.split(/(?<=[.!?])\s+/).filter((sentence) => {
18365
- const words = new Set(floatTokens(sentence));
18855
+ const words2 = new Set(floatTokens(sentence));
18366
18856
  return !tokenSets.some((ts) => {
18367
18857
  let hits = 0;
18368
- for (const w of words) if (ts.has(w)) hits++;
18858
+ for (const w of words2) if (ts.has(w)) hits++;
18369
18859
  return hits >= 2;
18370
18860
  });
18371
18861
  }).join(" ").trim();
@@ -19095,10 +19585,10 @@ function isOnCameraSpeaker(speaker, casts, cameraOn) {
19095
19585
  if (NARRATOR_SPEAKERS.has(speaker.toLowerCase())) return false;
19096
19586
  return casts.has(speaker);
19097
19587
  }
19098
- function makePresenterPresent(slots, canonical, opts = {}) {
19588
+ function makePresenterPresent(slots, canonical2, opts = {}) {
19099
19589
  const personSlots = slots.filter((s) => s.type.toLowerCase() === "person");
19100
19590
  const bySpeaker = /* @__PURE__ */ new Map();
19101
- for (const slot of personSlots) if (slot.castId) bySpeaker.set(canonical(slot.castId), slot.presence);
19591
+ for (const slot of personSlots) if (slot.castId) bySpeaker.set(canonical2(slot.castId), slot.presence);
19102
19592
  const solePerson = !opts.strict && personSlots.length === 1 ? personSlots[0].presence : null;
19103
19593
  return (speaker, sceneIndex) => {
19104
19594
  const presence = bySpeaker.get(speaker) ?? solePerson;
@@ -19130,17 +19620,17 @@ function joinDialogueTexts(texts) {
19130
19620
  }
19131
19621
  const prevWords = prev.split(/\s+/).filter(Boolean);
19132
19622
  const nextWords = text.split(/\s+/).filter(Boolean);
19133
- let overlap = 0;
19623
+ let overlap2 = 0;
19134
19624
  const maxK = Math.min(JOIN_DEDUP_MAX_WORDS, prevWords.length, nextWords.length);
19135
19625
  for (let k = maxK; k >= 1; k--) {
19136
19626
  const tail = prevWords.slice(-k).map(joinKey).join(" ");
19137
19627
  const head = nextWords.slice(0, k).map(joinKey).join(" ");
19138
19628
  if (tail && tail === head) {
19139
- overlap = k;
19629
+ overlap2 = k;
19140
19630
  break;
19141
19631
  }
19142
19632
  }
19143
- const rest = nextWords.slice(overlap).join(" ");
19633
+ const rest = nextWords.slice(overlap2).join(" ");
19144
19634
  if (rest) out.push(rest);
19145
19635
  }
19146
19636
  return out.join(" ");
@@ -19158,13 +19648,13 @@ function collapseVoiceover(blueprint) {
19158
19648
  const presenter = [...presenters][0];
19159
19649
  return (speaker) => NARRATOR_SPEAKERS.has(speaker.toLowerCase()) ? presenter : speaker;
19160
19650
  }
19161
- function multiSpeakerScenes(blueprint, casts, cameraOn, canonical, presentStrict) {
19651
+ function multiSpeakerScenes(blueprint, casts, cameraOn, canonical2, presentStrict) {
19162
19652
  const multiSpeaker = /* @__PURE__ */ new Set();
19163
19653
  blueprint.scenes.forEach((scene, i) => {
19164
19654
  const onCamAll = new Set(
19165
19655
  (scene.dialogue ?? []).map((l) => l.speaker ?? "voiceover").filter((sp) => isOnCameraSpeaker(sp, casts, cameraOn))
19166
19656
  );
19167
- const onCamPresent = [...onCamAll].filter((sp) => presentStrict(canonical(sp), i));
19657
+ const onCamPresent = [...onCamAll].filter((sp) => presentStrict(canonical2(sp), i));
19168
19658
  const effective = onCamPresent.length > 0 ? new Set(onCamPresent) : onCamAll;
19169
19659
  if (effective.size >= 2) multiSpeaker.add(i);
19170
19660
  });
@@ -19195,14 +19685,14 @@ function dialogueLines(blueprint, ctx) {
19195
19685
  });
19196
19686
  }).sort((a, b) => a.start - b.start);
19197
19687
  }
19198
- function buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, presentStrict) {
19688
+ function buildPhrases(blueprint, canonical2, compositeScenes, presenterPresent, presentStrict) {
19199
19689
  const casts = castIdSet(blueprint);
19200
19690
  const cameraOn = onCameraDialogue(blueprint);
19201
- const multiSpeaker = multiSpeakerScenes(blueprint, casts, cameraOn, canonical, presentStrict);
19691
+ const multiSpeaker = multiSpeakerScenes(blueprint, casts, cameraOn, canonical2, presentStrict);
19202
19692
  const lines = dialogueLines(blueprint, {
19203
19693
  compositeScenes,
19204
19694
  multiSpeaker,
19205
- canonical,
19695
+ canonical: canonical2,
19206
19696
  casts,
19207
19697
  cameraOn,
19208
19698
  presenterPresent
@@ -19251,12 +19741,12 @@ function buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, p
19251
19741
  flush();
19252
19742
  return phrases;
19253
19743
  }
19254
- function makeVoiceFactory(blueprint, canonical, nodes) {
19744
+ function makeVoiceFactory(blueprint, canonical2, nodes) {
19255
19745
  const bySpeaker = /* @__PURE__ */ new Map();
19256
19746
  const describe = (speaker) => {
19257
19747
  for (const scene of blueprint.scenes)
19258
19748
  for (const line of scene.dialogue ?? [])
19259
- if (canonical(line.speaker ?? "voiceover") === speaker && line.voice_description) return line.voice_description;
19749
+ if (canonical2(line.speaker ?? "voiceover") === speaker && line.voice_description) return line.voice_description;
19260
19750
  const cast = blueprint.global?.cast?.find((c) => c.id === speaker);
19261
19751
  return cast?.description ?? blueprint.global?.voiceover?.voice_description ?? `${speaker} voice`;
19262
19752
  };
@@ -19443,15 +19933,15 @@ function emitPhraseTts(phrase, voiceNode, idx, used, clock, nodes, out, language
19443
19933
  speaker: phrase.speaker
19444
19934
  });
19445
19935
  }
19446
- function emitCompositeInTimeline(composite, scene, i, isLast, env, canonical, ensureVoiceNode, usedVoIds, nodes, out) {
19936
+ function emitCompositeInTimeline(composite, scene, i, isLast, env, canonical2, ensureVoiceNode, usedVoIds, nodes, out) {
19447
19937
  const present2 = slotsForScene(env.slots, i);
19448
19938
  const onCam = (scene.dialogue ?? []).filter(
19449
19939
  (l) => Boolean(l.line?.trim()) && isOnCameraSpeaker(l.speaker ?? "voiceover", env.casts, env.cameraOn)
19450
19940
  );
19451
- const distinctSpeakers = new Set(onCam.map((l) => canonical(l.speaker ?? "voiceover")));
19941
+ const distinctSpeakers = new Set(onCam.map((l) => canonical2(l.speaker ?? "voiceover")));
19452
19942
  let nativeTurn;
19453
19943
  if (onCam.length > 0 && distinctSpeakers.size === 1) {
19454
- const speaker = canonical(onCam[0]?.speaker ?? "voiceover");
19944
+ const speaker = canonical2(onCam[0]?.speaker ?? "voiceover");
19455
19945
  const voiceNode = ensureVoiceNode(speaker);
19456
19946
  const start = onCam[0]?.start_s ?? scene.start_s ?? 0;
19457
19947
  const end = onCam[onCam.length - 1]?.end_s ?? scene.end_s ?? start;
@@ -19496,7 +19986,7 @@ function emitCompositeInTimeline(composite, scene, i, isLast, env, canonical, en
19496
19986
  onCam,
19497
19987
  scene,
19498
19988
  i,
19499
- canonical,
19989
+ canonical2,
19500
19990
  ensureVoiceNode,
19501
19991
  usedVoIds,
19502
19992
  env.clock,
@@ -19506,10 +19996,10 @@ function emitCompositeInTimeline(composite, scene, i, isLast, env, canonical, en
19506
19996
  );
19507
19997
  }
19508
19998
  }
19509
- function emitCompositeMultiSpeakerVoice(onCam, scene, i, canonical, ensureVoiceNode, usedVoIds, clock, nodes, out, languageCode) {
19999
+ function emitCompositeMultiSpeakerVoice(onCam, scene, i, canonical2, ensureVoiceNode, usedVoIds, clock, nodes, out, languageCode) {
19510
20000
  const bySpeaker = /* @__PURE__ */ new Map();
19511
20001
  for (const l of onCam) {
19512
- const speaker = canonical(l.speaker ?? "voiceover");
20002
+ const speaker = canonical2(l.speaker ?? "voiceover");
19513
20003
  const text = l.line.trim();
19514
20004
  const start = l.start_s ?? scene.start_s ?? 0;
19515
20005
  const end = l.end_s ?? start + estSpeechS(text);
@@ -19662,8 +20152,8 @@ function buildTimeline(blueprint, slots, opts, nodes) {
19662
20152
  if (layeredComposition(s, uiRouted.has(i))) compositeScenes.add(i);
19663
20153
  });
19664
20154
  }
19665
- const canonical = collapseVoiceover(blueprint);
19666
- const ensureVoiceNode = makeVoiceFactory(blueprint, canonical, nodes);
20155
+ const canonical2 = collapseVoiceover(blueprint);
20156
+ const ensureVoiceNode = makeVoiceFactory(blueprint, canonical2, nodes);
19667
20157
  const aspect = resolveAspect(blueprint.source?.aspect_ratio, opts.aspect, genAspectsFor(opts.videoModel));
19668
20158
  const env = {
19669
20159
  blueprint,
@@ -19690,9 +20180,9 @@ function buildTimeline(blueprint, slots, opts, nodes) {
19690
20180
  nativeSegments: [],
19691
20181
  sceneSlice: /* @__PURE__ */ new Map()
19692
20182
  };
19693
- const presenterPresent = makePresenterPresent(slots, canonical);
19694
- const presentStrict = makePresenterPresent(slots, canonical, { strict: true });
19695
- const phrases = buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, presentStrict);
20183
+ const presenterPresent = makePresenterPresent(slots, canonical2);
20184
+ const presentStrict = makePresenterPresent(slots, canonical2, { strict: true });
20185
+ const phrases = buildPhrases(blueprint, canonical2, compositeScenes, presenterPresent, presentStrict);
19696
20186
  const usedVoIds = /* @__PURE__ */ new Set();
19697
20187
  const claimed = /* @__PURE__ */ new Set();
19698
20188
  phrases.forEach((phrase, k) => {
@@ -19716,7 +20206,7 @@ function buildTimeline(blueprint, slots, opts, nodes) {
19716
20206
  i,
19717
20207
  i === lastIndex,
19718
20208
  env,
19719
- canonical,
20209
+ canonical2,
19720
20210
  ensureVoiceNode,
19721
20211
  usedVoIds,
19722
20212
  nodes,
@@ -28521,6 +29011,24 @@ var BUZZWORDS = [
28521
29011
  "drive results",
28522
29012
  "harness the power"
28523
29013
  ];
29014
+ var COPY_TELL_GATES = {
29015
+ "chat-artifact": { minCount: 1, severity: "warn" },
29016
+ "rhetorical-opener": { minCount: 2, severity: "warn" },
29017
+ "negation-pivot": { minCount: 2, severity: "warn" },
29018
+ "vague-attribution": { minCount: 2, severity: "warn" },
29019
+ "participle-tail": { minCount: 2, severity: "advisory" },
29020
+ "copula-avoidance": { minCount: 2, severity: "advisory" },
29021
+ "authority-trope": { minCount: 2, severity: "advisory" },
29022
+ signposting: { minCount: 2, severity: "advisory" },
29023
+ "hollow-conclusion": { minCount: 2, severity: "advisory" },
29024
+ "filler-phrase": { minCount: 2, severity: "advisory" },
29025
+ "aphorism-formula": { minCount: 2, severity: "advisory" },
29026
+ "false-range": { minCount: 2, severity: "advisory" },
29027
+ "staccato-drama": { minCount: 2, severity: "advisory" },
29028
+ "title-case-heading": { minCount: 2, severity: "advisory" },
29029
+ "inline-header-list": { minCount: 3, severity: "advisory" },
29030
+ emoji: { minCount: 3, severity: "advisory" }
29031
+ };
28524
29032
  var AUTONOMY_CLAIMS = [
28525
29033
  "fully autonomous",
28526
29034
  "completely autonomous",
@@ -28678,6 +29186,86 @@ var RULE_META = {
28678
29186
  severity: "warn",
28679
29187
  note: "Dense em-dashes are an AI cadence tell (and brand copy bans them). Recast with commas, colons, or full stops."
28680
29188
  },
29189
+ "chat-artifact": {
29190
+ family: "copy",
29191
+ severity: "warn",
29192
+ note: "Chatbot framing ('Here is an overview\u2026', 'av\xEDsame si') leaked into the page. Delete it and state the claim directly."
29193
+ },
29194
+ "rhetorical-opener": {
29195
+ family: "copy",
29196
+ severity: "warn",
29197
+ note: "Fake-candid openers ('Honestly?', 'La verdad es que') manufacture intimacy before an ordinary claim. Just say the thing."
29198
+ },
29199
+ "negation-pivot": {
29200
+ family: "copy",
29201
+ severity: "warn",
29202
+ note: "'It's not just X, it's Y' / 'no se trata solo de' is now a louder AI tell than any single word. Cut the setup, keep the claim."
29203
+ },
29204
+ "vague-attribution": {
29205
+ family: "copy",
29206
+ severity: "warn",
29207
+ note: "'Experts agree' / 'los estudios demuestran' attributes to nobody. Name the source and the year, or drop the claim."
29208
+ },
29209
+ "participle-tail": {
29210
+ family: "copy",
29211
+ severity: "advisory",
29212
+ note: "Participles hung off a comma (', ensuring\u2026', ', permitiendo\u2026') fake depth. End the sentence, or make it a real clause."
29213
+ },
29214
+ "copula-avoidance": {
29215
+ family: "copy",
29216
+ severity: "advisory",
29217
+ note: "'serves as' / 'cuenta con' dodges plain 'is' and 'has'. Use the simple verb \u2014 it reads as confidence, not flatness."
29218
+ },
29219
+ "authority-trope": {
29220
+ family: "copy",
29221
+ severity: "advisory",
29222
+ note: "'At its core' / 'en el fondo' pretends to reach a deeper truth before restating an ordinary point. Make the point once."
29223
+ },
29224
+ signposting: {
29225
+ family: "copy",
29226
+ severity: "advisory",
29227
+ note: "Announcing the writing ('Let's dive in', 'vamos a ver') instead of doing it. Delete the announcement, keep the content."
29228
+ },
29229
+ "hollow-conclusion": {
29230
+ family: "copy",
29231
+ severity: "advisory",
29232
+ note: "'The future looks bright' / 'las posibilidades son infinitas' asserts nothing. End on a concrete next step or a number."
29233
+ },
29234
+ "filler-phrase": {
29235
+ family: "copy",
29236
+ severity: "advisory",
29237
+ note: "Filler ('in order to', 'cabe destacar que') costs words and says nothing. Cut to the verb."
29238
+ },
29239
+ "aphorism-formula": {
29240
+ family: "copy",
29241
+ severity: "advisory",
29242
+ note: "'X is the currency of Y' sounds profound without adding precision. Replace it with the concrete claim it gestures at."
29243
+ },
29244
+ "false-range": {
29245
+ family: "copy",
29246
+ severity: "advisory",
29247
+ note: "'From onboarding to retention' pairs ends that aren't on a scale. List what you actually cover."
29248
+ },
29249
+ "staccato-drama": {
29250
+ family: "copy",
29251
+ severity: "advisory",
29252
+ note: "A run of very short sentences manufactures drama. One clipped line lands; three in a row read as engineered."
29253
+ },
29254
+ "title-case-heading": {
29255
+ family: "copy",
29256
+ severity: "advisory",
29257
+ note: "Title Case headings are an AI tell \u2014 and in Spanish the convention doesn't exist at all. Use sentence case."
29258
+ },
29259
+ "inline-header-list": {
29260
+ family: "copy",
29261
+ severity: "advisory",
29262
+ note: "'**Speed:** it is fast' bullets are AI list shape. Write the sentence, or let the bullet carry the content directly."
29263
+ },
29264
+ emoji: {
29265
+ family: "copy",
29266
+ severity: "advisory",
29267
+ note: "Emoji-decorated copy is a chatbot habit. Let the words and the layout carry the emphasis."
29268
+ },
28681
29269
  "dark-glow": {
28682
29270
  family: "borders_depth",
28683
29271
  severity: "warn",
@@ -28880,6 +29468,14 @@ function includesPhraseAtWordStart(haystack, phrase) {
28880
29468
  function stripHtmlToText(html) {
28881
29469
  return html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ").replace(/<!--[\s\S]*?-->/g, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
28882
29470
  }
29471
+ function stripHtmlPreservingHeadings(html) {
29472
+ return html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ").replace(/<!--[\s\S]*?-->/g, " ").replace(
29473
+ /<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi,
29474
+ (_all, _lvl, inner) => `
29475
+ ## ${String(inner).replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()}
29476
+ `
29477
+ ).replace(/<\/(?:p|div|section|li|tr|br)>|<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, " ").replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n");
29478
+ }
28883
29479
  function blankAstroFrontmatter(text) {
28884
29480
  if (!text.startsWith("---")) return text;
28885
29481
  const lines = text.split("\n");
@@ -29064,6 +29660,23 @@ var ANALYZERS = [
29064
29660
  return [{ id: "aphoristic-cadence", snippet: `${count} aphoristic constructions: "${firstSample}"`, file }];
29065
29661
  }
29066
29662
  },
29663
+ // Shared AI copy tells (engine/copy-tells) — the Wikipedia "Signs of AI
29664
+ // writing" set, English and Spanish. Page-scope: every gate here is a count
29665
+ // threshold, and a landing is always split across small components, so
29666
+ // measuring per file would dilute a page-wide habit into nothing.
29667
+ {
29668
+ scope: "page",
29669
+ run: (text, file) => {
29670
+ const body = stripHtmlPreservingHeadings(text);
29671
+ const out = [];
29672
+ for (const tell of detectCopyTells(body)) {
29673
+ const gate = COPY_TELL_GATES[tell.id];
29674
+ if (!gate || tell.count < gate.minCount) continue;
29675
+ out.push({ id: tell.id, snippet: `${tell.count}\xD7 \u2014 "${tell.sample}"`, file });
29676
+ }
29677
+ return out;
29678
+ }
29679
+ },
29067
29680
  // Gradient text in authored CSS. A rule block spans lines, so this scans a
29068
29681
  // ±6-line window around each `background-clip: text` instead of one line
29069
29682
  // (the Tailwind class form stays a line matcher above).
@@ -29494,6 +30107,20 @@ function parseScope(raw) {
29494
30107
  function actingUserId() {
29495
30108
  return getEnv().BAKER_ACTING_USER_ID;
29496
30109
  }
30110
+ function customEntry(server) {
30111
+ const failed = server.health && !server.health.ok;
30112
+ const problem = server.health?.message ?? "This server's address is not serving tools.";
30113
+ return {
30114
+ name: server.name,
30115
+ scope: server.scope,
30116
+ enabled: server.enabled,
30117
+ ...failed && !server.health?.transient ? { usable: false, problem } : {},
30118
+ ...failed && server.health?.transient ? { lastCheck: "failed", problem } : {},
30119
+ ...failed && server.health?.suggestedUrl ? { suggestedAddress: server.health.suggestedUrl } : {}
30120
+ };
30121
+ }
30122
+ var BROKEN_SERVER_HINT = "A server marked usable=false will NOT answer: its tools are absent from this turn. Say plainly that the tool is not working and relay `problem`; never claim its tools are loading, coming next message, or temporarily unavailable. Fixing it needs a person: the user updates its address in the dashboard (Brain \u2192 Integrations \u2192 MCPs). If `suggestedAddress` is present, that is the address to use.";
30123
+ var LAST_CHECK_FAILED_HINT = "A server with lastCheck=failed was unreachable or erroring when it was last checked \u2014 that may already have passed, and its tools ARE loaded this turn. Try the tool; if it works, say nothing about the check. If it fails, report `problem` as what happened rather than guessing.";
29497
30124
  function parseHeaders(raw) {
29498
30125
  const list = raw === void 0 ? [] : Array.isArray(raw) ? raw : [raw];
29499
30126
  const headers = {};
@@ -29540,8 +30167,14 @@ A tool the user names that is NOT listed here is simply not connected yet.`
29540
30167
  ...c.accountLabel ? { account: c.accountLabel } : {},
29541
30168
  ...c.readOnly ? { readOnly: true } : {}
29542
30169
  }));
29543
- const custom = data.custom.map((s) => ({ name: s.name, scope: s.scope, enabled: s.enabled }));
30170
+ const custom = data.custom.map(customEntry);
29544
30171
  const hints = [];
30172
+ if (data.custom.some((s) => s.health && !s.health.ok && !s.health.transient)) {
30173
+ hints.push(BROKEN_SERVER_HINT);
30174
+ }
30175
+ if (data.custom.some((s) => s.health && !s.health.ok && s.health.transient)) {
30176
+ hints.push(LAST_CHECK_FAILED_HINT);
30177
+ }
29545
30178
  if (data.managedDisabled) {
29546
30179
  hints.push("Managed integrations are disabled for this company \u2014 only custom MCP servers load.");
29547
30180
  }
@@ -29577,7 +30210,15 @@ var listCommand13 = defineCommand139({
29577
30210
  "/api/mcp/custom",
29578
30211
  user ? { actingUserId: user } : void 0
29579
30212
  );
29580
- writeJson({ ok: true, data: data.servers });
30213
+ writeJson({
30214
+ ok: true,
30215
+ // Detail view: the full row (address included) plus the usability verdict.
30216
+ // `health` itself is folded into `usable`/`problem` by `customEntry`.
30217
+ data: data.servers.map((server) => ({ ...server, health: void 0, ...customEntry(server) })),
30218
+ ...data.servers.some((s) => s.health && !s.health.ok) ? {
30219
+ hints: data.servers.some((s) => s.health && !s.health.ok && !s.health.transient) ? [BROKEN_SERVER_HINT] : [LAST_CHECK_FAILED_HINT]
30220
+ } : {}
30221
+ });
29581
30222
  } catch (err) {
29582
30223
  fail6(err);
29583
30224
  }
@@ -29588,7 +30229,11 @@ registerSchema({
29588
30229
  description: "Register a custom MCP server. Tools appear as mcp__<name>__* on the next message. URL must be HTTPS. Scope: company (default, all chats) | org (org admin key) | user (only the current sender).",
29589
30230
  args: {
29590
30231
  name: { type: "string", description: "Server name \u2192 tools appear as mcp__<name>__*", required: true },
29591
- url: { type: "string", description: "HTTPS MCP endpoint", required: true },
30232
+ url: {
30233
+ type: "string",
30234
+ description: "Full HTTPS MCP endpoint (commonly ends in /mcp, not just the domain)",
30235
+ required: true
30236
+ },
29592
30237
  scope: { type: "string", description: "user | user_org | company | org (default company)", required: false },
29593
30238
  header: { type: "string", description: 'Auth header "Key: Value" (repeatable)', required: false }
29594
30239
  }
@@ -29607,7 +30252,11 @@ Examples:
29607
30252
  },
29608
30253
  args: {
29609
30254
  name: { type: "string", description: "Server name (mcp__<name>__*)", required: true },
29610
- url: { type: "string", description: "HTTPS MCP endpoint", required: true },
30255
+ url: {
30256
+ type: "string",
30257
+ description: "Full HTTPS MCP endpoint (commonly ends in /mcp, not just the domain)",
30258
+ required: true
30259
+ },
29611
30260
  scope: { type: "string", description: "user | user_org | company | org (default company)", required: false },
29612
30261
  header: { type: "string", description: 'Auth header "Key: Value" (repeatable)', required: false }
29613
30262
  },
@@ -29623,7 +30272,14 @@ Examples:
29623
30272
  ...user ? { actingUserId: user } : {},
29624
30273
  ...headers ? { headers } : {}
29625
30274
  });
29626
- writeJson({ ok: true, data });
30275
+ writeJson({
30276
+ ok: true,
30277
+ data,
30278
+ hints: [
30279
+ "The address must be the full MCP endpoint (commonly ending in /mcp), not just the domain \u2014 a domain on its own usually answers sign-in and then serves no tools.",
30280
+ "The address is checked in the background. Run `baker mcp connected` on the next message to confirm it serves tools before promising the user anything."
30281
+ ]
30282
+ });
29627
30283
  } catch (err) {
29628
30284
  fail6(err);
29629
30285
  }