@gotcos/glasses-server 6.94.4 → 6.98.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.
Files changed (109) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/README.md +52 -0
  3. package/managed-runtime-contract.json +13 -0
  4. package/package.json +3 -1
  5. package/server/index.ts +19 -1
  6. package/server/lib/attached-provider-adapter.ts +35 -1
  7. package/server/lib/claude-bridge.ts +47 -0
  8. package/server/lib/codex-bridge.ts +11 -1
  9. package/server/lib/codex-rollout-watermark.ts +42 -0
  10. package/server/lib/cursor-bridge.ts +2 -1
  11. package/server/lib/dictation-clean-models.ts +2 -0
  12. package/server/lib/dictation-clean.ts +2 -2
  13. package/server/lib/dictation-polish-pipeline.ts +22 -2
  14. package/server/lib/dictation-prewarm.ts +16 -0
  15. package/server/lib/direct-bridge.ts +14 -2
  16. package/server/lib/direct-sse.ts +6 -1
  17. package/server/lib/display-bus.ts +1 -1
  18. package/server/lib/fork-thread.ts +32 -1
  19. package/server/lib/held-naming-batches.ts +36 -0
  20. package/server/lib/job-audit-caller.ts +20 -0
  21. package/server/lib/lens-gist-engines.ts +17 -5
  22. package/server/lib/live-cos-ask.ts +29 -6
  23. package/server/lib/live-cue-verdicts.ts +43 -8
  24. package/server/lib/live-cues-engine.ts +4 -1
  25. package/server/lib/live-cues-filter.ts +50 -0
  26. package/server/lib/live-cues-general-prompts.ts +137 -0
  27. package/server/lib/live-cues-general-topic.ts +245 -0
  28. package/server/lib/live-cues-general.ts +715 -0
  29. package/server/lib/live-cues-v2-data.ts +453 -0
  30. package/server/lib/live-cues-v2-grounded.ts +262 -0
  31. package/server/lib/live-cues-v2-judge-b.ts +59 -0
  32. package/server/lib/live-cues-v2-names.ts +49 -0
  33. package/server/lib/live-cues-v2-notes.ts +8 -4
  34. package/server/lib/live-cues-v2-open-items.ts +132 -0
  35. package/server/lib/live-cues-v2-pacing.ts +45 -0
  36. package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
  37. package/server/lib/live-cues-v2-settings.ts +89 -10
  38. package/server/lib/live-cues-v2.ts +570 -47
  39. package/server/lib/mac-recording-asr.ts +6 -3
  40. package/server/lib/mac-speaker-call-human.ts +371 -0
  41. package/server/lib/mac-speaker-call-interval-human.ts +499 -0
  42. package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
  43. package/server/lib/mac-speaker-call-original-view.ts +328 -0
  44. package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
  45. package/server/lib/mac-speaker-call-producer.ts +539 -0
  46. package/server/lib/mac-speaker-call-store.ts +264 -0
  47. package/server/lib/mac-speaker-call-view.ts +373 -0
  48. package/server/lib/mac-speaker-live-compute.ts +72 -0
  49. package/server/lib/mac-speaker-live-memory.ts +414 -0
  50. package/server/lib/mac-speaker-live-projection.ts +126 -0
  51. package/server/lib/mac-speaker-live-save.ts +90 -0
  52. package/server/lib/mac-speaker-live-store.ts +71 -0
  53. package/server/lib/mac-speaker-live-worker.ts +58 -0
  54. package/server/lib/mac-speaker-live.ts +175 -0
  55. package/server/lib/mac-speaker-profile-extractor.ts +15 -10
  56. package/server/lib/mac-speaker-shadow-repair.ts +307 -0
  57. package/server/lib/mac-voice-assignment.ts +44 -26
  58. package/server/lib/mac-voice-display.ts +195 -0
  59. package/server/lib/mac-voice-naming-batch.ts +1067 -0
  60. package/server/lib/mac-voice-suggestions.ts +548 -0
  61. package/server/lib/maintenance-lifecycle.ts +2 -0
  62. package/server/lib/media-store.ts +2 -1
  63. package/server/lib/meeting-ask-pasted.ts +135 -0
  64. package/server/lib/meeting-ask-review.ts +8 -1
  65. package/server/lib/meeting-ask-runner.ts +4 -1
  66. package/server/lib/meeting-ask-runtime.ts +189 -17
  67. package/server/lib/meeting-ask-store.ts +2 -0
  68. package/server/lib/meeting-ask-web.ts +284 -13
  69. package/server/lib/meeting-batch-progress.ts +51 -5
  70. package/server/lib/meeting-batch-transcribe.ts +35 -2
  71. package/server/lib/meeting-fact-check.ts +3 -0
  72. package/server/lib/meeting-finalization-jobs.ts +69 -2
  73. package/server/lib/meeting-public-topic.ts +6 -1
  74. package/server/lib/meeting-speaker-review.ts +182 -17
  75. package/server/lib/meeting-store.ts +6 -1
  76. package/server/lib/parakeet.ts +51 -5
  77. package/server/lib/pending-batch-retention.ts +58 -0
  78. package/server/lib/private-reference.ts +38 -0
  79. package/server/lib/provider-usage.ts +561 -0
  80. package/server/lib/public-https-url.ts +32 -0
  81. package/server/lib/query-job-runtime.ts +3 -0
  82. package/server/lib/rich-media-safety.ts +23 -2
  83. package/server/lib/speaker-shadow-decide.ts +503 -0
  84. package/server/lib/speaker-shadow-models.ts +150 -0
  85. package/server/lib/speaker-shadow-numeric.ts +143 -0
  86. package/server/lib/speaker-shadow-pair-source.ts +91 -0
  87. package/server/lib/speaker-shadow-paired.ts +268 -0
  88. package/server/lib/speaker-shadow-report.ts +113 -0
  89. package/server/lib/speaker-shadow-timeline.ts +210 -0
  90. package/server/lib/speaker-shadow-worker.ts +312 -0
  91. package/server/lib/speaker-shadow.ts +788 -0
  92. package/server/lib/token-audit.ts +36 -6
  93. package/server/lib/transcript-cleanup.ts +3 -2
  94. package/server/lib/voice-directory.ts +8 -4
  95. package/server/lib/web-lookup-ledger.ts +151 -0
  96. package/server/lib/work-review-runtime.ts +2 -0
  97. package/server/lib/work-search.ts +3 -1
  98. package/server/lib/xlsx-text.ts +277 -0
  99. package/server/routes/health.ts +5 -0
  100. package/server/routes/live-cues.ts +36 -14
  101. package/server/routes/meeting.ts +506 -49
  102. package/server/routes/speaker-shadow.ts +79 -0
  103. package/server/routes/transcribe-stream.ts +114 -33
  104. package/server/routes/voice.ts +3 -0
  105. package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
  106. package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
  107. package/shared/live-cues-v2.ts +38 -5
  108. package/shared/media-attachment.ts +2 -0
  109. package/shared/meeting-ask.ts +11 -0
@@ -0,0 +1,453 @@
1
+ // Live cues data arm and meeting assistant data context: Tableau actuals against budget for the marketing-sourced funnel,
2
+ // read from ONE local file (`<data dir>/data-facts.json`, written by operations/scripts/tableau/cue_data_facts.py from the
3
+ // weekday Tableau report CSVs). Nothing here calls a model, the network or Tableau, and nothing writes.
4
+ //
5
+ // Why: a funnel number said in a meeting ("SBU closed at 120% of MRR budget") is checked against what Tableau shows for the
6
+ // same group. The two are often different measures, so a candidate always carries its scope and period, and the writer is told
7
+ // never to present it as revenue, MRR or location counts (DATA_WRITER_ADDENDUM).
8
+ //
9
+ // Rules, each on purpose:
10
+ // - the arm is OFF when the file is missing, unreadable, invalid or older than DATA_FACTS_LIMITS.maxAgeHours (asOf, the
11
+ // Tableau extract refresh, not the file's mtime);
12
+ // - it offers at most ONE candidate, and only when the last ~600 characters spoken name a group (or an alias of one) AND
13
+ // carry a funnel word or a figure of two digits or a percent;
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
+ // industry word, so "let's dive into the numbers" and "pet project" stay quiet. The bare word "quilt" is never an alias;
16
+ // - domain (dataArmGate): a Quilt meeting runs it. An unreadable orientation is off. Any other confirmed domain (personal,
17
+ // Sprocket Rocket, Hermit Crabs...) is off. A meeting with no confirmed domain (most G2 meetings) needs a Quilt signal from
18
+ // the same evaluation: a Quilt brand or SBU/XBU named, a Quilt-domain memory or note among the candidates, or a Quilt term in
19
+ // the meeting's title. A plain industry word alone (grocery, liquor, pet...) is not a signal.
20
+
21
+ import { existsSync, readFileSync, statSync } from 'node:fs'
22
+ import type { GroundedNote } from './live-cues-v2-grounded.js'
23
+
24
+ export const DATA_FACTS_FILE = 'data-facts.json'
25
+
26
+ export const DATA_FACTS_LIMITS = {
27
+ /** An open month: 120 hours (5 days) from `asOf`. The file is rewritten on weekdays from a 10:30 CT job; a long weekend leaves it
28
+ * 72 hours old on Monday morning and a missed run or a holiday adds a day or two. Past 5 days the numbers could be last week's. */
29
+ maxAgeHours: 120,
30
+ /** A closed month does not go stale the way a month to date does: its figures only move by late-closing deals. It is kept for 35
31
+ * days from `asOf` (a month plus five days of slack): by then the next month's close should have replaced it, and a writer that
32
+ * stopped is still caught. Without this the first Monday after a month start, when the report window is still under 3 days, went dark. */
33
+ maxClosedAgeDays: 35,
34
+ /** A clock that reads this far behind the file's own time is wrong or the file is: off. */
35
+ maxFutureMs: 60 * 60_000,
36
+ maxBytes: 2_000_000,
37
+ maxGroups: 60,
38
+ maxSources: 400,
39
+ /** The speech the trigger reads: the last this many characters of the window (about 100 words). */
40
+ recentChars: 600,
41
+ /** A candidate is at most this long, like a file passage. */
42
+ candidateChars: 300,
43
+ maxNamedGroups: 2,
44
+ /** What the meeting assistant is given: at most this many characters of Tableau text. */
45
+ askChars: 3_500,
46
+ askSourceRowsPerIndustry: 6,
47
+ /** A typed question longer than this is probably pasted text (see askUserWords). */
48
+ askQuestionChars: 300,
49
+ } as const
50
+
51
+ export const DATA_STAGES = ['leads', 'opps', 'sqls', 'demos', 'customers'] as const
52
+ export type DataStageKey = typeof DATA_STAGES[number]
53
+
54
+ export interface DataStage {
55
+ actual: number
56
+ budget: number | null
57
+ budgetMonth: number | null
58
+ pctToGoal: number | null
59
+ priorMonthSamePeriod: number | null
60
+ pySamePeriod: number | null
61
+ exEventsPct?: number
62
+ coreActual?: number
63
+ }
64
+ export interface DataGroup {
65
+ name: string
66
+ /** How text names the group where the plain name would be mistaken for the company ("Quilt industry (Like Sew)"). */
67
+ display?: string
68
+ kind: 'bu' | 'industry'
69
+ aliases: string[]
70
+ weakAliases: string[]
71
+ /** The aliases that are Quilt brand names (and sbu/xbu): naming one is a Quilt signal. A plain industry word is not. */
72
+ brandAliases: string[]
73
+ members: string[]
74
+ coverage?: { have: number; of: number }
75
+ stages: Partial<Record<DataStageKey, DataStage>>
76
+ }
77
+ export interface DataSourceRow { industry: string; source: string; bucket: string; leads: number; opps: number; customers: number }
78
+ export interface DataFacts {
79
+ label: string
80
+ scope: string
81
+ period: { label: string; start: string; end: string; closed: boolean }
82
+ /** The Tableau extract refresh (or the report time), as given, and in ms. */
83
+ asOfIso: string
84
+ asOf: number
85
+ completeThrough?: string
86
+ groups: DataGroup[]
87
+ sources: DataSourceRow[]
88
+ }
89
+
90
+ export type DataFactsOff = 'missing' | 'unreadable' | 'invalid' | 'stale' | 'future'
91
+ export type DataFactsRead = { ok: true; facts: DataFacts } | { ok: false; reason: DataFactsOff }
92
+
93
+ const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
94
+ const str = (v: unknown, max: number): string | null => (typeof v === 'string' && v.trim() !== '' && v.length <= max ? v : null)
95
+ const fin = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null)
96
+ const wordList = (v: unknown, maxItems: number): string[] | null => {
97
+ if (v === undefined) return []
98
+ if (!Array.isArray(v) || v.length > maxItems) return null
99
+ const out: string[] = []
100
+ for (const item of v) {
101
+ const s = str(item, 40)
102
+ if (s === null || s.trim().length < 2) return null
103
+ out.push(s.trim().toLowerCase())
104
+ }
105
+ return out
106
+ }
107
+
108
+ function parseStage(raw: unknown): DataStage | null {
109
+ if (!isObj(raw)) return null
110
+ const actual = fin(raw.actual)
111
+ if (actual === null || actual < 0) return null
112
+ const opt = (v: unknown) => fin(v)
113
+ const stage: DataStage = { actual, budget: opt(raw.budget), budgetMonth: opt(raw.budgetMonth), pctToGoal: opt(raw.pctToGoal),
114
+ priorMonthSamePeriod: opt(raw.priorMonthSamePeriod), pySamePeriod: opt(raw.pySamePeriod) }
115
+ const ex = fin(raw.exEventsPct), core = fin(raw.coreActual)
116
+ if (ex !== null) stage.exEventsPct = ex
117
+ if (core !== null) stage.coreActual = core
118
+ return stage
119
+ }
120
+
121
+ function parseGroup(raw: unknown): DataGroup | null {
122
+ if (!isObj(raw)) return null
123
+ const name = str(raw.name, 40)
124
+ const aliases = wordList(raw.aliases, 40), weak = wordList(raw.weakAliases, 10), brand = wordList(raw.brandAliases, 20)
125
+ if (name === null || aliases === null || weak === null || brand === null || !isObj(raw.stages)) return null
126
+ const stages: DataGroup['stages'] = {}
127
+ for (const key of DATA_STAGES) {
128
+ const s = parseStage((raw.stages as Record<string, unknown>)[key])
129
+ if (s) stages[key] = s
130
+ }
131
+ if (!Object.keys(stages).length || (!aliases.length && !weak.length)) return null
132
+ const members = Array.isArray(raw.members) ? raw.members.map(m => str(m, 40)).filter((m): m is string => m !== null).slice(0, 20) : []
133
+ const cov = isObj(raw.coverage) && fin(raw.coverage.have) !== null && fin(raw.coverage.of) !== null && (raw.coverage.of as number) > 0
134
+ ? { have: raw.coverage.have as number, of: raw.coverage.of as number } : undefined
135
+ const display = str(raw.display, 60)
136
+ return { name, ...(display ? { display } : {}), kind: raw.kind === 'bu' ? 'bu' : 'industry', aliases, weakAliases: weak, brandAliases: brand, members, ...(cov ? { coverage: cov } : {}), stages }
137
+ }
138
+
139
+ /** The facts from a parsed file, or why not. A group that does not parse is dropped (never guessed at); a file with no usable group is
140
+ * invalid. The clock is passed in, so staleness is testable. */
141
+ export function parseDataFacts(raw: unknown, nowMs: number): DataFactsRead {
142
+ if (!isObj(raw) || raw.v !== 1 || raw.source !== 'Tableau') return { ok: false, reason: 'invalid' }
143
+ const label = str(raw.label, 120), scope = str(raw.scope, 160)
144
+ const period = isObj(raw.period) ? raw.period : null
145
+ const pLabel = str(period?.label, 40), pStart = str(period?.start, 10), pEnd = str(period?.end, 10)
146
+ const asOfIso = str(raw.asOf, 40)
147
+ const asOf = asOfIso === null ? NaN : Date.parse(asOfIso)
148
+ if (!label || !scope || !period || !pLabel || !pStart || !pEnd || typeof period.closed !== 'boolean' || Number.isNaN(asOf)) return { ok: false, reason: 'invalid' }
149
+ if (!Array.isArray(raw.groups) || raw.groups.length > DATA_FACTS_LIMITS.maxGroups) return { ok: false, reason: 'invalid' }
150
+ const groups = raw.groups.map(parseGroup).filter((g): g is DataGroup => g !== null)
151
+ if (!groups.length) return { ok: false, reason: 'invalid' }
152
+ const sources: DataSourceRow[] = []
153
+ for (const r of Array.isArray(raw.sources) ? raw.sources.slice(0, DATA_FACTS_LIMITS.maxSources) : []) {
154
+ if (!isObj(r)) continue
155
+ const industry = str(r.industry, 40), source = str(r.source, 80)
156
+ 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 })
158
+ }
159
+ if (asOf - nowMs > DATA_FACTS_LIMITS.maxFutureMs) return { ok: false, reason: 'future' }
160
+ const maxAgeMs = period.closed === true ? DATA_FACTS_LIMITS.maxClosedAgeDays * 24 * 3_600_000 : DATA_FACTS_LIMITS.maxAgeHours * 3_600_000
161
+ if (nowMs - asOf > maxAgeMs) return { ok: false, reason: 'stale' }
162
+ const through = typeof raw.completeThrough === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(raw.completeThrough) ? raw.completeThrough : undefined
163
+ 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 } }
165
+ }
166
+
167
+ /** Reads and parses the file. Missing, too big, unreadable or not JSON are three different reasons; never throws. */
168
+ export function readDataFactsFile(path: string, nowMs: number): DataFactsRead {
169
+ try {
170
+ if (!existsSync(path)) return { ok: false, reason: 'missing' }
171
+ const st = statSync(path)
172
+ if (!st.isFile() || st.size > DATA_FACTS_LIMITS.maxBytes) return { ok: false, reason: 'invalid' }
173
+ return parseDataFacts(JSON.parse(readFileSync(path, 'utf8')), nowMs)
174
+ } catch (e) {
175
+ return { ok: false, reason: e instanceof SyntaxError ? 'invalid' : 'unreadable' }
176
+ }
177
+ }
178
+
179
+ /** A reader that parses the file again only when its size or modified time changed, and checks staleness on every call. A cue
180
+ * evaluation runs up to three times a minute; this is one stat each. */
181
+ export function dataFactsReader(path: string, now: () => number = Date.now): () => DataFactsRead {
182
+ let sig = '', cached: { raw: unknown } | { reason: DataFactsOff } | null = null
183
+ return () => {
184
+ const nowMs = now()
185
+ try {
186
+ if (!existsSync(path)) { sig = ''; cached = null; return { ok: false, reason: 'missing' } }
187
+ const st = statSync(path)
188
+ const next = `${st.size}:${st.mtimeMs}`
189
+ if (next !== sig || !cached) {
190
+ sig = next
191
+ if (!st.isFile() || st.size > DATA_FACTS_LIMITS.maxBytes) cached = { reason: 'invalid' }
192
+ else { try { cached = { raw: JSON.parse(readFileSync(path, 'utf8')) } } catch (e) { cached = { reason: e instanceof SyntaxError ? 'invalid' : 'unreadable' } } }
193
+ }
194
+ } catch { sig = ''; cached = null; return { ok: false, reason: 'unreadable' } }
195
+ return 'reason' in cached ? { ok: false, reason: cached.reason } : parseDataFacts(cached.raw, nowMs)
196
+ }
197
+ }
198
+
199
+ // ── The trigger ──────────────────────────────────────────────────────────────
200
+
201
+ const FUNNEL_TOKENS = new Set(['lead', 'leads', 'opp', 'opps', 'opportunity', 'opportunities', 'sql', 'sqls', 'demo', 'demos', 'logo', 'logos',
202
+ 'customer', 'customers', 'budget', 'budgets', 'budgeted', 'pacing', 'pace', 'percent', 'plan', 'goal', 'goals', 'forecast', 'attainment', 'quota'])
203
+ const STAGE_OF: Record<string, DataStageKey> = { lead: 'leads', leads: 'leads', opp: 'opps', opps: 'opps', opportunity: 'opps', opportunities: 'opps',
204
+ sql: 'sqls', sqls: 'sqls', demo: 'demos', demos: 'demos', logo: 'customers', logos: 'customers', customer: 'customers', customers: 'customers' }
205
+ /** Words that, beside a weak alias, say the industry is meant: "pet vertical", "dive shops". */
206
+ const INDUSTRY_TOKENS = new Set(['industry', 'vertical', 'segment', 'stores', 'shops', 'retailers'])
207
+ /** A word between a weak alias and a funnel word: "leads for pet", "customers in dive". */
208
+ const BRIDGE_TOKENS = new Set(['for', 'in', 'on', 'of', 'the', 'our', 'with', 'by'])
209
+ /** A figure of two digits or more standing alone (not inside a word like said10 or a time like 10:30, and not a year), or a percent. */
210
+ function hasFigure(speech: string): boolean {
211
+ if (/%|\bpercent\b/i.test(speech)) return true
212
+ for (const m of speech.matchAll(/(?<![A-Za-z0-9:.,])(\d[\d,]*)(?:\.\d+)?(?![A-Za-z0-9:])/g)) {
213
+ const digits = m[1].replace(/,/g, '')
214
+ if (digits.length >= 2 && !/^(?:19|20)\d{2}$/.test(digits)) return true
215
+ }
216
+ return false
217
+ }
218
+
219
+ export const wordsOfSpeech = (text: string): string[] => text.toLowerCase().match(/[a-z0-9]+/g) ?? []
220
+
221
+ /** The speech the trigger reads: the last recentChars of the window, cut at a word. */
222
+ export function recentSpeech(window: string, chars: number = DATA_FACTS_LIMITS.recentChars): string {
223
+ if (window.length <= chars) return window
224
+ const cut = window.slice(-chars)
225
+ const space = cut.indexOf(' ')
226
+ return space > 0 && space < 24 ? cut.slice(space + 1) : cut
227
+ }
228
+
229
+ function occurrences(tokens: string[], phrase: string[]): number[] {
230
+ const at: number[] = []
231
+ if (!phrase.length) return at
232
+ for (let i = 0; i + phrase.length <= tokens.length; i += 1) {
233
+ let all = true
234
+ for (let j = 0; j < phrase.length; j += 1) if (tokens[i + j] !== phrase[j]) { all = false; break }
235
+ if (all) at.push(i)
236
+ }
237
+ return at
238
+ }
239
+
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)))
245
+ }
246
+
247
+ 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
+ /** The groups the speech names, the one named last first. A group named twice counts at its last mention. */
249
+ export function namedGroups(facts: DataFacts, speech: string): NamedGroup[] {
250
+ const tokens = wordsOfSpeech(speech)
251
+ const out: NamedGroup[] = []
252
+ for (const group of facts.groups) {
253
+ let last = -1, brand = false
254
+ for (const alias of group.aliases) for (const i of occurrences(tokens, wordsOfSpeech(alias))) last = Math.max(last, i)
255
+ 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)
257
+ if (last >= 0) out.push({ group, at: last, brand })
258
+ }
259
+ return out.sort((a, b) => b.at - a.at)
260
+ }
261
+
262
+ export type DataTrigger = { fired: true; groups: DataGroup[]; stages: DataStageKey[]; brand: boolean } | { fired: false; why: 'no_group' | 'no_funnel' }
263
+
264
+ /** Fires when the speech names a group AND carries a funnel word or a figure (two digits or more, or a percent). */
265
+ export function dataTrigger(facts: DataFacts, window: string): DataTrigger {
266
+ const speech = recentSpeech(window)
267
+ const named = namedGroups(facts, speech)
268
+ if (!named.length) return { fired: false, why: 'no_group' }
269
+ const tokens = wordsOfSpeech(speech)
270
+ if (!tokens.some(t => FUNNEL_TOKENS.has(t)) && !hasFigure(speech)) return { fired: false, why: 'no_funnel' }
271
+ const stages = [...new Set(tokens.map(t => STAGE_OF[t]).filter((s): s is DataStageKey => !!s))]
272
+ 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) }
274
+ }
275
+
276
+ export interface DataGateInput {
277
+ /** The confirmed meeting domain (Ask orientation), or null/empty when none is confirmed. */
278
+ domain: string | null | undefined
279
+ /** The orientation could not be read: it may belong to a personal meeting, so the arm is off. */
280
+ orientationFailed?: boolean
281
+ /** The speech names a Quilt brand or sbu/xbu (not only an industry word). */
282
+ brand: boolean
283
+ /** A Quilt-domain cue_memory or notes candidate survived the exclusions in this evaluation. */
284
+ memoryQuilt?: boolean
285
+ /** The meeting's title carries a Quilt term. */
286
+ titleQuilt?: boolean
287
+ }
288
+ export type DataGate = { ok: true; signal: 'quilt' | 'brand' | 'memory' | 'title' } | { ok: false; why: 'orientation' | 'domain' | 'unconfirmed' }
289
+
290
+ /** Whether this meeting may be given Quilt's numbers. A Quilt meeting: yes. An unreadable orientation: no. Another confirmed domain: no.
291
+ * No confirmed domain: only with a Quilt signal from this evaluation, never from an industry word alone. */
292
+ export function dataArmGate(i: DataGateInput): DataGate {
293
+ if (i.orientationFailed) return { ok: false, why: 'orientation' }
294
+ const d = typeof i.domain === 'string' ? i.domain.trim().toLowerCase() : ''
295
+ if (d === 'quilt') return { ok: true, signal: 'quilt' }
296
+ if (d !== '') return { ok: false, why: 'domain' }
297
+ if (i.brand) return { ok: true, signal: 'brand' }
298
+ if (i.memoryQuilt) return { ok: true, signal: 'memory' }
299
+ if (i.titleQuilt) return { ok: true, signal: 'title' }
300
+ return { ok: false, why: 'unconfirmed' }
301
+ }
302
+
303
+ /** Does a Quilt-domain note (cue_memory.py or the notes arm says the domain) stand among the candidates. */
304
+ export const isQuiltDomain = (domain: unknown): boolean => typeof domain === 'string' && domain.trim().toLowerCase() === 'quilt'
305
+
306
+ /** A Quilt term in a meeting title: the company, SBU or XBU, Tableau, or the name of a Quilt brand in the facts. */
307
+ export function quiltTitleSignal(facts: DataFacts, titles: ReadonlyArray<string | null | undefined>): boolean {
308
+ const brands = facts.groups.flatMap(g => g.brandAliases)
309
+ for (const title of titles) {
310
+ if (typeof title !== 'string' || !title.trim()) continue
311
+ const tokens = wordsOfSpeech(title)
312
+ if (tokens.some(t => t === 'quilt' || t === 'sbu' || t === 'xbu' || t === 'tableau')) return true
313
+ if (brands.some(b => occurrences(tokens, wordsOfSpeech(b)).length > 0)) return true
314
+ }
315
+ return false
316
+ }
317
+
318
+ // ── The candidate ────────────────────────────────────────────────────────────
319
+
320
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
321
+ 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)}%)`)
323
+
324
+ /** "Nov 2 05:41 CT": the refresh as the file states it, with CT only for a Central offset. */
325
+ export function refreshText(iso: string): string {
326
+ const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/.exec(iso)
327
+ if (!m) return iso
328
+ const zone = m[6] === '-06:00' || m[6] === '-0600' || m[6] === '-05:00' || m[6] === '-0500' ? ' CT' : m[6] ? ` ${m[6]}` : ''
329
+ return `${MONTHS[Number(m[2]) - 1] ?? m[2]} ${Number(m[3])} ${m[4]}:${m[5]}${zone}`
330
+ }
331
+
332
+ function periodText(f: DataFacts): string {
333
+ if (f.period.closed) return `${f.period.label} final`
334
+ const end = /^(\d{4})-(\d{2})-(\d{2})$/.exec(f.period.end)
335
+ return end ? `${f.period.label} to ${MONTHS[Number(end[2]) - 1]} ${Number(end[3])}, month to date` : `${f.period.label}, month to date`
336
+ }
337
+
338
+ function groupLine(g: DataGroup, stages: DataStageKey[], withContext: boolean): string {
339
+ const cov = g.coverage && g.coverage.have < g.coverage.of ? ` (${g.coverage.have} of ${g.coverage.of} industries)` : ''
340
+ const parts = stages.filter(k => g.stages[k]).map(k => {
341
+ const s = g.stages[k] as DataStage
342
+ const vs = s.budget === null ? ' (no budget)' : ` vs ${fmt(s.budget)}${pctText(s)}`
343
+ return `${k} ${fmt(s.actual)}${vs}`
344
+ })
345
+ let line = `${g.display ?? g.name}${cov}: ${parts.join(', ')}`
346
+ if (withContext && stages.length === 1) {
347
+ const s = g.stages[stages[0]]
348
+ if (s?.priorMonthSamePeriod != null) line += `, prior month same period ${fmt(s.priorMonthSamePeriod)}`
349
+ if (s?.pySamePeriod != null) line += `, last year ${fmt(s.pySamePeriod)}`
350
+ }
351
+ return line
352
+ }
353
+
354
+ /** The stages a candidate shows: the ones the speech named, else leads, opps and customers. */
355
+ function shownStages(g: DataGroup, named: DataStageKey[]): DataStageKey[] {
356
+ const wanted = (named.length ? named : (['leads', 'opps', 'customers'] as DataStageKey[])).filter(k => g.stages[k])
357
+ return wanted.length ? wanted : DATA_STAGES.filter(k => g.stages[k]).slice(0, 3)
358
+ }
359
+
360
+ /** The candidate text: period, scope and refresh first, then actual against budget to date and % to goal for the named group(s).
361
+ * At most candidateChars: a second group, then stages, are dropped before it is cut. Empty only when no named group has a stage. */
362
+ export function dataCandidateText(facts: DataFacts, groups: DataGroup[], named: DataStageKey[]): string {
363
+ const head = `Tableau ${periodText(facts)}, marketing-sourced funnel only, actual vs budget to date, refreshed ${refreshText(facts.asOfIso)}.`
364
+ const max = DATA_FACTS_LIMITS.candidateChars
365
+ const build = (gs: DataGroup[], cut: number): string => {
366
+ const lines = gs.map(g => groupLine(g, shownStages(g, named).slice(0, Math.max(1, shownStages(g, named).length - cut)), true)).filter(l => !l.endsWith(': '))
367
+ return lines.length ? `${head} ${lines.join('; ')}.` : ''
368
+ }
369
+ for (const gs of [groups, groups.slice(0, 1)]) {
370
+ for (let cut = 0; cut < 3; cut += 1) {
371
+ const text = build(gs, cut)
372
+ if (text && text.length <= max) return text
373
+ }
374
+ }
375
+ return build(groups.slice(0, 1), 2).slice(0, max)
376
+ }
377
+
378
+ /** 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' } {
380
+ const t = dataTrigger(facts, window)
381
+ if (!t.fired) return { none: t.why }
382
+ const notes = dataCandidateText(facts, t.groups, t.stages)
383
+ if (!notes) return { none: 'no_stage' }
384
+ 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 }
386
+ }
387
+
388
+ /** Appended to the writer's rules only when a Tableau note was kept. The v3/v4 rules themselves stay byte-identical. */
389
+ export const DATA_WRITER_ADDENDUM = '\nTableau note. A memory note whose title starts with Tableau is a live extract of the marketing-sourced funnel '
390
+ + '(leads, opportunities, SQLs, demos, customers) for the period in its title. It is not company revenue, MRR, ARR, location counts or customers from every source. '
391
+ + 'If you use it, say Tableau, the period, and marketing sources. Quote its numbers exactly; do not sum, project or round them. '
392
+ + 'Set a figure the room said beside the Tableau figure only when both measure the same thing (same stage, same group). '
393
+ + '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. '
394
+ + 'If the note does not cover what the room said, ignore it.'
395
+
396
+ // ── The meeting assistant ────────────────────────────────────────────────────
397
+
398
+ const ASK_TABLEAU = /\btableau\b/i
399
+ const ASK_FUNNEL = /\b(?:funnel|leads?|opps?|opportunit(?:y|ies)|sqls?|demos?|logos?|customers?|budget(?:s|ed)?|pac(?:e|ing)|forecast|attainment|goals?|quota|percent|plan)\b|%/i
400
+
401
+ /** The words of a typed question that are the user's own. A long text (a pasted article, a transcript) carries other people's words:
402
+ * past askQuestionChars only its last line counts, and only when that line is short; a long text with no short last line counts
403
+ * for nothing. */
404
+ export function askUserWords(question: string): string {
405
+ if (question.length <= DATA_FACTS_LIMITS.askQuestionChars) return question
406
+ const last = question.trimEnd().split(/\n+/).pop() ?? ''
407
+ return last.length <= DATA_FACTS_LIMITS.askQuestionChars && last.length < question.trim().length ? last : ''
408
+ }
409
+
410
+ export interface AskDataIntent { intent: 'tableau' | 'named' | null; /** A Quilt brand or sbu/xbu is named. */ brand: boolean }
411
+ /** 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
413
+ * 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
+ export function askDataIntent(question: string, facts: DataFacts): AskDataIntent {
415
+ const own = askUserWords(question)
416
+ const named = namedGroups(facts, own)
417
+ const brand = named.some(n => n.brand)
418
+ if (ASK_TABLEAU.test(own)) return { intent: 'tableau', brand }
419
+ return { intent: ASK_FUNNEL.test(own) && named.length > 0 ? 'named' : null, brand }
420
+ }
421
+
422
+ /** 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. */
424
+ 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
427
+ const lines: string[] = [
428
+ `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
+ `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).`,
430
+ ]
431
+ for (const g of chosen) {
432
+ const cov = g.coverage && g.coverage.have < g.coverage.of ? ` (${g.coverage.have} of ${g.coverage.of} industries: ${g.members.join(', ')})` : ''
433
+ const stages = DATA_STAGES.filter(k => g.stages[k]).map(k => {
434
+ const s = g.stages[k] as DataStage
435
+ const extra = [s.priorMonthSamePeriod != null ? `PM ${fmt(s.priorMonthSamePeriod)}` : '', s.pySamePeriod != null ? `PY ${fmt(s.pySamePeriod)}` : '',
436
+ s.exEventsPct != null ? `ex-events ${Math.round(s.exEventsPct)}%` : ''].filter(Boolean).join(', ')
437
+ return `${k} ${fmt(s.actual)} vs ${s.budget === null ? 'no budget' : fmt(s.budget)}${pctText(s)}${extra ? ` [${extra}]` : ''}`
438
+ })
439
+ lines.push(`${g.display ?? g.name}${cov}: ${stages.join('; ')}`)
440
+ }
441
+ if (named.length) {
442
+ for (const g of named) {
443
+ 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
+ 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
+ }
446
+ }
447
+ const text = lines.join('\n').slice(0, DATA_FACTS_LIMITS.askChars)
448
+ return chosen.length ? { title: facts.label, text } : null
449
+ }
450
+
451
+ /** Added to the meeting assistant's rules only when its Tableau source is in the context. */
452
+ 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.'