@gotcos/glasses-server 6.98.0 → 6.99.12

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.
Files changed (89) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/package.json +2 -1
  3. package/server/index.ts +147 -9
  4. package/server/lib/api-auth.ts +31 -14
  5. package/server/lib/claude-bridge.ts +21 -2
  6. package/server/lib/codex-bridge.ts +21 -2
  7. package/server/lib/display-bus.ts +3 -0
  8. package/server/lib/drop-destinations.ts +134 -0
  9. package/server/lib/drop-history-search.ts +97 -0
  10. package/server/lib/drop-p2-boot.ts +60 -0
  11. package/server/lib/drop-p2-runtime.ts +237 -0
  12. package/server/lib/drop-p2-settings.ts +174 -0
  13. package/server/lib/drop-resolution.ts +168 -0
  14. package/server/lib/drop-router.ts +325 -0
  15. package/server/lib/drop-service.ts +739 -0
  16. package/server/lib/drop-settings.ts +45 -0
  17. package/server/lib/drop-store.ts +428 -0
  18. package/server/lib/drop-when.ts +265 -0
  19. package/server/lib/glasses-pairing.ts +15 -0
  20. package/server/lib/learning-needs-you.ts +111 -0
  21. package/server/lib/live-cues-v2-data.ts +336 -26
  22. package/server/lib/live-cues-v2-settings.ts +2 -1
  23. package/server/lib/live-cues-v2.ts +9 -5
  24. package/server/lib/meeting-consolidation-run.ts +169 -0
  25. package/server/lib/meeting-store.ts +61 -0
  26. package/server/lib/meeting-title-steer.ts +200 -0
  27. package/server/lib/network-policy.ts +84 -6
  28. package/server/lib/permission-broker.ts +11 -0
  29. package/server/lib/ping-budget.ts +149 -0
  30. package/server/lib/ping-engine.ts +245 -0
  31. package/server/lib/ping-feed-sink.ts +33 -0
  32. package/server/lib/ping-sources.ts +192 -0
  33. package/server/lib/query-job-coordinator.ts +5 -2
  34. package/server/lib/query-job-runtime.ts +45 -4
  35. package/server/lib/query-job-types.ts +10 -1
  36. package/server/lib/reminder-scheduler.ts +153 -0
  37. package/server/lib/tailnet-self-name.ts +103 -0
  38. package/server/lib/tailscale-nudge.ts +40 -2
  39. package/server/lib/task-store.ts +32 -0
  40. package/server/lib/web-ask-admission.ts +56 -0
  41. package/server/lib/web-live-chunks.ts +273 -0
  42. package/server/lib/web-memories-bridge.ts +323 -0
  43. package/server/lib/web-session-auth.ts +492 -0
  44. package/server/lib/web-usage-bridge.ts +154 -0
  45. package/server/lib/work-search.ts +14 -1
  46. package/server/lib/your-move-count.ts +58 -0
  47. package/server/routes/display.ts +11 -1
  48. package/server/routes/drops.ts +260 -0
  49. package/server/routes/health.ts +16 -0
  50. package/server/routes/live-cues.ts +5 -4
  51. package/server/routes/meeting.ts +61 -5
  52. package/server/routes/permission-broker.ts +23 -3
  53. package/server/routes/transcribe-stream.ts +34 -3
  54. package/server/routes/web-control.ts +569 -0
  55. package/server/routes/welcome-context.ts +2 -1
  56. package/server/web-control/app.css +519 -0
  57. package/server/web-control/app.js +3643 -0
  58. package/server/web-control/control.css +312 -0
  59. package/server/web-control/fonts/DMSans.ttf +0 -0
  60. package/server/web-control/fonts/Fraunces.ttf +0 -0
  61. package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
  62. package/server/web-control/index.html +275 -0
  63. package/server/web-control/mark.svg +1 -0
  64. package/server/web-control/memories/d3.min.js +2 -0
  65. package/server/web-control/memories/graph-explorer.css +200 -0
  66. package/server/web-control/memories/graph-explorer.js +748 -0
  67. package/server/web-control/memories/memories-app.js +1561 -0
  68. package/server/web-control/memories/memories-theme.css +73 -0
  69. package/server/web-control/memories/memories.html +72 -0
  70. package/server/web-control/memories/memory-stewardship.js +63 -0
  71. package/server/web-control/memories/memory-workspace.js +216 -0
  72. package/server/web-control/panes/cos-chat.css +157 -0
  73. package/server/web-control/panes/cos-chat.js +973 -0
  74. package/server/web-control/panes/dictation.js +173 -0
  75. package/server/web-control/panes/meetings.css +257 -0
  76. package/server/web-control/panes/meetings.js +2264 -0
  77. package/server/web-control/panes/messages.js +711 -0
  78. package/server/web-control/panes/questions.css +32 -0
  79. package/server/web-control/panes/questions.js +275 -0
  80. package/server/web-control/panes/sessions.css +452 -0
  81. package/server/web-control/panes/settings.js +292 -0
  82. package/server/web-control/panes/speakers.css +269 -0
  83. package/server/web-control/panes/speakers.js +2755 -0
  84. package/server/web-control/panes/threads.js +200 -0
  85. package/server/web-control/panes/work.css +261 -0
  86. package/server/web-control/panes/work.js +1667 -0
  87. package/server/web-control/shell.js +294 -0
  88. package/server/web-control/theme.js +25 -0
  89. package/server/web-control/vendor/qrcode-encoder.js +10 -0
@@ -13,6 +13,10 @@
13
13
  // carry a funnel word or a figure of two digits or a percent;
14
14
  // - a one-word alias that is also plain English (pet, dive, music...) is "weak": it counts only beside a funnel word or an
15
15
  // industry word, so "let's dive into the numbers" and "pet project" stay quiet. The bare word "quilt" is never an alias;
16
+ // - a lead source named in speech ("paid search", "PPC", "events") narrows the note to that source: its rows for the named group,
17
+ // or the largest industries when no group is named. A source alone fires only for a jargon or multi-word name with a funnel word
18
+ // or figure; a plain word that is also English (direct, events, email, organic...) counts only beside a named group. A source with
19
+ // a group fires without a funnel word ("PPC landing pages for thrift"), the way a funnel word alone never could;
16
20
  // - domain (dataArmGate): a Quilt meeting runs it. An unreadable orientation is off. Any other confirmed domain (personal,
17
21
  // Sprocket Rocket, Hermit Crabs...) is off. A meeting with no confirmed domain (most G2 meetings) needs a Quilt signal from
18
22
  // the same evaluation: a Quilt brand or SBU/XBU named, a Quilt-domain memory or note among the candidates, or a Quilt term in
@@ -36,6 +40,7 @@ export const DATA_FACTS_LIMITS = {
36
40
  maxBytes: 2_000_000,
37
41
  maxGroups: 60,
38
42
  maxSources: 400,
43
+ maxBudgetLines: 400,
39
44
  /** The speech the trigger reads: the last this many characters of the window (about 100 words). */
40
45
  recentChars: 600,
41
46
  /** A candidate is at most this long, like a file passage. */
@@ -44,6 +49,8 @@ export const DATA_FACTS_LIMITS = {
44
49
  /** What the meeting assistant is given: at most this many characters of Tableau text. */
45
50
  askChars: 3_500,
46
51
  askSourceRowsPerIndustry: 6,
52
+ /** A source-alone cue (no group named) lists this many industries, largest by leads. */
53
+ cueSourceRows: 2,
47
54
  /** A typed question longer than this is probably pasted text (see askUserWords). */
48
55
  askQuestionChars: 300,
49
56
  } as const
@@ -74,7 +81,19 @@ export interface DataGroup {
74
81
  coverage?: { have: number; of: number }
75
82
  stages: Partial<Record<DataStageKey, DataStage>>
76
83
  }
77
- export interface DataSourceRow { industry: string; source: string; bucket: string; leads: number; opps: number; customers: number }
84
+ /** The stages a lead source has an actual for (Tableau's source budget file carries no per-source SQLs or demos). */
85
+ export const SOURCE_STAGES = ['leads', 'opps', 'customers'] as const
86
+ export type SourceStageKey = typeof SOURCE_STAGES[number]
87
+ export interface DataSourceStage { actual: number; budget: number | null; pctToGoal: number | null }
88
+ /** Tableau budgets per BUDGET LINE, not per lead source, and one line can count several sources (Paid Social sits inside "Paid Search -
89
+ * Non-Brand"). So a line's actual and budget are the whole line's, never one source's. Optional: older files have none. */
90
+ export interface DataBudgetLine { industry: string; line: string; sources: string[]; stages: Partial<Record<SourceStageKey, DataSourceStage>> }
91
+ export interface DataSourceRow {
92
+ industry: string; source: string; bucket: string; leads: number; opps: number; customers: number
93
+ /** The budget line the writer verified this source counts under; absent in older files. Informational: the readers join a source to
94
+ * its line by the line's own member list (budgetLinesFor), for the sources that have rows. */
95
+ budgetLine?: string
96
+ }
78
97
  export interface DataFacts {
79
98
  label: string
80
99
  scope: string
@@ -85,6 +104,8 @@ export interface DataFacts {
85
104
  completeThrough?: string
86
105
  groups: DataGroup[]
87
106
  sources: DataSourceRow[]
107
+ /** Empty for a file written before budget lines. */
108
+ budgetLines: DataBudgetLine[]
88
109
  }
89
110
 
90
111
  export type DataFactsOff = 'missing' | 'unreadable' | 'invalid' | 'stale' | 'future'
@@ -118,6 +139,26 @@ function parseStage(raw: unknown): DataStage | null {
118
139
  return stage
119
140
  }
120
141
 
142
+ function parseSourceStage(raw: unknown): DataSourceStage | null {
143
+ if (!isObj(raw)) return null
144
+ const actual = fin(raw.actual)
145
+ return actual === null || actual < 0 ? null : { actual, budget: fin(raw.budget), pctToGoal: fin(raw.pctToGoal) }
146
+ }
147
+
148
+ function parseBudgetLine(raw: unknown): DataBudgetLine | null {
149
+ if (!isObj(raw) || !isObj(raw.stages)) return null
150
+ const industry = str(raw.industry, 40), line = str(raw.line, 80)
151
+ if (!industry || !line) return null
152
+ const stages: DataBudgetLine['stages'] = {}
153
+ for (const key of SOURCE_STAGES) {
154
+ const st = parseSourceStage((raw.stages as Record<string, unknown>)[key])
155
+ if (st) stages[key] = st
156
+ }
157
+ if (!Object.keys(stages).length) return null
158
+ const sources = Array.isArray(raw.sources) ? raw.sources.map(x => str(x, 80)).filter((x): x is string => x !== null).slice(0, 20) : []
159
+ return { industry, line, sources, stages }
160
+ }
161
+
121
162
  function parseGroup(raw: unknown): DataGroup | null {
122
163
  if (!isObj(raw)) return null
123
164
  const name = str(raw.name, 40)
@@ -154,14 +195,16 @@ export function parseDataFacts(raw: unknown, nowMs: number): DataFactsRead {
154
195
  if (!isObj(r)) continue
155
196
  const industry = str(r.industry, 40), source = str(r.source, 80)
156
197
  const leads = fin(r.leads), opps = fin(r.opps), customers = fin(r.customers)
157
- if (industry && source && leads !== null && opps !== null && customers !== null) sources.push({ industry, source, bucket: typeof r.bucket === 'string' ? r.bucket : '', leads, opps, customers })
198
+ const budgetLine = str(r.budgetLine, 80)
199
+ if (industry && source && leads !== null && opps !== null && customers !== null) sources.push({ industry, source, bucket: typeof r.bucket === 'string' ? r.bucket : '', leads, opps, customers, ...(budgetLine ? { budgetLine } : {}) })
158
200
  }
201
+ const budgetLines = (Array.isArray(raw.budgetLines) ? raw.budgetLines.slice(0, DATA_FACTS_LIMITS.maxBudgetLines) : []).map(parseBudgetLine).filter((l): l is DataBudgetLine => l !== null)
159
202
  if (asOf - nowMs > DATA_FACTS_LIMITS.maxFutureMs) return { ok: false, reason: 'future' }
160
203
  const maxAgeMs = period.closed === true ? DATA_FACTS_LIMITS.maxClosedAgeDays * 24 * 3_600_000 : DATA_FACTS_LIMITS.maxAgeHours * 3_600_000
161
204
  if (nowMs - asOf > maxAgeMs) return { ok: false, reason: 'stale' }
162
205
  const through = typeof raw.completeThrough === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(raw.completeThrough) ? raw.completeThrough : undefined
163
206
  return { ok: true, facts: { label, scope, period: { label: pLabel, start: pStart, end: pEnd, closed: period.closed }, asOfIso: asOfIso as string, asOf,
164
- ...(through ? { completeThrough: through } : {}), groups, sources } }
207
+ ...(through ? { completeThrough: through } : {}), groups, sources, budgetLines } }
165
208
  }
166
209
 
167
210
  /** Reads and parses the file. Missing, too big, unreadable or not JSON are three different reasons; never throws. */
@@ -237,40 +280,169 @@ function occurrences(tokens: string[], phrase: string[]): number[] {
237
280
  return at
238
281
  }
239
282
 
240
- function weakAt(tokens: string[], i: number): boolean {
241
- const near = (k: number) => (k >= 0 && k < tokens.length ? tokens[k] : '')
242
- const funnelOrIndustry = (t: string) => FUNNEL_TOKENS.has(t) || INDUSTRY_TOKENS.has(t)
243
- if (funnelOrIndustry(near(i - 1)) || funnelOrIndustry(near(i + 1))) return true
244
- return (BRIDGE_TOKENS.has(near(i - 1)) && FUNNEL_TOKENS.has(near(i - 2))) || (BRIDGE_TOKENS.has(near(i + 1)) && FUNNEL_TOKENS.has(near(i + 2)))
283
+ /** `anchors` are the token positions of a lead-source name that is specific enough to stand alone (ppc, paid search): directly beside one,
284
+ * a weak alias counts the way it does beside a funnel word ("paid search pet", "pet ppc"). Not across a bridge word: "let's dive in,
285
+ * google ads are the topic" is not about the dive industry. */
286
+ function weakAt(tokens: string[], i: number, anchors: ReadonlySet<number>): boolean {
287
+ const inside = (k: number) => k >= 0 && k < tokens.length
288
+ const funnel = (k: number) => inside(k) && FUNNEL_TOKENS.has(tokens[k])
289
+ const funnelOrIndustry = (k: number) => funnel(k) || anchors.has(k) || (inside(k) && INDUSTRY_TOKENS.has(tokens[k]))
290
+ const bridge = (k: number) => inside(k) && BRIDGE_TOKENS.has(tokens[k])
291
+ if (funnelOrIndustry(i - 1) || funnelOrIndustry(i + 1)) return true
292
+ return (bridge(i - 1) && funnel(i - 2)) || (bridge(i + 1) && funnel(i + 2))
293
+ }
294
+
295
+ // ── Lead sources ─────────────────────────────────────────────────────────────
296
+
297
+ /** How a lead source is said aloud. `strong` aliases are jargon or multi-word names that stand alone; `weak` ones are plain English
298
+ * (direct, events, email, organic, social...) and count only beside a named group or a stage word. `match` picks the source names IN THE
299
+ * FILE the concept covers, so a file without Webinar rows has nothing for "webinar". Names in the file are aliases too (see sourceHits); where
300
+ * a concept lists the same phrase as weak, the concept wins the tie, so a plain-English name stays weak. */
301
+ interface SourceConcept { label: string; strong: string[]; weak: string[]; match: (key: string) => boolean; /** "non-brand" or "brand" right beside it narrows it. */ narrow?: boolean }
302
+ const SOURCE_CONCEPTS: SourceConcept[] = [
303
+ { label: 'Paid Search', strong: ['paid search', 'ppc', 'pay per click', 'sem', 'search engine marketing', 'google ads', 'google adwords', 'adwords', 'search ads'], weak: [], match: k => k.startsWith('paid search'), narrow: true },
304
+ { label: 'Paid Search Brand', strong: [], weak: ['brand search', 'branded search'], match: k => k === 'paid search brand' },
305
+ { label: 'Paid Search Non-Brand', strong: [], weak: ['non brand search', 'nonbrand search'], match: k => k === 'paid search non brand' },
306
+ { label: 'Paid Social', strong: ['paid social', 'social ads', 'facebook ads', 'meta ads', 'instagram ads', 'linkedin ads', 'paid facebook', 'paid linkedin'], weak: ['facebook', 'linkedin', 'instagram'], match: k => k === 'paid social' },
307
+ { label: 'Social Media', strong: [], weak: ['social media'], match: k => k === 'social media' },
308
+ { label: 'Social', strong: [], weak: ['social'], match: k => k.includes('social') },
309
+ { label: 'Events', strong: ['trade show', 'trade shows', 'tradeshow', 'tradeshows'], weak: ['events', 'event', 'conference', 'conferences'], match: k => /(^| )events?( |$)/.test(k) },
310
+ { label: 'Organic', strong: [], weak: ['organic', 'organic search', 'seo'], match: k => k.startsWith('organic') },
311
+ { label: 'Direct', strong: [], weak: ['direct', 'direct traffic'], match: k => k === 'direct' },
312
+ { label: 'Referral Traffic', strong: [], weak: ['referral traffic'], match: k => k === 'referral traffic' },
313
+ { label: 'Customer Referral', strong: [], weak: ['customer referral', 'customer referrals'], match: k => k === 'customer referral' },
314
+ { label: 'Referral', strong: [], weak: ['referral', 'referrals'], match: k => k === 'referral traffic' || k === 'customer referral' },
315
+ { label: 'AI Referrals', strong: ['ai referrals', 'ai referral', 'ai traffic'], weak: ['chatgpt', 'chat gpt', 'perplexity'], match: k => k === 'ai referrals' },
316
+ { label: 'Aggregators', strong: ['paid aggregator', 'paid aggregators', 'lead aggregator', 'lead aggregators', 'capterra', 'software advice', 'getapp', 'g2 crowd'], weak: ['aggregator', 'aggregators'], match: k => k === 'paid aggregator' },
317
+ { label: 'Email', strong: ['email database', 'email marketing', 'newsletter', 'newsletters'], weak: ['email', 'emails'], match: k => k === 'email database' },
318
+ { label: 'Webinar', strong: [], weak: ['webinar', 'webinars'], match: k => k === 'webinar' },
319
+ { label: 'Partnership', strong: ['paid partnership', 'paid partnerships'], weak: ['partnership', 'partnerships'], match: k => k === 'paid partnership' },
320
+ ]
321
+
322
+ const sourceKey = (name: string): string => wordsOfSpeech(name).join(' ')
323
+ /** "Paid Search - Non-Brand" as people say it: "Paid Search Non-Brand". */
324
+ export const sourceLabel = (name: string): string => name.replace(/\s+-\s+/g, ' ')
325
+
326
+ /** Every lead source the file knows: the rows' and the budget lines' members. */
327
+ function sourceNamesOf(facts: DataFacts): string[] {
328
+ const out = new Set<string>()
329
+ for (const r of facts.sources) out.add(r.source)
330
+ for (const l of facts.budgetLines) for (const x of l.sources) out.add(x)
331
+ return [...out]
332
+ }
333
+
334
+ /** Plain words that are a lead source only some of the time. A hit is dropped where the next or previous word says it is something else:
335
+ * "direct reports", "the conference call", "let me email Gina". Keyed by the alias as spoken. */
336
+ const PLAIN_WORD_GUARDS: Record<string, (tokens: string[], start: number, end: number) => boolean> = {
337
+ direct: (t, _s, e) => ['report', 'reports', 'mail', 'message', 'messages', 'line', 'lines'].includes(t[e] ?? ''),
338
+ conference: (t, _s, e) => ['call', 'calls', 'room', 'rooms'].includes(t[e] ?? ''),
339
+ conferences: (t, _s, e) => ['call', 'calls', 'room', 'rooms'].includes(t[e] ?? ''),
340
+ // "email" the verb: after a helper or a pronoun ("let me email Gina", "I will email them") or before "about" or a pronoun.
341
+ email: (t, s, e) => emailAsVerb(t, s, e),
342
+ emails: (t, s, e) => emailAsVerb(t, s, e),
343
+ }
344
+ const EMAIL_VERB_BEFORE = new Set(['to', 'will', 'll', 'should', 'please', 'can', 'could', 'would', 'just', 'gonna', 'may', 'might', 'must', 'me', 'us', 'we', 'i', 'you', 'they', 'he', 'she'])
345
+ const EMAIL_VERB_AFTER = new Set(['about', 'him', 'her', 'them', 'me', 'us', 'you', 'everyone', 'everybody'])
346
+ function emailAsVerb(t: string[], start: number, end: number): boolean {
347
+ return EMAIL_VERB_BEFORE.has(t[start - 1] ?? '') || EMAIL_VERB_AFTER.has(t[end] ?? '')
348
+ }
349
+
350
+ interface SourceHit { start: number; end: number; names: string[]; strong: boolean; label: string }
351
+
352
+ /** "ppc non-brand", "brand ppc", "paid search brand": the word right beside a broad paid-search name picks Non-Brand or Brand. */
353
+ function narrowedNames(tokens: string[], start: number, end: number, names: string[]): string[] {
354
+ const at = (k: number) => tokens[k] ?? ''
355
+ const nonBrand = (at(start - 2) === 'non' && at(start - 1) === 'brand') || at(start - 1) === 'nonbrand' || (at(end) === 'non' && at(end + 1) === 'brand') || at(end) === 'nonbrand'
356
+ if (nonBrand) return names.filter(n => sourceKey(n).includes('non brand'))
357
+ const brand = at(start - 1) === 'brand' || at(start - 1) === 'branded' || at(end) === 'brand' || at(end) === 'branded'
358
+ return brand ? names.filter(n => sourceKey(n).endsWith('brand') && !sourceKey(n).includes('non brand')) : names
359
+ }
360
+
361
+ /** Every place the speech names a lead source, the longer name winning where two overlap ("paid search non-brand" beats "paid search"). */
362
+ function sourceHits(facts: DataFacts, tokens: string[]): SourceHit[] {
363
+ const all = sourceNamesOf(facts)
364
+ const hits: SourceHit[] = []
365
+ const add = (alias: string, strong: boolean, label: string, names: string[], narrow: boolean) => {
366
+ const phrase = wordsOfSpeech(alias)
367
+ const guard = phrase.length === 1 ? PLAIN_WORD_GUARDS[phrase[0]] : undefined
368
+ for (const start of occurrences(tokens, phrase)) {
369
+ const end = start + phrase.length
370
+ if (guard?.(tokens, start, end)) continue
371
+ hits.push({ start, end, names: narrow ? narrowedNames(tokens, start, end, names) : names, strong, label })
372
+ }
373
+ }
374
+ for (const c of SOURCE_CONCEPTS) {
375
+ const names = all.filter(n => c.match(sourceKey(n)))
376
+ for (const a of c.strong) add(a, true, c.label, names, !!c.narrow)
377
+ for (const a of c.weak) add(a, false, c.label, names, !!c.narrow)
378
+ }
379
+ for (const n of all) add(n, wordsOfSpeech(n).length > 1, sourceLabel(n), [n], false)
380
+ const span = (h: SourceHit) => h.end - h.start
381
+ return hits.filter((h, i) => !hits.some((o, j) => j !== i && o.start <= h.start && o.end >= h.end
382
+ && (span(o) > span(h) || (span(o) === span(h) && (o.names.length > h.names.length || (o.names.length === h.names.length && j < i))))))
383
+ }
384
+
385
+ export interface NamedSource {
386
+ /** What the speech called it ("Paid Search", "Events"), for a sentence. */
387
+ label: string
388
+ /** The source names in the file it covers. Empty when this extract has nothing for it. */
389
+ names: string[]
390
+ /** Specific enough to stand alone; a weak one is plain English and counts only beside a group or a stage word. */
391
+ strong: boolean
392
+ at: number
393
+ }
394
+
395
+ /** The lead sources the speech names, the one named last first. Two phrases that cover the same source names are one source. */
396
+ export function namedSources(facts: DataFacts, speech: string): NamedSource[] {
397
+ const merged = new Map<string, NamedSource>()
398
+ for (const h of sourceHits(facts, wordsOfSpeech(speech))) {
399
+ const key = [...h.names].sort().join('|') || `none:${h.label}`
400
+ const cur = merged.get(key)
401
+ if (!cur) merged.set(key, { label: h.label, names: h.names, strong: h.strong, at: h.start })
402
+ else { cur.strong = cur.strong || h.strong; cur.at = Math.max(cur.at, h.start) }
403
+ }
404
+ return [...merged.values()].sort((a, b) => b.at - a.at)
245
405
  }
246
406
 
247
407
  export interface NamedGroup { group: DataGroup; at: number; /** Named by a Quilt brand name or sbu/xbu, not only by an industry word. */ brand: boolean }
248
408
  /** The groups the speech names, the one named last first. A group named twice counts at its last mention. */
249
409
  export function namedGroups(facts: DataFacts, speech: string): NamedGroup[] {
250
410
  const tokens = wordsOfSpeech(speech)
411
+ const anchors = new Set<number>()
412
+ for (const h of sourceHits(facts, tokens)) if (h.strong) for (let k = h.start; k < h.end; k += 1) anchors.add(k)
251
413
  const out: NamedGroup[] = []
252
414
  for (const group of facts.groups) {
253
415
  let last = -1, brand = false
254
416
  for (const alias of group.aliases) for (const i of occurrences(tokens, wordsOfSpeech(alias))) last = Math.max(last, i)
255
417
  for (const alias of group.brandAliases) for (const i of occurrences(tokens, wordsOfSpeech(alias))) { last = Math.max(last, i); brand = true }
256
- for (const alias of group.weakAliases) for (const i of occurrences(tokens, wordsOfSpeech(alias))) if (weakAt(tokens, i)) last = Math.max(last, i)
418
+ for (const alias of group.weakAliases) for (const i of occurrences(tokens, wordsOfSpeech(alias))) if (weakAt(tokens, i, anchors)) last = Math.max(last, i)
257
419
  if (last >= 0) out.push({ group, at: last, brand })
258
420
  }
259
421
  return out.sort((a, b) => b.at - a.at)
260
422
  }
261
423
 
262
- export type DataTrigger = { fired: true; groups: DataGroup[]; stages: DataStageKey[]; brand: boolean } | { fired: false; why: 'no_group' | 'no_funnel' }
424
+ export type DataTrigger = { fired: true; groups: DataGroup[]; stages: DataStageKey[]; brand: boolean
425
+ /** The lead source the speech named last, when the note should be about that source. Null: the group totals. */
426
+ source: NamedSource | null
427
+ /** The speech carries a funnel word or a figure. False only when a source beside a group fired it. */
428
+ funnel: boolean } | { fired: false; why: 'no_group' | 'no_funnel' }
263
429
 
264
- /** Fires when the speech names a group AND carries a funnel word or a figure (two digits or more, or a percent). */
430
+ /** Fires when the speech names a group AND carries a funnel word or a figure (two digits or more, or a percent). A lead source narrows
431
+ * the note: a specific one fires beside a group with no funnel word ("PPC landing pages for thrift") and, with a funnel word or a
432
+ * figure, alone; a plain-English one ("events", "direct") counts only beside a group. */
265
433
  export function dataTrigger(facts: DataFacts, window: string): DataTrigger {
266
434
  const speech = recentSpeech(window)
267
435
  const named = namedGroups(facts, speech)
268
- if (!named.length) return { fired: false, why: 'no_group' }
269
436
  const tokens = wordsOfSpeech(speech)
270
- if (!tokens.some(t => FUNNEL_TOKENS.has(t)) && !hasFigure(speech)) return { fired: false, why: 'no_funnel' }
437
+ // The last specific source named; a plain-English one only when no specific one was, and only beside a group.
438
+ const usable = namedSources(facts, speech).filter(s => s.names.length > 0 && (s.strong || named.length > 0))
439
+ const source = usable.find(s => s.strong) ?? usable[0] ?? null
440
+ if (!named.length && !source) return { fired: false, why: 'no_group' }
441
+ const funnel = tokens.some(t => FUNNEL_TOKENS.has(t)) || hasFigure(speech)
442
+ if (!funnel && !(source?.strong && named.length)) return { fired: false, why: 'no_funnel' }
271
443
  const stages = [...new Set(tokens.map(t => STAGE_OF[t]).filter((s): s is DataStageKey => !!s))]
272
444
  const picked = named.slice(0, DATA_FACTS_LIMITS.maxNamedGroups)
273
- return { fired: true, groups: picked.map(n => n.group), stages: DATA_STAGES.filter(s => stages.includes(s)), brand: picked.some(n => n.brand) }
445
+ return { fired: true, groups: picked.map(n => n.group), stages: DATA_STAGES.filter(s => stages.includes(s)), brand: picked.some(n => n.brand), source, funnel }
274
446
  }
275
447
 
276
448
  export interface DataGateInput {
@@ -319,7 +491,7 @@ export function quiltTitleSignal(facts: DataFacts, titles: ReadonlyArray<string
319
491
 
320
492
  const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
321
493
  const fmt = (n: number) => (Number.isInteger(n) ? String(n) : String(Math.round(n * 10) / 10))
322
- const pctText = (s: DataStage) => (s.pctToGoal === null ? '' : ` (${Math.round(s.pctToGoal)}%)`)
494
+ const pctText = (s: { pctToGoal: number | null }) => (s.pctToGoal === null ? '' : ` (${Math.round(s.pctToGoal)}%)`)
323
495
 
324
496
  /** "Nov 2 05:41 CT": the refresh as the file states it, with CT only for a Central offset. */
325
497
  export function refreshText(iso: string): string {
@@ -375,14 +547,91 @@ export function dataCandidateText(facts: DataFacts, groups: DataGroup[], named:
375
547
  return build(groups.slice(0, 1), 2).slice(0, max)
376
548
  }
377
549
 
550
+ const bySourceLeads = (a: DataSourceRow, b: DataSourceRow) => b.leads - a.leads
551
+ const rowStages = (r: DataSourceRow, shown: SourceStageKey[]) => shown.map(k => `${k} ${fmt(r[k])}`).join(', ')
552
+
553
+ /** The rows of the named source names, for these industries (every industry when null), the largest by leads first. */
554
+ function sourceRowsFor(facts: DataFacts, names: string[], industries: string[] | null): DataSourceRow[] {
555
+ return facts.sources.filter(r => names.includes(r.source) && (!industries || industries.includes(r.industry))).sort(bySourceLeads)
556
+ }
557
+
558
+ /** The budget lines of an industry that count any of these sources. A line is the whole line's actual against its budget. */
559
+ function budgetLinesFor(facts: DataFacts, industry: string, names: string[]): DataBudgetLine[] {
560
+ return facts.budgetLines.filter(l => l.industry === industry && l.sources.some(x => names.includes(x)))
561
+ }
562
+
563
+ /** One budget line as a sentence. `rowSources` are the sources shown above it (the ones that have rows). A line named for one of them
564
+ * says what else it counts ("Budget line Paid Search Non-Brand (also counts Paid Social): ..."); a line named for something else says
565
+ * the source sits inside it ("Paid Social is budgeted inside the Paid Search Non-Brand line, which also counts Paid Search Non-Brand: ...").
566
+ * "Also counts" lists only other sources that have volume in this industry. Empty when the line has none of the shown stages. */
567
+ function budgetClause(facts: DataFacts, l: DataBudgetLine, rowSources: string[], shown: SourceStageKey[]): string {
568
+ const text = lineStagesText(l, shown)
569
+ if (!text) return ''
570
+ const others = l.sources.filter(x => !rowSources.includes(x) && facts.sources.some(r => r.industry === l.industry && r.source === x && r.leads + r.opps + r.customers > 0))
571
+ .slice(0, 3).map(sourceLabel).join(', ')
572
+ const line = sourceLabel(l.line)
573
+ if (rowSources.some(x => sourceKey(x) === sourceKey(l.line))) return `Budget line ${line}${others ? ` (also counts ${others})` : ''}: ${text}.`
574
+ const mine = rowSources.filter(x => l.sources.includes(x)).map(sourceLabel)
575
+ return `${mine.join(' and ')} ${mine.length > 1 ? 'are' : 'is'} budgeted inside the ${line} line${others ? `, which also counts ${others}` : ''}: ${text}.`
576
+ }
577
+
578
+ function lineStagesText(l: DataBudgetLine, shown: SourceStageKey[]): string {
579
+ const parts = shown.filter(k => l.stages[k]).map(k => { const st = l.stages[k] as DataSourceStage; return `${k} ${fmt(st.actual)} vs ${st.budget === null ? 'no budget' : fmt(st.budget)}${pctText(st)}` })
580
+ return parts.join(', ')
581
+ }
582
+
583
+ /** The candidate when the room named a lead source. Leads with that source's own rows ("Thrift, Paid Search Non-Brand alone: leads 45,
584
+ * opps 11, customers 3") for the named industry, or for the largest industries when no group is named; with the budget line it is counted
585
+ * under, when the file has one, because Tableau budgets per line and a line can count more than one source. At most candidateChars:
586
+ * budget lines, then a second group, then stages are dropped before it is cut. Empty when the source has no row for the named group. */
587
+ export function dataSourceCandidateText(facts: DataFacts, source: NamedSource, groups: DataGroup[], named: DataStageKey[]): string {
588
+ const head = `Tableau ${periodText(facts)}, marketing-sourced, lead-source detail, refreshed ${refreshText(facts.asOfIso)}.`
589
+ const max = DATA_FACTS_LIMITS.candidateChars
590
+ const wanted = SOURCE_STAGES.filter(k => named.includes(k))
591
+ const all: SourceStageKey[] = wanted.length ? wanted : [...SOURCE_STAGES]
592
+ const several = source.names.length > 1
593
+ // `budget`: every row's line, only the largest row's line, or none.
594
+ type Budget = 'all' | 'top' | 'none'
595
+ const segment = (g: DataGroup | null, shown: SourceStageKey[], budget: Budget): string => {
596
+ if (g && g.kind === 'industry' && g.members.length === 1) {
597
+ const industry = g.members[0]
598
+ const rows = sourceRowsFor(facts, source.names, [industry])
599
+ if (!rows.length) return ''
600
+ let out = `${g.display ?? g.name}, ${rows.map(r => `${sourceLabel(r.source)} alone: ${rowStages(r, shown)}`).join('; ')}.`
601
+ if (budget !== 'none') for (const l of budgetLinesFor(facts, industry, (budget === 'top' ? rows.slice(0, 1) : rows).map(r => r.source))) {
602
+ const clause = budgetClause(facts, l, rows.map(r => r.source), shown)
603
+ if (clause) out += ` ${clause}`
604
+ }
605
+ return out
606
+ }
607
+ const found = sourceRowsFor(facts, source.names, g ? g.members : null)
608
+ // A list of the largest industries is only worth a cue when the largest has something to say.
609
+ if (!found.length || found[0].leads === 0) return ''
610
+ const rows = found.slice(0, DATA_FACTS_LIMITS.cueSourceRows)
611
+ return `${g ? `${g.display ?? g.name} industries, ` : ''}${several ? source.label : sourceLabel(source.names[0])} alone, largest by leads: ${rows.map(r => `${r.industry}${several ? ` ${sourceLabel(r.source)}` : ''} ${rowStages(r, shown)}`).join('; ')}.`
612
+ }
613
+ const tries = groups.length ? [groups, groups.slice(0, 1)] : [[] as DataGroup[]]
614
+ for (const gs of tries) for (const budget of ['all', 'top', 'none'] as Budget[]) for (let cut = 0; cut < 3; cut += 1) {
615
+ const shown = all.slice(0, Math.max(1, all.length - cut))
616
+ const parts = (gs.length ? gs : [null]).map(g => segment(g, shown, budget)).filter(Boolean)
617
+ const text = parts.length ? `${head} ${parts.join(' ')}` : ''
618
+ if (text && text.length <= max) return text
619
+ }
620
+ const parts = (tries[tries.length - 1].length ? tries[tries.length - 1] : [null]).map(g => segment(g, all.slice(0, 1), 'none')).filter(Boolean)
621
+ return parts.length ? `${head} ${parts.join(' ')}`.slice(0, max) : ''
622
+ }
623
+
378
624
  /** The grounded candidate for a window, or why there is none. */
379
- export function dataCandidate(facts: DataFacts, window: string, nowDate: string): { candidate: GroundedNote; brand: boolean } | { none: 'no_group' | 'no_funnel' | 'no_stage' } {
625
+ export function dataCandidate(facts: DataFacts, window: string, nowDate: string): { candidate: GroundedNote; brand: boolean; /** Fired by a lead source with no group named. */ sourceOnly: boolean } | { none: 'no_group' | 'no_funnel' | 'no_stage' } {
380
626
  const t = dataTrigger(facts, window)
381
627
  if (!t.fired) return { none: t.why }
382
- const notes = dataCandidateText(facts, t.groups, t.stages)
628
+ const fromSource = t.source ? dataSourceCandidateText(facts, t.source, t.groups, t.stages) : ''
629
+ // A source with no row for the named group must not turn a window with no funnel word into a totals cue.
630
+ if (!fromSource && t.source && !t.funnel) return { none: 'no_funnel' }
631
+ const notes = fromSource || (t.groups.length ? dataCandidateText(facts, t.groups, t.stages) : '')
383
632
  if (!notes) return { none: 'no_stage' }
384
633
  const date = /^\d{4}-\d{2}-\d{2}/.exec(facts.asOfIso)?.[0] ?? nowDate
385
- return { candidate: { id: 'D1', date, title: facts.label, notes, key: `data:tableau|${facts.label}|${facts.asOfIso}`, arm: 'data', kind: 'data' }, brand: t.brand }
634
+ return { candidate: { id: 'D1', date, title: facts.label, notes, key: `data:tableau|${facts.label}|${facts.asOfIso}`, arm: 'data', kind: 'data' }, brand: t.brand, sourceOnly: t.groups.length === 0 }
386
635
  }
387
636
 
388
637
  /** Appended to the writer's rules only when a Tableau note was kept. The v3/v4 rules themselves stay byte-identical. */
@@ -391,6 +640,8 @@ export const DATA_WRITER_ADDENDUM = '\nTableau note. A memory note whose title s
391
640
  + 'If you use it, say Tableau, the period, and marketing sources. Quote its numbers exactly; do not sum, project or round them. '
392
641
  + 'Set a figure the room said beside the Tableau figure only when both measure the same thing (same stage, same group). '
393
642
  + 'When they differ in scope, such as MRR or locations against marketing-sourced customers, say they are different measures and give the Tableau figure as context, not as proof that the room is wrong. '
643
+ + 'A note that names a lead source (Paid Search Non-Brand, Events...) covers only that source: say which source, never present it as the whole group, '
644
+ + 'and when it gives a budget line, say the budget is for that line, which can count other sources too. '
394
645
  + 'If the note does not cover what the room said, ignore it.'
395
646
 
396
647
  // ── The meeting assistant ────────────────────────────────────────────────────
@@ -407,23 +658,47 @@ export function askUserWords(question: string): string {
407
658
  return last.length <= DATA_FACTS_LIMITS.askQuestionChars && last.length < question.trim().length ? last : ''
408
659
  }
409
660
 
661
+ /** A funnel stage or pacing word: enough, beside a plain-English source word, to say the question is about the data ("leads from events"). */
662
+ const ASK_STAGE = /\b(?:leads?|opps?|opportunit(?:y|ies)|sqls?|demos?|logos?|customers?|pac(?:e|ing)|attainment)\b/i
663
+ const ASK_SOURCE_WORD = /\b(?:sources?|channels?|campaigns?)\b/i
664
+ /** "how did events do for thrift": a question about how something performed, which a group and a plain source word then make a data question. */
665
+ const ASK_PERFORMANCE = /\b(?:do|doing|perform(?:ed|ing|ance)?|results?|numbers?|convert(?:ed|ing)?|conversions?|roi)\b/i
666
+
667
+ /** The lead sources a typed question names that it may be answered from. A specific name ("paid search", "PPC") always counts; a
668
+ * plain-English one ("direct", "events", "email") only with a stage or source word, or with a group beside "Tableau" or a
669
+ * performance word, so "the direct reports on the thrift team" stays quiet. */
670
+ export function askSources(facts: DataFacts, own: string, hasGroup: boolean): NamedSource[] {
671
+ const weakOk = ASK_STAGE.test(own) || ASK_SOURCE_WORD.test(own) || (hasGroup && (ASK_PERFORMANCE.test(own) || ASK_TABLEAU.test(own)))
672
+ return namedSources(facts, own).filter(x => x.strong || weakOk)
673
+ }
674
+
410
675
  export interface AskDataIntent { intent: 'tableau' | 'named' | null; /** A Quilt brand or sbu/xbu is named. */ brand: boolean }
411
676
  /** What a typed question asks of the data: `tableau` when it says Tableau, `named` when it names a group and asks about funnel,
412
- * budget or pacing, else null. `tableau` may be answered in any meeting but a personal one. `named` goes through dataArmGate like
677
+ * budget or pacing, or names a lead source (see askSources) beside a group or a funnel word, else null. `tableau` may be answered in any meeting but a personal one. `named` goes through dataArmGate like
413
678
  * the cue arm: a Quilt meeting, or an unconfirmed one with a Quilt brand named or a Quilt term in the meeting's title. */
414
679
  export function askDataIntent(question: string, facts: DataFacts): AskDataIntent {
415
680
  const own = askUserWords(question)
416
681
  const named = namedGroups(facts, own)
417
682
  const brand = named.some(n => n.brand)
418
683
  if (ASK_TABLEAU.test(own)) return { intent: 'tableau', brand }
419
- return { intent: ASK_FUNNEL.test(own) && named.length > 0 ? 'named' : null, brand }
684
+ if (ASK_FUNNEL.test(own) && named.length > 0) return { intent: 'named', brand }
685
+ // A lead source: a specific name needs a group or a funnel word beside it ("how did paid search do for thrift"); a plain-English one
686
+ // already passed askSources' own test.
687
+ const sources = askSources(facts, own, named.length > 0)
688
+ if (sources.length && (!sources.some(x => x.strong) || named.length > 0 || ASK_FUNNEL.test(own))) return { intent: 'named', brand }
689
+ return { intent: null, brand }
420
690
  }
421
691
 
422
692
  /** The Tableau text the meeting assistant reads as a local source, or null. Stage rows for the groups the question names (or the
423
- * business units and every industry when it names none), then the lead-source rows of the named industries. */
693
+ * business units and every industry when it names none), then the lead-source rows of the named industries. When the question names a
694
+ * lead source ("paid search", "PPC"), the answer is about that source instead: its rows for the named industries, or for every industry
695
+ * when none is named, each with the budget line Tableau counts it under. The rows are listed, never added up: the writer's rules say
696
+ * not to sum, and a sum of rows is not a figure Tableau shows. */
424
697
  export function dataFactsForAsk(facts: DataFacts, rawQuestion: string): { title: string; text: string } | null {
425
- const named = namedGroups(facts, askUserWords(rawQuestion)).map(n => n.group)
426
- const chosen = named.length ? named : facts.groups
698
+ const own = askUserWords(rawQuestion)
699
+ const named = namedGroups(facts, own).map(n => n.group)
700
+ const sources = askSources(facts, own, named.length > 0)
701
+ const chosen = named.length || sources.length ? named : facts.groups
427
702
  const lines: string[] = [
428
703
  `Tableau marketing funnel, ${periodText(facts)}. Scope: ${facts.scope}; marketing-sourced leads, opportunities, SQLs, demos and customers only. Not company revenue, MRR, ARR or location counts.`,
429
704
  `Extract refreshed ${refreshText(facts.asOfIso)}.${facts.completeThrough ? ` Complete through ${facts.completeThrough}.` : ''} Each stage: actual vs budget to date (% to goal; PM = prior month same period; PY = last year same period).`,
@@ -438,16 +713,51 @@ export function dataFactsForAsk(facts: DataFacts, rawQuestion: string): { title:
438
713
  })
439
714
  lines.push(`${g.display ?? g.name}${cov}: ${stages.join('; ')}`)
440
715
  }
441
- if (named.length) {
716
+ if (sources.length) {
717
+ lines.push(...sourceLinesForAsk(facts, sources, named, lines.join('\n').length))
718
+ } else if (named.length) {
442
719
  for (const g of named) {
443
720
  const rows = facts.sources.filter(r => g.members.includes(r.industry)).sort((a, b) => b.leads - a.leads).slice(0, DATA_FACTS_LIMITS.askSourceRowsPerIndustry * Math.max(1, g.members.length))
444
721
  if (rows.length) lines.push(`${g.display ?? g.name} lead sources (leads/opps/customers): ${rows.map(r => `${g.kind === 'bu' ? `${r.industry} ` : ''}${r.source} ${fmt(r.leads)}/${fmt(r.opps)}/${fmt(r.customers)}`).join('; ')}`)
445
722
  }
446
723
  }
447
724
  const text = lines.join('\n').slice(0, DATA_FACTS_LIMITS.askChars)
448
- return chosen.length ? { title: facts.label, text } : null
725
+ return chosen.length || sources.length ? { title: facts.label, text } : null
726
+ }
727
+
728
+ /** The lines for a question about a lead source: what was asked, then one line per industry (largest leads first) with that source's
729
+ * rows and the budget line(s) it is counted under. Industries that do not fit in askChars are named as left out, never half shown. */
730
+ function sourceLinesForAsk(facts: DataFacts, sources: NamedSource[], groups: DataGroup[], used: number): string[] {
731
+ const names = [...new Set(sources.flatMap(x => x.names))]
732
+ const asked = sources.map(x => `${x.label}${x.names.length ? ` (${x.names.map(sourceLabel).join(', ')})` : ' (no rows in this Tableau extract)'}`).join('; ')
733
+ const out = [`Lead source asked about: ${asked}. Each row below is that source alone as leads/opps/customers. Tableau sets budgets per budget line, and one line can count several sources, `
734
+ + 'so a budget appears only against its whole line, never against one source. Rows are listed, not added up.']
735
+ if (!names.length) return out
736
+ const wanted = groups.length ? new Set(groups.flatMap(g => g.members)) : null
737
+ // The industries that have rows for the source; plus a named single industry with none, so the answer says so.
738
+ const industries = [...new Set([...facts.sources.filter(r => names.includes(r.source)).map(r => r.industry).filter(i => !wanted || wanted.has(i)),
739
+ ...groups.filter(g => g.kind === 'industry').flatMap(g => g.members)])]
740
+ const leadsOf = (i: string) => sourceRowsFor(facts, names, [i]).reduce((n, r) => n + r.leads, 0)
741
+ const blocks = industries.sort((a, b) => leadsOf(b) - leadsOf(a)).map(industry => {
742
+ const rows = sourceRowsFor(facts, names, [industry])
743
+ const display = facts.groups.find(g => g.kind === 'industry' && g.name === industry)?.display ?? industry
744
+ const rowText = rows.length ? rows.map(r => `${sourceLabel(r.source)} ${fmt(r.leads)}/${fmt(r.opps)}/${fmt(r.customers)}`).join('; ') : 'no rows for this source'
745
+ // Only the sources that have rows here choose the lines: "social" must not pull in a line whose only link is a source with no rows.
746
+ const rowSources = rows.map(r => r.source)
747
+ const budget = budgetLinesFor(facts, industry, rowSources).map(l => budgetClause(facts, l, rowSources, [...SOURCE_STAGES])).filter(Boolean)
748
+ return `${display} (leads/opps/customers): ${rowText}.${budget.length ? ` ${budget.join(' ')}` : ''}`
749
+ })
750
+ const room = DATA_FACTS_LIMITS.askChars - used - out[0].length - 80
751
+ let spent = 0, shown = 0
752
+ for (const b of blocks) {
753
+ if (spent + b.length + 1 > room) break
754
+ out.push(b); spent += b.length + 1; shown += 1
755
+ }
756
+ if (shown < blocks.length) out.push(`${blocks.length - shown} more industries are not shown (the smallest by leads).`)
757
+ return out
449
758
  }
450
759
 
451
760
  /** Added to the meeting assistant's rules only when its Tableau source is in the context. */
452
761
  export const DATA_ASK_ADDENDUM = ' A Tableau source covers the marketing-sourced funnel only: when you use it, say Tableau, its period and marketing sources, '
453
- + 'and never present it as company revenue, MRR, ARR or location counts. Set a figure from the meeting beside it only when both measure the same thing; otherwise name the difference.'
762
+ + 'and never present it as company revenue, MRR, ARR or location counts. Set a figure from the meeting beside it only when both measure the same thing; otherwise name the difference. '
763
+ + 'A lead-source row covers only that source: say which source it is, and when you quote a budget, say it is the budget line, which can count other sources too.'
@@ -28,7 +28,8 @@ export type LiveCuesV2SettingSource = 'env' | 'setting' | 'default'
28
28
  /** COS Control's live-cue spend limits (6.79.2). Bounds are inclusive; integer fields take whole numbers only. */
29
29
  export const LIVE_CUES_V2_LIMIT_BOUNDS = {
30
30
  gate: { min: 0.5, max: 0.95, default: 0.8, integer: false, env: 'COS_LIVE_CUES_V2_GATE' },
31
- maxCuesPerMeeting: { min: 1, max: 25, default: 25, integer: true, env: 'COS_LIVE_CUES_V2_MAX_PER_MEETING' },
31
+ // 6.99.0: the ceiling is 50 (Miles 2026-11-03, longer meetings); the default stays 25. Everything that clamps or validates this reads `.max`.
32
+ maxCuesPerMeeting: { min: 1, max: 50, default: 25, integer: true, env: 'COS_LIVE_CUES_V2_MAX_PER_MEETING' },
32
33
  cooldownSeconds: { min: 20, max: 900, default: 45, integer: true, env: 'COS_LIVE_CUES_V2_COOLDOWN_S' },
33
34
  dailyWriterCalls: { min: 1, max: 500, default: 150, integer: true, env: 'COS_LIVE_CUES_V2_DAILY_WRITER_CALLS' },
34
35
  } as const
@@ -90,8 +90,10 @@ export const LIVE_CUES_V2_LIMITS = {
90
90
  staleMs: 30_000,
91
91
  windowWords: 400,
92
92
  windowChars: 2_200,
93
- /** 25 a meeting (Miles 2026-10-19: 10 ran out 10 minutes into an HC call). Writer calls stay at twice the cue cap so
94
- * discarded drafts cannot end a meeting's cues early, and the weekly default keeps the same ~15 capped meetings. */
93
+ /** FALLBACK DEFAULTS ONLY (nothing reads these at run time): the live cap is `resolveLiveCuesV2Limits().maxCuesPerMeeting`, which
94
+ * Settings can raise to 50 (6.99.0) and env can lock. 25 a meeting (Miles 2026-10-19: 10 ran out 10 minutes into an HC call).
95
+ * Writer calls stay at twice the cue cap so discarded drafts cannot end a meeting's cues early. At 25 cues the weekly default
96
+ * of 750 writer calls covers ~15 capped meetings (50 calls each); at 50 cues it covers ~7 (100 calls each). */
95
97
  maxCuesPerMeeting: 25,
96
98
  maxWriterCallsPerMeeting: 50,
97
99
  defaultWeeklyWriterCalls: 750,
@@ -171,9 +173,9 @@ export function resolveLiveCuesV2Limits(env: NodeJS.ProcessEnv = process.env, se
171
173
  try { saved = settings?.limits?.() ?? {} } catch { saved = {} }
172
174
  const fromEnv: Record<LiveCuesV2LimitName, number | null> = {
173
175
  gate: envGate(env),
174
- maxCuesPerMeeting: envWhole(env, LIVE_CUES_V2_LIMIT_BOUNDS.maxCuesPerMeeting.env, 1, 25),
176
+ maxCuesPerMeeting: envWhole(env, LIVE_CUES_V2_LIMIT_BOUNDS.maxCuesPerMeeting.env, LIVE_CUES_V2_LIMIT_BOUNDS.maxCuesPerMeeting.min, LIVE_CUES_V2_LIMIT_BOUNDS.maxCuesPerMeeting.max),
175
177
  cooldownSeconds: envCooldownSeconds(env),
176
- dailyWriterCalls: envWhole(env, LIVE_CUES_V2_LIMIT_BOUNDS.dailyWriterCalls.env, 1, 500),
178
+ dailyWriterCalls: envWhole(env, LIVE_CUES_V2_LIMIT_BOUNDS.dailyWriterCalls.env, LIVE_CUES_V2_LIMIT_BOUNDS.dailyWriterCalls.min, LIVE_CUES_V2_LIMIT_BOUNDS.dailyWriterCalls.max),
177
179
  }
178
180
  const out = {} as LiveCuesV2Limits
179
181
  for (const name of LIVE_CUES_V2_LIMIT_NAMES) {
@@ -1323,7 +1325,9 @@ export class LiveCuesV2Engine {
1323
1325
  if (!early.ok) { trace.dataArm = early.why; return null }
1324
1326
  const found = dataCandidate(read.facts, window, today)
1325
1327
  if ('none' in found) { trace.dataArm = found.none; return null }
1326
- const gate = dataArmGate({ domain: ctx.domain, orientationFailed: ctx.orientationFailed, brand: found.brand, memoryQuilt: ctx.memoryQuilt, titleQuilt: quiltTitleSignal(read.facts, ctx.titles) })
1328
+ // A lead source alone (no group named) is everyday speech in a marketing agency's meeting ("the trade show is in 30 days"): a Quilt
1329
+ // memory among the candidates does not vouch for it. Only a Quilt-confirmed meeting or a Quilt term in the title does.
1330
+ const gate = dataArmGate({ domain: ctx.domain, orientationFailed: ctx.orientationFailed, brand: found.brand, memoryQuilt: found.sourceOnly ? false : ctx.memoryQuilt, titleQuilt: quiltTitleSignal(read.facts, ctx.titles) })
1327
1331
  if (!gate.ok) { trace.dataArm = gate.why; return null }
1328
1332
  trace.dataArm = 'offered'
1329
1333
  trace.dataSignal = gate.signal