@ahmednawaz/crank 0.1.2

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.
@@ -0,0 +1,991 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Glean agent client for Crank.
5
+ * Uses short form payloads (long feature/proactive strings → HTTP 400).
6
+ *
7
+ * Streaming: POST /agents/runs/stream returns SSE `event: message` chunks.
8
+ * Motive Robot Content Designer typically emits a quick USER ack, then ~55–90s
9
+ * of silence while the agent runs, then GLEAN_AI token deltas until done
10
+ * (~90–140s total). Stream vs /runs/wait does not materially change latency —
11
+ * both block until the agent finishes. Stream only adds live progress UI.
12
+ * Set GLEAN_USE_STREAM=false to force wait-only.
13
+ *
14
+ * Option count: CRANK_GLEAN_OPTION_COUNT (default 3). Fewer options may reduce
15
+ * agent work slightly; most latency is inherent agent runtime (~1–2 min).
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const { env, envInt } = require('../lib/env');
21
+ const {
22
+ unitDraftLabel,
23
+ assignUnitLabels,
24
+ hasSemanticStructure
25
+ } = require('../lib/copy-labels');
26
+
27
+ const SKILLS_DIR = path.resolve(__dirname, '..', 'skills');
28
+ const DEFAULT_AGENT_ID = 'a866095fd53b419ab08338e3559fb7aa';
29
+ const STREAM_FIRST_BYTE_MS = Number(process.env.GLEAN_STREAM_FIRST_BYTE_MS || 12000);
30
+ const WAIT_HEARTBEAT_MS = Number(process.env.GLEAN_WAIT_HEARTBEAT_MS || 3500);
31
+ /** Overall cap for /runs/wait and stream-to-completion (Robot Content Designer ~90–140s). */
32
+ const GLEAN_AGENT_TIMEOUT_MS = Number(
33
+ process.env.GLEAN_AGENT_TIMEOUT_MS || 300000
34
+ );
35
+ /** Max copy options to parse/return (default 3; was 4, was 6). */
36
+ const DEFAULT_OPTION_COUNT = envInt('GLEAN_OPTION_COUNT', 3);
37
+
38
+ function extractAiDelta(payload) {
39
+ if (!payload || typeof payload !== 'object') return '';
40
+ const messages = Array.isArray(payload.messages) ? payload.messages : [];
41
+ const parts = [];
42
+ for (const msg of messages) {
43
+ const role = String(msg.role || msg.author || '').toUpperCase();
44
+ if (role === 'USER') continue;
45
+ const content = msg.content || msg.fragments || [];
46
+ if (typeof content === 'string') {
47
+ parts.push(content);
48
+ continue;
49
+ }
50
+ if (!Array.isArray(content)) continue;
51
+ for (const block of content) {
52
+ if (typeof block === 'string') parts.push(block);
53
+ else if (block && typeof block.text === 'string') parts.push(block.text);
54
+ }
55
+ }
56
+ return parts.join('');
57
+ }
58
+
59
+ /** One-line status for UI: tail of stream text so it visibly changes each token. */
60
+ function extractLatestThought(text, fallback = 'Working…') {
61
+ const raw = String(text || '').trim();
62
+ if (!raw) return fallback;
63
+
64
+ const compact = raw.replace(/\s+/g, ' ').trim();
65
+ if (compact.length > 16) {
66
+ const tail = compact.slice(-160).trim();
67
+ if (tail.length >= 6) return tail;
68
+ }
69
+
70
+ const sentences = raw.split(/(?<=[.!?])\s+/).filter((s) => s.trim().length > 2);
71
+ if (sentences.length) {
72
+ const last = sentences[sentences.length - 1].trim();
73
+ if (last.length <= 220 && !/^\|/.test(last) && !/^[-*#]{2,}/.test(last)) {
74
+ return last.slice(0, 220);
75
+ }
76
+ }
77
+
78
+ const lines = raw.split(/\n/).map((l) => l.trim()).filter(Boolean);
79
+ for (let i = lines.length - 1; i >= 0; i--) {
80
+ const line = lines[i];
81
+ if (/^\|[-:\s|]+\|$/.test(line)) continue;
82
+ if (line.startsWith('|')) {
83
+ const cells = line.split('|').map((c) => c.trim()).filter(Boolean);
84
+ if (cells.length >= 1) {
85
+ const label = cells[0].replace(/\*\*/g, '').trim();
86
+ if (/option\s*\d/i.test(label)) return `Drafting ${label}…`;
87
+ if (cells[1] && cells[1].length < 100) {
88
+ return cells[1].replace(/\*\*/g, '').slice(0, 180);
89
+ }
90
+ }
91
+ continue;
92
+ }
93
+ if (/^(Heading|Body|CTA|Copy\s*\d+)/i.test(line)) return line.slice(0, 180);
94
+ if (line.length > 3 && !/^```/.test(line)) return line.slice(0, 180);
95
+ }
96
+
97
+ return compact.slice(0, 180) || fallback;
98
+ }
99
+
100
+ function loadSkillsContext() {
101
+ const files = [];
102
+ try {
103
+ for (const name of fs.readdirSync(SKILLS_DIR)) {
104
+ if (!/\.(md|txt)$/i.test(name)) continue;
105
+ files.push({
106
+ name,
107
+ content: fs.readFileSync(path.join(SKILLS_DIR, name), 'utf8').slice(0, 8000)
108
+ });
109
+ }
110
+ } catch {
111
+ // optional
112
+ }
113
+ return files;
114
+ }
115
+
116
+ function stubVariants(session) {
117
+ const nodes = assignUnitLabels(Array.isArray(session.nodes) ? session.nodes : []);
118
+ const base = nodes.map((n, i) => {
119
+ const path = n.path != null ? String(n.path) : String(i);
120
+ return {
121
+ id: n.id || `copy-${path}`,
122
+ path,
123
+ role: n.role || 'text',
124
+ text: n.text,
125
+ selector: n.selector,
126
+ originalText:
127
+ n.originalText != null ? String(n.originalText) : String(n.text || '')
128
+ };
129
+ });
130
+
131
+ const mk = (id, label, transform) => {
132
+ const mapped = base.map((n) => ({
133
+ id: n.id,
134
+ path: n.path,
135
+ role: n.role,
136
+ selector: n.selector,
137
+ originalText: n.originalText,
138
+ text: transform(String(n.text || ''))
139
+ }));
140
+ const fields = {};
141
+ mapped.forEach((n, idx) => {
142
+ const role = String(n.role || 'text').toLowerCase();
143
+ if (role === 'heading' && fields.heading == null) fields.heading = n.text;
144
+ else if (
145
+ (role === 'action' || role === 'button') &&
146
+ fields.cta == null
147
+ ) {
148
+ fields.cta = n.text;
149
+ } else if (fields.body == null && !hasSemanticStructure(mapped)) {
150
+ fields[`copy${idx + 1}`] = n.text;
151
+ } else if (fields.body == null) {
152
+ fields.body = n.text;
153
+ }
154
+ });
155
+ return {
156
+ id,
157
+ label,
158
+ summary: `${label} pass over ${base.length} copy unit(s)`,
159
+ fields,
160
+ nodes: mapped
161
+ };
162
+ };
163
+
164
+ return [
165
+ mk('glean-stub-concise', 'Concise', (t) =>
166
+ t.replace(/\s+/g, ' ').trim().replace(/\.$/, '')
167
+ ),
168
+ mk('glean-stub-clear', 'Clearer', (t) =>
169
+ t.trim().length ? `${t.trim().replace(/\.$/, '')}.` : t
170
+ ),
171
+ mk('glean-stub-action', 'More actionable', (t) => {
172
+ const trimmed = t.trim();
173
+ if (!trimmed) return trimmed;
174
+ if (/^(get|start|try|open|view|add|create|save)/i.test(trimmed)) return trimmed;
175
+ return `Try: ${trimmed}`;
176
+ }),
177
+ mk('glean-stub-tone', 'Product tone', (t) =>
178
+ t.replace(/\butilize\b/gi, 'use').replace(/\bleverage\b/gi, 'use')
179
+ )
180
+ ];
181
+ }
182
+
183
+ function authHeaders(extra = {}) {
184
+ const headers = {
185
+ 'Content-Type': 'application/json',
186
+ Authorization: `Bearer ${process.env.GLEAN_API_KEY}`,
187
+ ...extra
188
+ };
189
+ if (process.env.GLEAN_ACT_AS) {
190
+ headers['X-Glean-ActAs'] = process.env.GLEAN_ACT_AS;
191
+ }
192
+ return headers;
193
+ }
194
+
195
+ function baseUrl() {
196
+ const raw =
197
+ process.env.GLEAN_BASE_URL ||
198
+ process.env.GLEAN_API_URL ||
199
+ 'https://motive-prod-be.glean.com';
200
+ return String(raw).replace(/\/+$/, '').replace(/\/rest\/api\/v1.*$/i, '');
201
+ }
202
+
203
+ /**
204
+ * Build Robot Content Designer form fields.
205
+ * Keep payloads short (long feature/proactive strings → HTTP 400) but always
206
+ * include the real selected Heading/Body/CTA so the agent rewrites those strings.
207
+ * Component type must match the agent's vocabulary or it asks clarifying questions
208
+ * and falls back to generic assumptions.
209
+ */
210
+ function formatDraft(session) {
211
+ const nodes = assignUnitLabels(
212
+ Array.isArray(session.nodes) ? session.nodes : []
213
+ );
214
+ if (!nodes.length) {
215
+ return String(session.combinedText || '').trim().slice(0, 1500);
216
+ }
217
+ return nodes
218
+ .map((n, i) => {
219
+ const label = n.label || unitDraftLabel(n, i, nodes);
220
+ return `${label}: ${String(n.text || '').trim()}`;
221
+ })
222
+ .join('\n')
223
+ .slice(0, 1500);
224
+ }
225
+
226
+ function draftLabelHint(session) {
227
+ const nodes = assignUnitLabels(
228
+ Array.isArray(session.nodes) ? session.nodes : []
229
+ );
230
+ if (!nodes.length) return 'Copy 1:';
231
+ if (hasSemanticStructure(nodes)) {
232
+ const labels = nodes.map((n, i) => n.label || unitDraftLabel(n, i, nodes));
233
+ return labels.join(', ');
234
+ }
235
+ if (nodes.length === 1) return 'Copy 1:';
236
+ return 'Copy 1:, Copy 2:, … (same count as draft)';
237
+ }
238
+
239
+ function buildAgentInput(session, options = {}) {
240
+ const draft = formatDraft(session);
241
+ const optionCount = Math.min(
242
+ Math.max(
243
+ Number(options.optionCount || options.variantCount || DEFAULT_OPTION_COUNT) ||
244
+ DEFAULT_OPTION_COUNT,
245
+ 2
246
+ ),
247
+ 6
248
+ );
249
+ // Short feature blurb — long strings → HTTP 400. Ask for copy-only output (no rubric).
250
+ const labelHint = draftLabelHint(session);
251
+ const feature =
252
+ options.feature ||
253
+ (draft
254
+ ? `Return exactly ${optionCount} new rewritten alternatives only — do not include the original draft as an option. Label fields like the draft (${labelHint}). Table, no rubric/score columns. No questions.`
255
+ : `Return exactly ${optionCount} new UI copy alternatives only (not the original draft). No clarifying questions.`);
256
+
257
+ return {
258
+ 'Enter in a human-written draft': draft,
259
+ 'Explain the functionality of the feature': String(feature).slice(0, 220),
260
+ 'Proactive or reactive': options.proactiveOrReactive || 'Proactive',
261
+ // Must be from agent vocabulary (e.g. General UI string) — not "Hero / landing card"
262
+ 'Type of component': options.componentType || 'General UI string',
263
+ 'What is the difficulty of the user experience':
264
+ options.uxDifficulty || 'Easy',
265
+ _crankOptionCount: optionCount
266
+ };
267
+ }
268
+
269
+ function extractMessageText(data) {
270
+ const messages = Array.isArray(data?.messages) ? data.messages : [];
271
+ const parts = [];
272
+ for (const msg of messages) {
273
+ if (msg.role && String(msg.role).toUpperCase() === 'USER') continue;
274
+ const content = msg.content || msg.fragments || [];
275
+ if (typeof content === 'string') {
276
+ parts.push(content);
277
+ continue;
278
+ }
279
+ if (!Array.isArray(content)) continue;
280
+ for (const block of content) {
281
+ if (typeof block === 'string') parts.push(block);
282
+ else if (block && typeof block.text === 'string') parts.push(block.text);
283
+ }
284
+ }
285
+ return parts.join('\n\n').trim();
286
+ }
287
+
288
+ /**
289
+ * Robot Content Designer tables append rubric/score cells after Heading/Body/CTA:
290
+ * |Good …|No Pattern|Weak …|
291
+ * Those are evaluation notes — never user-facing copy.
292
+ */
293
+ const SCORE_CELL_RE = /\|\s*(?:Good|Weak|No\s+Pattern)\b/i;
294
+
295
+ function splitCopyAndScores(block) {
296
+ const text = String(block || '');
297
+ const m = SCORE_CELL_RE.exec(text);
298
+ if (!m || m.index == null) return { copy: text, scores: '' };
299
+ return {
300
+ copy: text.slice(0, m.index),
301
+ scores: text
302
+ .slice(m.index)
303
+ .replace(/^\|/, '')
304
+ .replace(/\s*\|\s*/g, ' · ')
305
+ .replace(/\s+/g, ' ')
306
+ .trim()
307
+ };
308
+ }
309
+
310
+ function cleanCopyField(value) {
311
+ const { copy } = splitCopyAndScores(value);
312
+ return String(copy || '')
313
+ .replace(/\|\s*\*{0,2}Option\s+\d+.*$/i, '')
314
+ .replace(/\|+\s*$/g, '')
315
+ .replace(/\*{1,2}/g, '')
316
+ .replace(/^["'\s]+|["'\s]+$/g, '')
317
+ .replace(/\s+/g, ' ')
318
+ .trim();
319
+ }
320
+
321
+ /**
322
+ * Split a Glean option cell into labeled copy fields.
323
+ * Supports Heading / Body / Body 1 / Body 2 / CTA on one line or many
324
+ * (Robot Content Designer often emits Body N when the draft has multiple bodies).
325
+ * Score/critique table cells after the copy are discarded from field values.
326
+ */
327
+ function parseLabeledCopyFields(block) {
328
+ const { copy: text } = splitCopyAndScores(block);
329
+ // Copy 1 / Body 1 must match as their own labels (not swallowed by Heading / Body).
330
+ const labelRe =
331
+ /\b(Heading|CTA|Label|Body(?:\s*\d+)?|Copy(?:\s*\d+)?)\s*:/gi;
332
+ const hits = [];
333
+ let m;
334
+ while ((m = labelRe.exec(text)) !== null) {
335
+ hits.push({
336
+ rawLabel: String(m[1] || '').replace(/\s+/g, ' ').trim(),
337
+ labelStart: m.index,
338
+ valueStart: m.index + m[0].length
339
+ });
340
+ }
341
+ if (!hits.length) return [];
342
+
343
+ const out = [];
344
+ for (let i = 0; i < hits.length; i++) {
345
+ const valueEnd = i + 1 < hits.length ? hits[i + 1].labelStart : text.length;
346
+ const value = cleanCopyField(text.slice(hits[i].valueStart, valueEnd));
347
+ if (!value) continue;
348
+ const lower = hits[i].rawLabel.toLowerCase();
349
+ let role = 'text';
350
+ let key = 'body';
351
+ if (lower === 'heading') {
352
+ role = 'heading';
353
+ key = 'heading';
354
+ } else if (lower === 'cta') {
355
+ role = 'action';
356
+ key = 'cta';
357
+ } else if (lower === 'label') {
358
+ role = 'label';
359
+ key = 'label';
360
+ } else if (/^copy(?:\s*\d+)?$/.test(lower)) {
361
+ role = 'text';
362
+ key = lower.replace(/\s+/g, '');
363
+ }
364
+ out.push({
365
+ key,
366
+ role,
367
+ label: hits[i].rawLabel,
368
+ text: value
369
+ });
370
+ }
371
+ return out;
372
+ }
373
+
374
+ function fieldsFromLabeled(labeled) {
375
+ const fields = {};
376
+ const bodies = [];
377
+ for (const f of labeled) {
378
+ if (f.key === 'heading' && fields.heading == null) fields.heading = f.text;
379
+ else if (f.key === 'cta' && fields.cta == null) fields.cta = f.text;
380
+ else if (f.key === 'body') bodies.push(f.text);
381
+ }
382
+ if (bodies.length === 1) fields.body = bodies[0];
383
+ else if (bodies.length > 1) {
384
+ fields.body = bodies[0];
385
+ fields.bodies = bodies;
386
+ }
387
+ return fields;
388
+ }
389
+
390
+ function assignmentListFromFields(fields = {}, labeled = null) {
391
+ if (Array.isArray(labeled) && labeled.length) {
392
+ return labeled
393
+ .map((f) => ({
394
+ role: f.role || 'text',
395
+ value: cleanCopyField(f.text)
396
+ }))
397
+ .filter((a) => a.value);
398
+ }
399
+ const list = [];
400
+ if (fields.heading) {
401
+ list.push({ role: 'heading', value: cleanCopyField(fields.heading) });
402
+ }
403
+ const bodies = Array.isArray(fields.bodies)
404
+ ? fields.bodies
405
+ : fields.body
406
+ ? [fields.body]
407
+ : [];
408
+ for (const b of bodies) {
409
+ const value = cleanCopyField(b);
410
+ if (value) list.push({ role: 'text', value });
411
+ }
412
+ if (fields.cta) {
413
+ list.push({ role: 'action', value: cleanCopyField(fields.cta) });
414
+ }
415
+ return list;
416
+ }
417
+
418
+ function roleCompatible(assignRole, nodeRole) {
419
+ const a = String(assignRole || 'text').toLowerCase();
420
+ const n = String(nodeRole || 'text').toLowerCase();
421
+ if (a === 'heading') return n === 'heading';
422
+ if (a === 'action' || a === 'button') return n === 'action' || n === 'button';
423
+ return n === 'text' || n === 'label' || n === 'body';
424
+ }
425
+
426
+ function normalizeWs(text) {
427
+ return String(text || '').replace(/\s+/g, ' ').trim();
428
+ }
429
+
430
+ /**
431
+ * Detect Glean "Option N (original)" rows — header tag or copy identical to session draft.
432
+ */
433
+ function isOriginalVariant(headerMeta, mapped, nodes) {
434
+ if (/human-written|\boriginal\b/i.test(String(headerMeta || ''))) {
435
+ return true;
436
+ }
437
+ const sessionNodes = Array.isArray(nodes) ? nodes : [];
438
+ const mappedList = Array.isArray(mapped) ? mapped : [];
439
+ if (!sessionNodes.length || !mappedList.length) return false;
440
+
441
+ let compared = 0;
442
+ for (const m of mappedList) {
443
+ const base = sessionNodes.find(
444
+ (n) =>
445
+ (m.id && n.id && m.id === n.id) ||
446
+ (m.path != null &&
447
+ n.path != null &&
448
+ String(m.path) === String(n.path))
449
+ );
450
+ if (!base) continue;
451
+ compared += 1;
452
+ const orig =
453
+ base.originalText != null
454
+ ? String(base.originalText)
455
+ : base.text != null
456
+ ? String(base.text)
457
+ : '';
458
+ if (normalizeWs(m.text) !== normalizeWs(orig)) return false;
459
+ }
460
+ return compared > 0;
461
+ }
462
+
463
+ /**
464
+ * Map Glean Heading/Body/CTA (including Body 1..N) onto selected copy units 1:1.
465
+ * Never blast one field onto every node that shares a role (e.g. all `text`).
466
+ * When counts match, zip by order; otherwise exclusive role match then positional.
467
+ */
468
+ function mapOptionFieldsToNodes(nodes, fields = {}, labeled = null) {
469
+ const list = Array.isArray(nodes) ? nodes : [];
470
+ const mapped = list.map((n, i) => {
471
+ const path = n.path != null ? String(n.path) : String(i);
472
+ return {
473
+ id: n.id || `copy-${path}`,
474
+ path,
475
+ role: n.role || 'text',
476
+ selector: n.selector,
477
+ text: String(n.text != null ? n.text : ''),
478
+ editable: n.editable,
479
+ textSource: n.textSource,
480
+ originalText:
481
+ n.originalText != null
482
+ ? String(n.originalText)
483
+ : n.text != null
484
+ ? String(n.text)
485
+ : undefined
486
+ };
487
+ });
488
+
489
+ const assignments = assignmentListFromFields(fields, labeled);
490
+ if (!assignments.length || !mapped.length) return mapped;
491
+
492
+ // Equal counts → positional zip (Heading + Body 1..N ↔ selection units).
493
+ if (assignments.length === mapped.length) {
494
+ for (let i = 0; i < mapped.length; i++) {
495
+ mapped[i].text = assignments[i].value;
496
+ }
497
+ return mapped;
498
+ }
499
+
500
+ const usedNodes = new Set();
501
+ const usedAssign = new Set();
502
+
503
+ // Heading & CTA first so body values cannot claim those nodes.
504
+ for (let ai = 0; ai < assignments.length; ai++) {
505
+ const a = assignments[ai];
506
+ const r = String(a.role || '').toLowerCase();
507
+ if (r !== 'heading' && r !== 'action' && r !== 'button') continue;
508
+ const idx = mapped.findIndex(
509
+ (n, i) => !usedNodes.has(i) && roleCompatible(a.role, n.role)
510
+ );
511
+ if (idx >= 0) {
512
+ mapped[idx].text = a.value;
513
+ usedNodes.add(idx);
514
+ usedAssign.add(ai);
515
+ }
516
+ }
517
+
518
+ // Prefer text-role nodes for remaining body values, then any unused node.
519
+ for (let ai = 0; ai < assignments.length; ai++) {
520
+ if (usedAssign.has(ai)) continue;
521
+ let idx = mapped.findIndex(
522
+ (n, i) => !usedNodes.has(i) && roleCompatible(assignments[ai].role, n.role)
523
+ );
524
+ if (idx < 0) {
525
+ idx = mapped.findIndex((_, i) => !usedNodes.has(i));
526
+ }
527
+ if (idx < 0) break;
528
+ mapped[idx].text = assignments[ai].value;
529
+ usedNodes.add(idx);
530
+ usedAssign.add(ai);
531
+ }
532
+
533
+ return mapped;
534
+ }
535
+
536
+ /**
537
+ * Normalize Robot Content Designer markdown/table output into variant cards.
538
+ * Real responses often use HTML <br> inside markdown table cells:
539
+ * **Option 2 (AI-written) ✅ Recommended**<br>Heading: …<br>Body 1: …<br>Body 2: …
540
+ */
541
+ function parseOptionsFromText(text, session, maxOptions = DEFAULT_OPTION_COUNT) {
542
+ const cap = Math.min(Math.max(Number(maxOptions) || DEFAULT_OPTION_COUNT, 2), 6);
543
+ const nodes = assignUnitLabels(
544
+ Array.isArray(session.nodes) ? session.nodes : []
545
+ );
546
+ const normalized = String(text || '')
547
+ .replace(/<br\s*\/?>/gi, '\n')
548
+ .replace(/&nbsp;/gi, ' ')
549
+ .replace(/&amp;/gi, '&')
550
+ .replace(/&lt;/gi, '<')
551
+ .replace(/&gt;/gi, '>');
552
+
553
+ const variants = [];
554
+ const seenNums = new Set();
555
+ // Markdown tables put Heading on the same line as "| Option N | Heading: …"
556
+ // so search the option line + following cell body together.
557
+ // Start must allow a bare "|" row (after a prior option ended on lookahead
558
+ // `|| **Option N`), not only (^|\n).
559
+ const optionRe =
560
+ /(?:(?:^|\n)\|{0,1}|\|)\s*\*{0,2}Option\s+(\d+)([^*\n|]*)\*{0,2}([^\n]*)\n?([\s\S]*?)(?=(?:\n\|?\s*\*{0,2}Option\s+\d+)|\|\s*\*{0,2}Option\s+\d+|(?:\n### |\n#### |\n---\n)|$)/gi;
561
+ let match;
562
+ while ((match = optionRe.exec(normalized)) !== null) {
563
+ const num = match[1];
564
+ if (seenNums.has(num)) continue;
565
+ const headerMeta = `${match[2] || ''} ${match[3] || ''}`;
566
+ const rawBlock = `${headerMeta}\n${match[4] || ''}`;
567
+ const { copy: block, scores } = splitCopyAndScores(rawBlock);
568
+ const labeled = parseLabeledCopyFields(block);
569
+ const recommended = /recommended/i.test(headerMeta);
570
+ if (!labeled.length) continue;
571
+ seenNums.add(num);
572
+
573
+ const fields = fieldsFromLabeled(labeled);
574
+ const mapped = mapOptionFieldsToNodes(nodes, fields, labeled);
575
+
576
+ if (isOriginalVariant(headerMeta, mapped, nodes)) continue;
577
+
578
+ // Summary is user-facing preview — clean Heading/Body/CTA only (never rubric cells).
579
+ const summaryParts = labeled.map((f) => f.text).filter(Boolean);
580
+
581
+ const variant = {
582
+ id: `glean-option-${num}`,
583
+ label: recommended ? `Option ${num} (recommended)` : `Option ${num}`,
584
+ summary: summaryParts.join(' · ').slice(0, 240),
585
+ fields,
586
+ nodes: mapped,
587
+ recommended
588
+ };
589
+ if (scores) variant.scores = scores.slice(0, 400);
590
+ variants.push(variant);
591
+ }
592
+
593
+ if (variants.length) {
594
+ return variants.slice(0, cap).map((v, i) => {
595
+ const num = i + 1;
596
+ return {
597
+ ...v,
598
+ id: `glean-option-${num}`,
599
+ label: v.recommended ? `Option ${num} (recommended)` : `Option ${num}`
600
+ };
601
+ });
602
+ }
603
+
604
+ return [
605
+ {
606
+ id: 'glean-raw',
607
+ label: 'Glean response',
608
+ summary: String(text || '').slice(0, 200),
609
+ fields: {},
610
+ nodes: mapOptionFieldsToNodes(nodes, {}),
611
+ rawText: text
612
+ }
613
+ ];
614
+ }
615
+
616
+ function extractTextFromStreamPayload(payload) {
617
+ if (!payload || typeof payload !== 'object') return '';
618
+ if (typeof payload.text === 'string') return payload.text;
619
+ if (typeof payload.content === 'string') return payload.content;
620
+ if (typeof payload.delta === 'string') return payload.delta;
621
+ if (typeof payload.message === 'string') return payload.message;
622
+
623
+ const fromMessages = extractMessageText(payload);
624
+ if (fromMessages) return fromMessages;
625
+
626
+ const content = payload.content || payload.delta?.content;
627
+ if (Array.isArray(content)) {
628
+ return content
629
+ .map((c) => (typeof c === 'string' ? c : c?.text || ''))
630
+ .filter(Boolean)
631
+ .join('');
632
+ }
633
+ return '';
634
+ }
635
+
636
+ function makeEmitter(onProgress) {
637
+ return (event) => {
638
+ if (typeof onProgress !== 'function') return;
639
+ try {
640
+ onProgress(event);
641
+ } catch {
642
+ // ignore UI progress errors
643
+ }
644
+ };
645
+ }
646
+
647
+ /**
648
+ * Try SSE stream to completion. Rejects if no first byte within
649
+ * STREAM_FIRST_BYTE_MS, or if the stream ends empty.
650
+ * Emits silent-phase heartbeats, then live GLEAN_AI token text.
651
+ */
652
+ async function tryStreamAgent(body, emit) {
653
+ const ac = new AbortController();
654
+ const streamUrl = `${baseUrl()}/rest/api/v1/agents/runs/stream`;
655
+ const started = Date.now();
656
+
657
+ const firstByteTimer = setTimeout(() => {
658
+ ac.abort(new Error('stream first-byte timeout'));
659
+ }, STREAM_FIRST_BYTE_MS);
660
+
661
+ const overallTimer = setTimeout(() => {
662
+ ac.abort(new Error(`stream timed out after ${GLEAN_AGENT_TIMEOUT_MS}ms`));
663
+ }, GLEAN_AGENT_TIMEOUT_MS);
664
+
665
+ let heartbeat = null;
666
+ let gotAiTokens = false;
667
+
668
+ const emitSilentProgress = () => {
669
+ if (gotAiTokens) return;
670
+ const status = 'Connected — agent running…';
671
+ emit({
672
+ stage: 'stream_wait',
673
+ text: status,
674
+ detail: 'First tokens usually after ~1 min (agent runtime)',
675
+ latestThought: status,
676
+ live: false
677
+ });
678
+ };
679
+
680
+ try {
681
+ const response = await fetch(streamUrl, {
682
+ method: 'POST',
683
+ headers: authHeaders({ Accept: 'text/event-stream' }),
684
+ body: JSON.stringify(body),
685
+ signal: ac.signal
686
+ });
687
+
688
+ if (!response.ok) {
689
+ throw new Error(`stream ${response.status}`);
690
+ }
691
+ if (!response.body || typeof response.body.getReader !== 'function') {
692
+ throw new Error('No stream body');
693
+ }
694
+
695
+ const reader = response.body.getReader();
696
+ const decoder = new TextDecoder('utf-8');
697
+ let buffer = '';
698
+ let assembled = '';
699
+ let lastEvent = '';
700
+ let gotByte = false;
701
+ let eventCount = 0;
702
+
703
+ heartbeat = setInterval(emitSilentProgress, WAIT_HEARTBEAT_MS);
704
+
705
+ while (true) {
706
+ const { done, value } = await reader.read();
707
+ if (done) break;
708
+
709
+ if (!gotByte) {
710
+ gotByte = true;
711
+ clearTimeout(firstByteTimer);
712
+ emitSilentProgress();
713
+ }
714
+
715
+ buffer += decoder.decode(value, { stream: true });
716
+ const parts = buffer.split(/\n\n/);
717
+ buffer = parts.pop() || '';
718
+
719
+ for (const part of parts) {
720
+ const lines = part.split(/\n/);
721
+ let eventName = lastEvent;
722
+ const dataLines = [];
723
+ for (const line of lines) {
724
+ if (line.startsWith('event:')) {
725
+ eventName = line.slice(6).trim();
726
+ lastEvent = eventName;
727
+ } else if (line.startsWith('data:')) {
728
+ dataLines.push(line.slice(5).trim());
729
+ }
730
+ }
731
+ if (!dataLines.length) continue;
732
+ const raw = dataLines.join('\n');
733
+ if (raw === '[DONE]') continue;
734
+ eventCount += 1;
735
+
736
+ let payload;
737
+ try {
738
+ payload = JSON.parse(raw);
739
+ } catch {
740
+ // Non-JSON SSE data — treat as raw text delta.
741
+ if (raw.trim()) {
742
+ assembled += raw;
743
+ gotAiTokens = true;
744
+ emit({
745
+ stage: eventName || 'thinking',
746
+ text: 'Streaming agent output…',
747
+ partial: assembled.slice(-6000),
748
+ latestThought: extractLatestThought(
749
+ assembled,
750
+ 'Generating copy…'
751
+ ),
752
+ streamChars: assembled.length,
753
+ live: true
754
+ });
755
+ }
756
+ continue;
757
+ }
758
+
759
+ // Prefer AI-only deltas (skip the initial USER ack message).
760
+ const aiDelta = extractAiDelta(payload);
761
+ const chunkText = aiDelta || extractTextFromStreamPayload(payload);
762
+ if (chunkText) {
763
+ // Token deltas are small appends; full snapshots replace.
764
+ if (
765
+ chunkText.length >= assembled.length &&
766
+ assembled.length > 0 &&
767
+ chunkText.startsWith(assembled.slice(0, Math.min(40, assembled.length)))
768
+ ) {
769
+ assembled = chunkText;
770
+ } else {
771
+ assembled += chunkText;
772
+ }
773
+ gotAiTokens = true;
774
+ emit({
775
+ stage: eventName || 'thinking',
776
+ text: 'Streaming agent output…',
777
+ partial: assembled.slice(-6000),
778
+ latestThought: extractLatestThought(
779
+ assembled,
780
+ 'Generating copy…'
781
+ ),
782
+ streamChars: assembled.length,
783
+ live: true
784
+ });
785
+ } else if (payload.status || payload.stage || payload.type) {
786
+ const statusText = String(
787
+ payload.message || payload.detail || payload.status || 'Working…'
788
+ ).slice(0, 280);
789
+ emit({
790
+ stage: String(payload.status || payload.stage || payload.type),
791
+ text: statusText,
792
+ partial: assembled.slice(-2500),
793
+ latestThought: statusText,
794
+ live: !!assembled
795
+ });
796
+ }
797
+ }
798
+ }
799
+
800
+ if (!assembled.trim()) {
801
+ throw new Error(
802
+ `Empty stream response (events=${eventCount}, bytes_seen=${gotByte})`
803
+ );
804
+ }
805
+ console.log(
806
+ `[Crank] Glean stream done: ${eventCount} events, ${assembled.length} chars, ${Math.round((Date.now() - started) / 1000)}s`
807
+ );
808
+ return assembled;
809
+ } finally {
810
+ clearTimeout(firstByteTimer);
811
+ clearTimeout(overallTimer);
812
+ if (heartbeat) clearInterval(heartbeat);
813
+ }
814
+ }
815
+
816
+ function emitWaitProgress(emit) {
817
+ // Same stage every tick — UI updates elapsed locally (no rotating fake stages).
818
+ const status = 'Connected — agent running…';
819
+ emit({
820
+ stage: 'waiting',
821
+ text: status,
822
+ detail: 'Robot Content Designer · typically 1–2 min (mostly agent runtime)',
823
+ note: 'Using /runs/wait (stream disabled or unavailable).',
824
+ latestThought: status,
825
+ live: false
826
+ });
827
+ }
828
+
829
+ async function waitForAgent(body, emit) {
830
+ const url = `${baseUrl()}/rest/api/v1/agents/runs/wait`;
831
+ const started = Date.now();
832
+ // Heartbeats are UI-only; /runs/wait blocks until the agent finishes (often 60–120s).
833
+ emitWaitProgress(emit);
834
+ const heartbeat = setInterval(() => {
835
+ emitWaitProgress(emit);
836
+ }, WAIT_HEARTBEAT_MS);
837
+
838
+ const ac = new AbortController();
839
+ const overallTimer = setTimeout(() => {
840
+ ac.abort(new Error(`Glean wait timed out after ${GLEAN_AGENT_TIMEOUT_MS}ms`));
841
+ }, GLEAN_AGENT_TIMEOUT_MS);
842
+
843
+ try {
844
+ const response = await fetch(url, {
845
+ method: 'POST',
846
+ headers: authHeaders(),
847
+ body: JSON.stringify(body),
848
+ signal: ac.signal
849
+ });
850
+ if (!response.ok) {
851
+ const text = await response.text().catch(() => '');
852
+ throw new Error(
853
+ `Glean agents/runs/wait ${response.status}: ${text.slice(0, 400)}`
854
+ );
855
+ }
856
+ const data = await response.json();
857
+ const text = extractMessageText(data);
858
+ if (!text) throw new Error('Glean agent returned no text content');
859
+ return text;
860
+ } catch (err) {
861
+ if (err && err.name === 'AbortError') {
862
+ throw new Error(`Glean wait timed out after ${GLEAN_AGENT_TIMEOUT_MS}ms`);
863
+ }
864
+ throw err;
865
+ } finally {
866
+ clearTimeout(overallTimer);
867
+ clearInterval(heartbeat);
868
+ }
869
+ }
870
+
871
+ /**
872
+ * Ask Glean for copy variants.
873
+ * Prefers stream when it actually emits; otherwise wait + heartbeats.
874
+ */
875
+ async function proposeCopyVariants(session, options = {}, onProgress) {
876
+ const stub =
877
+ String(process.env.GLEAN_STUB || 'true').toLowerCase() !== 'false';
878
+ const emit = makeEmitter(onProgress);
879
+
880
+ const stubOptionCount = Math.min(
881
+ Math.max(
882
+ Number(options.optionCount || options.variantCount || DEFAULT_OPTION_COUNT) ||
883
+ DEFAULT_OPTION_COUNT,
884
+ 2
885
+ ),
886
+ 6
887
+ );
888
+
889
+ if (stub || !process.env.GLEAN_API_KEY) {
890
+ emit({ stage: 'stub', text: 'Using local stub variants…' });
891
+ return stubVariants(session).slice(0, stubOptionCount);
892
+ }
893
+
894
+ const agentId = process.env.GLEAN_AGENT_ID || DEFAULT_AGENT_ID;
895
+ const input = buildAgentInput(session, options);
896
+ const optionCount = input._crankOptionCount || DEFAULT_OPTION_COUNT;
897
+ const { _crankOptionCount, ...gleanInput } = input;
898
+ const body = {
899
+ agent_id: agentId,
900
+ input: gleanInput
901
+ };
902
+
903
+ const draftPreview = String(
904
+ gleanInput['Enter in a human-written draft'] || ''
905
+ ).slice(0, 180);
906
+ console.log(
907
+ `[Crank] Glean draft (${draftPreview.length} chars shown): ${draftPreview.replace(/\n/g, ' | ')}`
908
+ );
909
+ console.log(
910
+ `[Crank] Glean fields: component=${gleanInput['Type of component']}; options=${optionCount}; feature=${String(
911
+ gleanInput['Explain the functionality of the feature'] || ''
912
+ ).slice(0, 100)}`
913
+ );
914
+
915
+ emit({
916
+ stage: 'start',
917
+ text: `Sending copy to Robot Content Designer (${optionCount} options)…`,
918
+ detail: 'Typically 1–2 min — most time is agent runtime, not network',
919
+ partial: draftPreview,
920
+ latestThought: `Sending copy to Robot Content Designer (${optionCount} options)…`
921
+ });
922
+
923
+ // Prefer /runs/stream (live GLEAN_AI tokens after a silent agent phase).
924
+ // Fall back to /runs/wait only if stream fails. Never run both to completion.
925
+ // Set GLEAN_USE_STREAM=false to skip stream entirely.
926
+ const useStream =
927
+ String(process.env.GLEAN_USE_STREAM || 'true').toLowerCase() !== 'false';
928
+
929
+ if (useStream) {
930
+ try {
931
+ emit({
932
+ stage: 'stream_wait',
933
+ text: 'Connecting to live agent stream…',
934
+ detail: 'Robot Content Designer · typically 1–2 min (mostly agent runtime)',
935
+ latestThought: 'Connecting to live agent stream…',
936
+ live: false
937
+ });
938
+ const streamed = await tryStreamAgent(body, emit);
939
+ emit({
940
+ stage: 'done',
941
+ text: 'Parsing streamed copy options…',
942
+ partial: streamed.slice(0, 2000),
943
+ latestThought: 'Parsing streamed copy options…',
944
+ live: true
945
+ });
946
+ return parseOptionsFromText(streamed, session, optionCount);
947
+ } catch (streamErr) {
948
+ emit({
949
+ stage: 'fallback',
950
+ text: 'Live stream failed — switching to /runs/wait…',
951
+ partial: String(streamErr.message || streamErr).slice(0, 160),
952
+ latestThought: 'Live stream failed — switching to /runs/wait…'
953
+ });
954
+ }
955
+ } else {
956
+ emit({
957
+ stage: 'waiting',
958
+ text: 'Connected — agent running…',
959
+ detail: 'Robot Content Designer · typically 1–2 min (mostly agent runtime)',
960
+ latestThought: 'Connected — agent running…',
961
+ note: 'Using /runs/wait (GLEAN_USE_STREAM=false).',
962
+ live: false
963
+ });
964
+ }
965
+
966
+ const text = await waitForAgent(body, emit);
967
+ emit({
968
+ stage: 'done',
969
+ text: 'Parsing copy options…',
970
+ partial: text.slice(0, 2000),
971
+ live: false
972
+ });
973
+ return parseOptionsFromText(text, session, optionCount);
974
+ }
975
+
976
+ module.exports = {
977
+ proposeCopyVariants,
978
+ loadSkillsContext,
979
+ stubVariants,
980
+ parseOptionsFromText,
981
+ splitCopyAndScores,
982
+ cleanCopyField,
983
+ parseLabeledCopyFields,
984
+ mapOptionFieldsToNodes,
985
+ buildAgentInput,
986
+ formatDraft,
987
+ draftLabelHint,
988
+ isOriginalVariant,
989
+ normalizeWs,
990
+ DEFAULT_AGENT_ID
991
+ };