@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
@@ -1,4 +1,4 @@
1
- import { readRetirementRules } from './meeting-official-rules.js';
1
+ import { readRetirementRules, RETIREMENT_AUTHORITIES } from './meeting-official-rules.js';
2
2
  /** One admitted public WebSearch per hop. Hook is an enforcement boundary, not an after-the-fact count. */
3
3
  import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
4
4
  import { join } from 'node:path';
@@ -12,6 +12,41 @@ import { resolveProviderBinary } from './provider-binary.js';
12
12
  import { pageWasRead, READ_PAGE_PREFIX } from './meeting-ask-review.js';
13
13
  import type { MeetingAskExchange, MeetingAskSource } from '../../shared/meeting-ask.js';
14
14
  import { fetchPublicArticle } from './meeting-web-page.js';
15
+ import { webLedger, type WebFeature } from './web-lookup-ledger.js';
16
+ import { privateReference } from './private-reference.js';
17
+ /** The eight 16-bit groups of an IPv6 address `isIP` already accepted ("::" expanded, a trailing dotted IPv4 turned into two groups). */
18
+ function ipv6Groups(h: string): number[] {
19
+ let text = h.toLowerCase().split('%')[0];
20
+ const dotted = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(text);
21
+ if (dotted)
22
+ text = text.slice(0, dotted.index) + ((Number(dotted[1]) << 8) | Number(dotted[2])).toString(16) + ':' + ((Number(dotted[3]) << 8) | Number(dotted[4])).toString(16);
23
+ const [head, tail] = text.split('::');
24
+ const left = head ? head.split(':') : [], right = tail === undefined ? [] : tail ? tail.split(':') : [];
25
+ const fill = tail === undefined ? [] : Array(8 - left.length - right.length).fill('0');
26
+ return [...left, ...fill, ...right].map(g => parseInt(g, 16));
27
+ }
28
+ /**
29
+ * Public IPv6 only. Refused: the unspecified and loopback addresses and every address whose first 96 bits are zero (this covers the
30
+ * deprecated IPv4-compatible form ::a.b.c.d), IPv4-mapped ::ffff:0:0/96, IPv4-translated ::ffff:0:0:0/96, NAT64 64:ff9b::/32 (a gateway forwards it to the embedded IPv4,
31
+ * private ones included), unique-local fc00::/7, link-local fe80::/10, site-local fec0::/10, multicast ff00::/8, 6to4 2002::/16 and
32
+ * Teredo 2001::/32 (both embed an IPv4 address), and the documentation range 2001:db8::/32.
33
+ */
34
+ function publicIPv6(h: string): boolean {
35
+ const g = ipv6Groups(h);
36
+ if (g.length !== 8 || g.some(x => !Number.isInteger(x)))
37
+ return false;
38
+ if (g.slice(0, 6).every(x => x === 0) || g.slice(0, 5).every(x => x === 0) && g[5] === 0xffff)
39
+ return false;
40
+ if (g.slice(0, 4).every(x => x === 0) && g[4] === 0xffff && g[5] === 0)
41
+ return false; // IPv4-translated ::ffff:0:a.b.c.d (RFC 2765)
42
+ if (g[0] === 0x64 && g[1] === 0xff9b)
43
+ return false;
44
+ if ((g[0] & 0xfe00) === 0xfc00 || (g[0] & 0xffc0) === 0xfe80 || (g[0] & 0xffc0) === 0xfec0 || (g[0] & 0xff00) === 0xff00)
45
+ return false;
46
+ if (g[0] === 0x2002 || g[0] === 0x2001 && (g[1] === 0 || g[1] === 0xdb8))
47
+ return false;
48
+ return true;
49
+ }
15
50
  export function publicAddress(host: string): boolean {
16
51
  const h = host.toLowerCase().replace(/^\[|\]$/g, '');
17
52
  if (!h || h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal'))
@@ -21,7 +56,7 @@ export function publicAddress(host: string): boolean {
21
56
  return !(p[0] === 0 || p[0] === 10 || p[0] === 127 || p[0] >= 224 || p[0] === 169 && p[1] === 254 || p[0] === 172 && p[1] >= 16 && p[1] <= 31 || p[0] === 192 && p[1] === 168 || p[0] === 100 && p[1] >= 64 && p[1] <= 127);
22
57
  }
23
58
  if (isIP(h) === 6)
24
- return !(h === '::' || h === '::1' || h.startsWith('fc') || h.startsWith('fd') || h.startsWith('fe80') || h.startsWith('::ffff:'));
59
+ return publicIPv6(h);
25
60
  return h.includes('.');
26
61
  }
27
62
  export function publicWebUrl(value: unknown): string | null { try {
@@ -78,8 +113,9 @@ export interface WebGateInput {
78
113
  /** The user's own earlier questions in this meeting, oldest first. Never answers, transcript or files. */
79
114
  priorQuestions?: readonly string[];
80
115
  }
81
- const PRIVATE_COST = /\b(?:private|internal|employee)\b|\b(?:our|my|client|customer)\b.*\b(?:budget|contract|salary|quote)\b/i;
82
- const SENSITIVE = /\b(?:birthday|birthdate|address|phone|email|salary|medical|diagnosis|password|secret|confidential|wife|husband|daughter|son|family)\b/i;
116
+ const PRIVATE_COST_WORDS = /\b(?:internal|employee)\b|\b(?:our|my|client|customer)\b.*\b(?:budget|contract|salary|quote)\b/i;
117
+ const PRIVATE_COST = { test: (text: string) => PRIVATE_COST_WORDS.test(text) || privateReference(text) };
118
+ const SENSITIVE = /\b(?:birthday|birthdate|address|phone|email|salary|medical|diagnosis|password|secret|confidential|wife|husband|daughter|son|family|hospital|surgery|clinic|therapy|pregnant|pregnancy|fever)\b/i;
83
119
  /** normalizeAsk redacts before the journal write, so a stored question shows a secret only as the mark it left. */
84
120
  const REDACTION_MARK = /REDACTED|\[private key\]/;
85
121
  /** A subjectless follow-up may borrow words from at most this many of the user's latest earlier questions. */
@@ -148,7 +184,14 @@ export function publicIntent(question: string): boolean {
148
184
  if (w.some(x => INTENT_WORDS.has(x)))
149
185
  return true;
150
186
  const s = w.join(' ');
151
- if (factCheckRequest(question)) return true;
187
+ // "Is that true about our pipeline?" asks about the user's own thing, not a public claim; the other fact-check wording is unchanged.
188
+ const ownThing = /\b(?:my|our|his|her|their|your|we|i)\b/.test(s) && /\bis (?:this|that) (?:true|accurate)\b/.test(s) && !/\bfact check(?:ing)?\b|\bverify\b|\bcompar|\b(?:average|typical|benchmark)\b/.test(s);
189
+ if (factCheckRequest(question) && !ownThing) return true;
190
+ // 6.97.8: public safety and statistics questions. "is it true (that)" counts unless it asks about what someone said or about the user's own things.
191
+ if (/\bis it true\b/.test(s) && !/\b(?:said|says|told|mentioned|claimed|promised|asked|my|our|his|her|their|your|we|i)\b/.test(s))
192
+ return true;
193
+ if (/\bsafer than\b|\b(?:more|less) dangerous than\b|\b(?:crash|accident|fatality) rates?\b|\bstatistics?\b|\bhow common\b|\bhow likely\b/.test(s))
194
+ return true;
152
195
  return /\bknown for\b/.test(s) || /\blook (?:\S+ ){0,2}up\b/.test(s);
153
196
  }
154
197
  /**
@@ -172,7 +215,7 @@ export function factCheckRequest(question: string): boolean {
172
215
  /** Public resource requests should not require the user to say "web".
173
216
  * Private/internal application and file lookups retain their COS routing. */
174
217
  export function publicResourceRequest(question: string): boolean {
175
- return !/\b(?:private|internal|confidential|file|document|our app|our application|client application)\b/i.test(question)
218
+ return !/\b(?:internal|confidential|file|document|our app|our application|client application)\b/i.test(question) && !privateReference(question)
176
219
  && /\b(?:find|give|send|show|locate|get|look up)\b.{0,100}\b(?:application|apply|signup|sign up|official link|link|documentation|docs)\b/i.test(question);
177
220
  }
178
221
  export function explicitWebRequest(question: string): boolean {
@@ -328,14 +371,31 @@ export function parseWebEvidence(raw: unknown, query: string): MeetingAskSource[
328
371
  return sources;
329
372
  }
330
373
  export function meetingWebCapability() { return resolveProviderBinary('claude').ok ? { available: true, engine: 'claude:haiku' } : { available: false, reason: 'claude_search_provider_unavailable' }; }
331
- export async function searchMeetingWeb(query: string, signal?: AbortSignal): Promise<{
374
+ /** `options.auditCaller`: the token-audit caller label for the search model's row, e.g. live_cues_general. Omitted, the row
375
+ * keeps its meeting_ask_web label and every older caller behaves exactly as before. */
376
+ export async function searchMeetingWeb(query: string, signal?: AbortSignal, options?: {
377
+ auditCaller?: string;
378
+ /** Result pages to keep (default 2; the page reader tries two more candidates than it keeps). A comparison question gives its three searches 2, 1 and 1: four pages in all. */
379
+ maxPages?: number;
380
+ /** Which feature to count the lookup under in the daily web ledger. Omitted: the general cues' audit caller counts as cues, anything else as the meeting assistant. */
381
+ feature?: WebFeature;
382
+ }): Promise<{
332
383
  sources: MeetingAskSource[];
333
384
  notices: string[];
334
385
  }> {
335
386
  if (query !== redactSecrets(query))
336
387
  return { sources: [], notices: ['Public search was withheld because the query contained a secret.'] };
337
388
  if (/\b(?:application|apply|signup|documentation|docs)\b/i.test(query) && !/\bofficial\b/i.test(query)) query += ' official';
338
- if(query.toLowerCase().replace(/[^a-z0-9]/g,'') === '401k403bleavingemployerkeepmoneyinoldretirementplanirsdepartmentoflabor') return readRetirementRules(publicAddress,signal);
389
+ const feature: WebFeature = options?.feature ?? (options?.auditCaller === 'live_cues_general' ? 'cues' : 'ask');
390
+ if(query.toLowerCase().replace(/[^a-z0-9]/g,'') === '401k403bleavingemployerkeepmoneyinoldretirementplanirsdepartmentoflabor') {
391
+ // Not a search, but it fetches the two official pages, so those fetches are counted.
392
+ const rules = await readRetirementRules(publicAddress,signal);
393
+ webLedger().pagesRead(feature, rules.sources.length);
394
+ webLedger().pagesFailed(feature, RETIREMENT_AUTHORITIES.length - rules.sources.length);
395
+ return rules;
396
+ }
397
+ // Counted where the search is dispatched, whatever it returns: a search that ran and found nothing still ran.
398
+ webLedger().search(feature);
339
399
  const root = mkdtempSync(join(tmpdir(), 'cos-ask-web-'));
340
400
  writeFileSync(join(root,'query.txt'),query.slice(0,240),{mode:0o600});
341
401
  const hook = join(root, 'hook.cjs');
@@ -348,7 +408,7 @@ export async function searchMeetingWeb(query: string, signal?: AbortSignal): Pro
348
408
  const early = new AbortController();
349
409
  const poll = setInterval(() => { try { JSON.parse(readFileSync(join(root, 'result.json'), 'utf8')); early.abort(); } catch { /* not written yet */ } }, 100);
350
410
  try {
351
- await runMeetingAskModel({ engine: 'claude', model: 'haiku' }, '', '', signal ? AbortSignal.any([signal, early.signal]) : early.signal, { workspace: root, args: webSearchArgs(hook), stdin: JSON.stringify({ query: query.slice(0, 240), blockedDomains: UNREADABLE_RESULT_HOSTS }) });
411
+ await runMeetingAskModel({ engine: 'claude', model: 'haiku' }, '', '', signal ? AbortSignal.any([signal, early.signal]) : early.signal, { workspace: root, args: webSearchArgs(hook), stdin: JSON.stringify({ query: query.slice(0, 240), blockedDomains: UNREADABLE_RESULT_HOSTS }) }, options?.auditCaller ? { caller: options.auditCaller } : undefined);
352
412
  } catch (e) {
353
413
  if (!early.signal.aborted || signal?.aborted || (e instanceof AskProcessError && e.terminationUnconfirmed)) throw e;
354
414
  } finally { clearInterval(poll); }
@@ -375,21 +435,23 @@ export async function searchMeetingWeb(query: string, signal?: AbortSignal): Pro
375
435
  // (same per-page bounds and DNS pinning) and the first two that read, in priority order, are kept,
376
436
  // so one blocked or oversize page no longer leaves a check without a readable source. An
377
437
  // unavailable page keeps its honest search-result description, never a fabricated excerpt.
378
- const candidates = prioritizeWebPages(sources, query).slice(0, 4);
438
+ const keepPages = Math.max(1, Math.min(4, Math.trunc(options?.maxPages ?? 2)));
439
+ const candidates = prioritizeWebPages(sources, query).slice(0, keepPages + 2);
379
440
  const focus = words(query).filter(w => w.length > 2 && isSubject(w));
380
441
  // Stop waiting once two pages have read; slower attempts end on their own 5 s page bound.
381
442
  const pages: Array<Awaited<ReturnType<typeof fetchPublicArticle>> | undefined> = new Array(candidates.length);
382
443
  await new Promise<void>(resolve => {
383
444
  let settled = 0, read = 0;
384
445
  if (!candidates.length) resolve();
385
- candidates.forEach((source, i) => { void fetchPublicArticle(source.url!, publicAddress, signal, focus).catch(() => null).then(page => { pages[i] = page; settled++; if (page) read++; if (read >= 2 || settled === candidates.length) resolve(); }); });
446
+ candidates.forEach((source, i) => { void fetchPublicArticle(source.url!, publicAddress, signal, focus).catch(() => null).then(page => { pages[i] = page; if (!page) webLedger().pagesFailed(feature, 1); settled++; if (page) read++; if (read >= keepPages || settled === candidates.length) resolve(); }); });
386
447
  });
387
448
  candidates.forEach((source, i) => {
388
449
  const page = pages[i];
389
- if (!page || fetched >= 2) return;
450
+ if (!page || fetched >= keepPages) return;
390
451
  source.text = READ_PAGE_PREFIX + redactSecrets(page.text); source.url=page.url; if(page.title) source.title=redactSecrets(page.title); fetched++;
391
452
  });
392
- return { sources, notices: [`Public web: ${fetched} result page${fetched === 1 ? '' : 's'} read (at most two, bounded excerpts). Other links have search-result evidence only. The search summary describes the search, not any single page. Search provider: Claude Haiku.`] };
453
+ if (fetched) webLedger().pagesRead(feature, fetched);
454
+ return { sources, notices: [`Public web: ${fetched} result page${fetched === 1 ? '' : 's'} read (at most ${keepPages === 2 ? 'two' : keepPages === 1 ? 'one' : keepPages}, bounded excerpts). Other links have search-result evidence only. The search summary describes the search, not any single page. Search provider: Claude Haiku.`] };
393
455
  }
394
456
  catch (e) {
395
457
  if (e instanceof AskProcessError && e.terminationUnconfirmed) {
@@ -412,3 +474,212 @@ export function recentPublicPages(exchanges:readonly MeetingAskExchange[],sessio
412
474
  const prior=exchanges.filter(e=>e.sessionId===sessionId&&e.serverInstanceId===serverInstanceId&&e.status==='completed'&&e.publicWebQuery===query&&now-Date.parse(e.createdAt)>=0&&now-Date.parse(e.createdAt)<900000).sort((a,b)=>a.createdAt.localeCompare(b.createdAt)).at(-1);
413
475
  return (prior?.sources??[]).filter(s=>pageWasRead(s)&&now-Date.parse(s.retrievedAt??prior!.createdAt)>=0&&now-Date.parse(s.retrievedAt??prior!.createdAt)<900000).slice(0,2).map(s=>({...s,retrievedAt:s.retrievedAt??prior!.createdAt}));
414
476
  }
477
+
478
+ /*
479
+ * Pasted links (6.97.8).
480
+ *
481
+ * The privacy rule is "a word reaches the web only if the user typed it". A public http(s) address the user typed into the
482
+ * question is words the user typed, so COS reads that one page even when the question has no public-intent wording ("This is
483
+ * the article we're discussing by the way: <url>"). It is read, not searched: nothing but the address the user typed is
484
+ * requested, and the page is fetched by fetchPublicArticle with its DNS pin, redirect checks and size and time caps.
485
+ *
486
+ * What stays. publicWebUrl (http or https, public host, no credentials, ports 80 and 443), the public-address check on every
487
+ * address the host resolves to (fetchPublicArticle), and redactSecrets: a link that carries a key or token, in its path or
488
+ * in a query parameter named like one, is refused, never fetched. At most PASTED_URL_MAX links are looked at per question;
489
+ * the rest are ignored. A link found only in the transcript, a file, memory or an earlier ANSWER is never fetched: this
490
+ * reads the current question text and nothing else.
491
+ */
492
+ export const PASTED_URL_MAX = 2;
493
+ export type PastedUrlRefusal = 'pasted_not_public' | 'pasted_secret' | 'pasted_over_limit';
494
+ export interface PastedUrls {
495
+ /** The links that may be read, in the order typed. */
496
+ urls: string[];
497
+ /** One entry per link that was looked at and refused, or ignored for being over the limit. */
498
+ refused: PastedUrlRefusal[];
499
+ }
500
+ const URL_IN_TEXT = /https?:\/\/[^\s<>"'`]+/gi;
501
+ /**
502
+ * Parameters that carry a credential, a one-time code or a signature, in a link's query or fragment. By name: distinctive words anywhere in
503
+ * the name (token, secret, passw..., apikey, signature, credential, bearer, hmac), short words as whole pieces of it (pwd, pass, passcode,
504
+ * sig, auth, session, sid, key, code, otp, jwt, pw), login/reset/verify/magic/invite hashes and keys, and one-letter names (?t=, ?h=, ?k=)
505
+ * only when the value is a long opaque string (a "?t=30" timestamp is fine). By value: a JWT in any parameter.
506
+ */
507
+ const SECRET_NAME_ANYWHERE = /token|secret|passw|apikey|signature|credential|bearer|hmac/i;
508
+ const SECRET_NAME_PIECES = new Set(['pwd', 'pass', 'passcode', 'passkey', 'sig', 'auth', 'authorization', 'session', 'sessionid', 'sid', 'key', 'code', 'otp', 'jwt', 'pw', 'secret']);
509
+ const SECRET_NAME_HASHES = /(?:login|reset|verify|verification|confirm|magic|invite|activation|unsubscribe|recovery)[_-]?(?:hash|key|code|id|link|token)/i;
510
+ const JWT_VALUE = /^eyJ[\w-]+\.[\w-]+\.[\w-]*$/;
511
+ function secretParam(name: string, value: string): boolean {
512
+ if (!value)
513
+ return false;
514
+ if (JWT_VALUE.test(value))
515
+ return true;
516
+ if (SECRET_NAME_ANYWHERE.test(name) || SECRET_NAME_HASHES.test(name))
517
+ return true;
518
+ const pieces = name.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
519
+ if (pieces.some(x => SECRET_NAME_PIECES.has(x)))
520
+ return true;
521
+ return name.length === 1 && /^[A-Za-z0-9_-]{16,}$/.test(value);
522
+ }
523
+ /** Addresses that are themselves the secret: whoever holds the link can post as the owner. Never fetched, never stored. */
524
+ const WEBHOOK_HOSTS = new Set(['hooks.slack.com', 'hooks.zapier.com', 'maker.ifttt.com', 'events.pagerduty.com', 'hooks.airtable.com', 'webhook.office.com']);
525
+ function webhookLink(u: URL): boolean {
526
+ const host = u.hostname.toLowerCase(), path = u.pathname.toLowerCase();
527
+ return WEBHOOK_HOSTS.has(host) || host.endsWith('.webhook.office.com') || /^hook\.[a-z0-9.-]*make\.com$/.test(host) || /^(?:discord|discordapp)\.com$/.test(host) && /^\/api(?:\/v\d+)?\/webhooks\//.test(path)
528
+ || host === 'outlook.office.com' && path.startsWith('/webhook') || host === 'api.telegram.org' && /^\/bot/.test(path);
529
+ }
530
+ function trimUrl(raw: string): string {
531
+ let u = raw.replace(/[.,;:!?'"\]}]+$/, '');
532
+ // A closing parenthesis belongs to the link only when an opening one does.
533
+ while (u.endsWith(')') && (u.match(/\)/g) ?? []).length > (u.match(/\(/g) ?? []).length)
534
+ u = u.slice(0, -1).replace(/[.,;:!?'"\]}]+$/, '');
535
+ return u;
536
+ }
537
+ /** One typed address: the public address to read, or why not. */
538
+ function classifyPastedUrl(raw: string): { ok: string } | { refusal: 'pasted_not_public' | 'pasted_secret' } {
539
+ let parsed: URL | null = null;
540
+ try {
541
+ parsed = new URL(raw);
542
+ }
543
+ catch { /* not an address */ }
544
+ if (!parsed)
545
+ return { refusal: 'pasted_not_public' };
546
+ // The fragment is read as parameters too (#access_token=..., or #/route?code=...): it never reaches a server, but it reaches stored text.
547
+ const fragment = parsed.hash.slice(1), fragmentParams = fragment ? new URLSearchParams(fragment.includes('?') ? fragment.slice(fragment.indexOf('?') + 1) : fragment) : new URLSearchParams();
548
+ const carriesSecret = !!(parsed.username || parsed.password) || raw !== redactSecrets(raw) || REDACTION_MARK.test(raw) || webhookLink(parsed)
549
+ || [...parsed.searchParams, ...fragmentParams].some(([name, value]) => secretParam(name, value));
550
+ if (carriesSecret)
551
+ return { refusal: 'pasted_secret' };
552
+ const ok = publicWebUrl(raw);
553
+ return ok ? { ok } : { refusal: 'pasted_not_public' };
554
+ }
555
+ /** The addresses typed into `question` that COS may read, and what it refused. Pure: no network, no disk. */
556
+ export function pastedPublicUrls(question: string): PastedUrls {
557
+ const found: string[] = [];
558
+ for (const m of question.match(URL_IN_TEXT) ?? []) {
559
+ const u = trimUrl(m);
560
+ if (u && !found.includes(u))
561
+ found.push(u);
562
+ }
563
+ const urls: string[] = [], refused: PastedUrlRefusal[] = [];
564
+ found.forEach((raw, i) => {
565
+ if (i >= PASTED_URL_MAX) {
566
+ refused.push('pasted_over_limit');
567
+ return;
568
+ }
569
+ const c = classifyPastedUrl(raw);
570
+ if ('ok' in c)
571
+ urls.push(c.ok);
572
+ else
573
+ refused.push(c.refusal);
574
+ });
575
+ return { urls, refused };
576
+ }
577
+ /** A web address written without its scheme but with a query or fragment that holds parameters: example.com/page?access_token=... */
578
+ const SCHEMELESS_WITH_PARAMS = /(?<![\w@.\/-])((?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?::\d+)?(?:\/[^\s?#<>"'`]*)?)[?#][^\s<>"'`]*=[^\s<>"'`]*/gi;
579
+ const SCHEMELESS_WEBHOOK = /(?<![\w@.\/-])(?:hooks\.slack\.com|hooks\.zapier\.com|maker\.ifttt\.com|events\.pagerduty\.com|hooks\.airtable\.com|(?:[a-z0-9-]+\.)*webhook\.office\.com|hook\.[a-z0-9.-]*make\.com|(?:discord|discordapp)\.com\/api(?:\/v\d+)?\/webhooks|outlook\.office\.com\/webhook|api\.telegram\.org\/bot)[^\s<>"'`]*/gi;
580
+ /**
581
+ * `text` as it may be KEPT (a paste_ or web_ file, which the live cues' prompts and the writer read):
582
+ * - an address that is not a clean public one (credentials, a key or token anywhere in it, a webhook, a private host) becomes
583
+ * "[link removed]";
584
+ * - every other address is cut to origin + path, with no query and no fragment (a one-time code, a reset hash or a token that no
585
+ * list of parameter names knows about cannot survive);
586
+ * - an address written without its scheme loses its query or fragment the same way, and a schemeless webhook address is removed.
587
+ * Every address counts, not only the first two: the limit of two is about what is read, not about what may sit in a stored file.
588
+ */
589
+ export function redactUnsafeLinks(text: string): string {
590
+ const withScheme = text.replace(URL_IN_TEXT, m => {
591
+ const u = trimUrl(m), tail = m.slice(u.length);
592
+ if (!u)
593
+ return m;
594
+ const c = classifyPastedUrl(u);
595
+ if (!('ok' in c))
596
+ return '[link removed]' + tail;
597
+ const p = new URL(c.ok); // classifyPastedUrl returned it, so it parses
598
+ return p.origin + p.pathname + tail;
599
+ });
600
+ return withScheme.replace(SCHEMELESS_WEBHOOK, '[link removed]').replace(SCHEMELESS_WITH_PARAMS, '$1');
601
+ }
602
+ /** Two addresses for the same page: scheme-agnostic host, no fragment, no tracking parameters, no trailing slash. */
603
+ export function pastedUrlKey(url: string): string {
604
+ try {
605
+ const u = new URL(url);
606
+ const keep = [...u.searchParams].filter(([k]) => !/^(?:utm_|fbclid$|gclid$|mc_|ref$)/i.test(k)).sort(([a], [b]) => a.localeCompare(b));
607
+ const query = keep.map(([k, v]) => `${k}=${v}`).join('&');
608
+ return `${u.hostname.toLowerCase().replace(/^www\./, '')}${u.pathname.replace(/\/+$/, '')}${query ? `?${query}` : ''}`;
609
+ }
610
+ catch {
611
+ return url;
612
+ }
613
+ }
614
+ /** A read page from this meeting's own recent exchanges for the same address (read less than 15 minutes ago), newest first. */
615
+ export function recentPastedPage(exchanges: readonly MeetingAskExchange[], sessionId: string, serverInstanceId: string, url: string, now = Date.now()): MeetingAskSource | null {
616
+ const key = pastedUrlKey(url);
617
+ const rows = exchanges.filter(e => e.sessionId === sessionId && e.serverInstanceId === serverInstanceId && e.status === 'completed').sort((a, b) => b.createdAt.localeCompare(a.createdAt));
618
+ for (const e of rows)
619
+ for (const s of e.sources ?? []) {
620
+ const at = Date.parse(s.retrievedAt ?? e.createdAt);
621
+ if (s.url && pageWasRead(s) && pastedUrlKey(s.url) === key && now - at >= 0 && now - at < 900000)
622
+ return { ...s, retrievedAt: s.retrievedAt ?? e.createdAt };
623
+ }
624
+ return null;
625
+ }
626
+ /** "The article", "that link", "the linked story": a follow-up that clearly points back at a link typed earlier, with none of its own.
627
+ * A bare "page", "site" or "post" does not count ("what is on page 3 of the deck?" is not about the article). */
628
+ export function pointsAtEarlierLink(question: string): boolean {
629
+ return pastedPublicUrls(question).urls.length === 0
630
+ && (/\b(?:the|that|this|those|my|our)\s+(?:(?:linked|pasted|news|web)\s+)?(?:article|story|link|url|piece)\b/i.test(question)
631
+ || /\b(?:linked|pasted)\s+(?:page|post|article|story|piece)\b/i.test(question));
632
+ }
633
+ /** The pages read for links the USER typed in this meeting's last few questions (read less than 15 minutes ago), newest first, at most PASTED_URL_MAX. */
634
+ export function recentPastedPagesFromUserLinks(exchanges: readonly MeetingAskExchange[], sessionId: string, serverInstanceId: string, now = Date.now()): MeetingAskSource[] {
635
+ const mine = exchanges.filter(e => e.sessionId === sessionId && e.serverInstanceId === serverInstanceId && e.status === 'completed' && e.action === 'ask' && now - Date.parse(e.createdAt) < 900000)
636
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 4);
637
+ const out: MeetingAskSource[] = [];
638
+ for (const e of mine)
639
+ for (const url of pastedPublicUrls(e.question).urls) {
640
+ const page = recentPastedPage(exchanges, sessionId, serverInstanceId, url, now);
641
+ if (page && !out.some(p => p.url === page.url))
642
+ out.push(page);
643
+ if (out.length >= PASTED_URL_MAX)
644
+ return out;
645
+ }
646
+ return out;
647
+ }
648
+
649
+
650
+ /*
651
+ * Comparison and fact-check questions (6.97.8).
652
+ *
653
+ * A public factual comparison ("is private aviation safer than commercial?") is answered from more than one page, so it may run up to
654
+ * COMPARISON_SEARCHES admitted searches in parallel (the usual Ask runs one) and keep COMPARISON_PAGES pages in all (2, 1 and 1).
655
+ * Cost and timing: three searches start together, so the wait is the slowest one (the 12 s per-search budget), not the sum; each
656
+ * search is one Haiku call plus the page reads, and each is counted in the daily web ledger. The answer still has to finish inside the
657
+ * 30 s Ask deadline, so a slow search ends with a notice instead of delaying the answer.
658
+ * Every query is built from the user's OWN typed words plus the generic modifiers publicSearchGate allows (comparison, average), and
659
+ * each still passes publicSearchGate in webLookup: nothing from the transcript, files or memory is ever added.
660
+ */
661
+ export const COMPARISON_SEARCHES = 3;
662
+ export const COMPARISON_PAGES = 4;
663
+ /** "than", "versus", "compared to": the question sets two things side by side. A fact-check request counts on its own (factCheckRequest). */
664
+ export function comparisonQuestion(question: string): boolean {
665
+ return /\b(?:than|versus|vs\.?|compared (?:to|with)|compare(?:d)?|difference between)\b/i.test(question) || factCheckRequest(question);
666
+ }
667
+ const COMPARATOR = new Set(['than', 'versus', 'vs', 'compared', 'compare']);
668
+ const SIDE_NOISE = new Set(['more', 'less', 'or', 'and', 'much', 'many']);
669
+ /**
670
+ * Up to three distinct queries for a comparison question, all made of the user's own typed words (those in `base`, the admitted query) and
671
+ * the modifiers "comparison" and "average": the query as admitted; the first side of the comparison plus "average"; the second side plus
672
+ * "average" (so each side gets its own pages). The sides come from the words around "than", "versus" or "vs" in the question. With no
673
+ * comparator they fall back to all the subject words plus "comparison" and plus "average". A typo stays as typed.
674
+ */
675
+ export function comparisonQueries(base: string, question: string = base): string[] {
676
+ const baseWords = new Set((base.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []));
677
+ const typed = (question.match(/[\p{L}\p{N}]+/gu) ?? []);
678
+ const at = typed.findIndex(w => COMPARATOR.has(w.toLowerCase()));
679
+ const uniq = (list: string[]) => list.filter((w, i) => list.findIndex(x => x.toLowerCase() === w.toLowerCase()) === i);
680
+ const side = (list: string[]) => uniq(list.filter(w => baseWords.has(w.toLowerCase()) && isSubject(w.toLowerCase()) && !SIDE_NOISE.has(w.toLowerCase()) && !COMPARATOR.has(w.toLowerCase())));
681
+ const all = side(typed);
682
+ const left = at < 0 ? [] : side(typed.slice(0, at)), right = at < 0 ? [] : side(typed.slice(at + 1)).filter(w => !left.some(l => l.toLowerCase() === w.toLowerCase()));
683
+ const out = left.length && right.length ? [base, `${left.join(' ')} average`, `${right.join(' ')} average`] : [base, `${all.join(' ')} comparison`, `${all.join(' ')} average`];
684
+ return out.map(q => q.trim().split(/\s+/).slice(0, 12).join(' ')).filter((q, i, list) => q.split(' ').some(w => isSubject(w.toLowerCase())) && list.indexOf(q) === i).slice(0, COMPARISON_SEARCHES);
685
+ }
@@ -5,7 +5,7 @@
5
5
  import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, unlinkSync } from 'node:fs'
6
6
  import { basename, join } from 'node:path'
7
7
  import { dataPath } from './data-dir.js'
8
- import { MeetingFinalizationJobStore, type MeetingFinalizationJob } from './meeting-finalization-jobs.js'
8
+ import { finalizationStop, MeetingFinalizationJobStore, type MeetingFinalizationJob } from './meeting-finalization-jobs.js'
9
9
  import { listActiveRecoveries } from './unsaved-audio-quarantine.js'
10
10
 
11
11
  export const BATCH_PROGRESS_FILENAME = '_batch_progress.json'
@@ -32,7 +32,8 @@ export interface MeetingBatchProgress {
32
32
 
33
33
  export interface MeetingSyncMeeting {
34
34
  meetingId: string
35
- phase: MeetingBatchPhase | 'pending'
35
+ /** `stopped` (6.97.0): visible, not working, and never counted as active or blocking. */
36
+ phase: MeetingBatchPhase | 'pending' | 'stopped'
36
37
  percent: number | null
37
38
  segmentsDone: number | null
38
39
  segmentsTotal: number | null
@@ -255,6 +256,27 @@ function finalizationSyncRow(job: MeetingFinalizationJob): MeetingSyncMeeting {
255
256
  }
256
257
  }
257
258
 
259
+ /**
260
+ * 6.97.0: a job that stopped trying. Says what is true and no more: nothing is running, so it does not
261
+ * say "do not restart", and it does not say "retrying". Short enough for COS Control's one-line row.
262
+ */
263
+ function stoppedFinalizationRow(job: MeetingFinalizationJob, chunkFiles: number): MeetingSyncMeeting {
264
+ const hq = job.phase === 'batch_pending'
265
+ const what = hq ? 'HQ polish' : job.phase === 'ops_pending' ? 'Library save' : 'Capture save'
266
+ const audio = hq && job.audioDir ? ' · audio kept' : ''
267
+ const next = finalizationStop(job)?.reason === 'crash_parked' ? '' : ' · retries later'
268
+ return {
269
+ meetingId: job.sessionId,
270
+ phase: 'stopped',
271
+ percent: null,
272
+ segmentsDone: null,
273
+ segmentsTotal: null,
274
+ chunkFiles,
275
+ updatedAt: job.updatedAt,
276
+ label: `${what} stopped${finalizationStop(job)?.reason === 'crash_parked' ? ' after crashes' : ''}${audio}${next}`,
277
+ }
278
+ }
279
+
258
280
  /** Snapshot of pending HQ polish work for /api/health and COS Control. */
259
281
  export function getMeetingSyncSnapshot(
260
282
  root: string = pendingBatchRoot(),
@@ -262,6 +284,16 @@ export function getMeetingSyncSnapshot(
262
284
  ): MeetingSyncSnapshot {
263
285
  const meetings: MeetingSyncMeeting[] = []
264
286
  const retained: MeetingSyncRetainedMeeting[] = []
287
+ const finalizationJobs = listFinalizationJobsForSnapshot(root, options)
288
+ // 6.97.0: a stopped job's directory is not evidence of work. A progress file or a fresh marker left
289
+ // by its last attempt would otherwise read as active below; its one row is the job's own.
290
+ const stoppedDirs = new Map<string, MeetingFinalizationJob>()
291
+ for (const job of finalizationJobs) {
292
+ if (!finalizationStop(job)) continue
293
+ stoppedDirs.set(job.sessionId, job)
294
+ if (job.audioDir) stoppedDirs.set(basename(job.audioDir), job)
295
+ }
296
+ const stoppedChunks = new Map<string, number>()
265
297
  let dirs: string[] = []
266
298
  if (existsSync(root)) {
267
299
  try {
@@ -285,6 +317,12 @@ export function getMeetingSyncSnapshot(
285
317
  chunkFiles = readdirSync(dir).filter(f => f.endsWith('.wav')).length
286
318
  } catch { /* ignore */ }
287
319
 
320
+ const stoppedJob = stoppedDirs.get(name)
321
+ if (stoppedJob) {
322
+ stoppedChunks.set(stoppedJob.sessionId, chunkFiles)
323
+ continue
324
+ }
325
+
288
326
  // A terminal outcome ends the meeting's ACTIVE life. Its WAVs stay for
289
327
  // retry, reported as retained — never as running work. The gate is
290
328
  // progress==null ONLY: the pending marker is refreshed every segment and
@@ -389,13 +427,21 @@ export function getMeetingSyncSnapshot(
389
427
  // then showed Meeting sync Idle with restart locked (2026-09-09 local 6.45.1
390
428
  // rollout). Same contract as active recoveries: this window is active work.
391
429
  const seen = new Set(meetings.map(meeting => meeting.meetingId))
392
- for (const job of listFinalizationJobsForSnapshot(root, options)) {
430
+ const stoppedRows: MeetingSyncMeeting[] = []
431
+ for (const job of finalizationJobs) {
393
432
  if (seen.has(job.sessionId)) continue
394
433
  seen.add(job.sessionId)
395
- meetings.push(finalizationSyncRow(job))
434
+ if (finalizationStop(job)) stoppedRows.push(stoppedFinalizationRow(job, stoppedChunks.get(job.sessionId) ?? 0))
435
+ else meetings.push(finalizationSyncRow(job))
396
436
  }
397
437
 
398
438
  if (meetings.length === 0) {
439
+ // 6.97.0: visible, but no work. Control keys Restart, Update and Sync now off `active` and
440
+ // `blocksRestart` here and off `finalization.pending`, so all three must read idle.
441
+ if (stoppedRows.length > 0) {
442
+ const label = stoppedRows.length === 1 ? stoppedRows[0]!.label : `${stoppedRows.length} meetings stopped · audio kept`
443
+ return { active: false, percent: null, label, blocksRestart: false, meetings: stoppedRows, retained }
444
+ }
399
445
  const label = retained.length > 0
400
446
  ? `Idle · ${retained.length} retained batch${retained.length === 1 ? '' : 'es'}`
401
447
  : 'Idle'
@@ -416,7 +462,7 @@ export function getMeetingSyncSnapshot(
416
462
  percent,
417
463
  label,
418
464
  blocksRestart: true,
419
- meetings,
465
+ meetings: [...meetings, ...stoppedRows],
420
466
  retained,
421
467
  }
422
468
  }
@@ -600,6 +600,23 @@ async function decodeWhisperWindow(
600
600
 
601
601
  /** A Parakeet window with no words over audio the speech detector says holds speech is a failed decode. */
602
602
  export const PARAKEET_EMPTY_SPEECH_SEC = 0.5
603
+ /** How long one saved chunk runs; the most a kept live word can be placed from its true time. */
604
+ const LIVE_CHUNK_SEC = 6
605
+
606
+ /** A window's live words, clocked evenly across each chunk. They need real times on the window's
607
+ * clock: one untimed window would make the saver drop speaker labels for the whole meeting. */
608
+ function liveWordsForWindow(segment: BatchSegment, entries: IndexedTranscriptChunk[]): WhisperWord[] {
609
+ const words: WhisperWord[] = []
610
+ for (const entry of entries) {
611
+ if (entry.chunkIndex < segment.startChunkIdx || entry.chunkIndex > segment.endChunkIdx) continue
612
+ const tokens = (entry.chunk.text ?? '').split(/\s+/).filter(Boolean)
613
+ const start = (entry.chunk.elapsed - segment.startElapsed) / 1000
614
+ tokens.forEach((word, i) => {
615
+ words.push({ word, start: start + (i * LIVE_CHUNK_SEC) / tokens.length, end: start + ((i + 1) * LIVE_CHUNK_SEC) / tokens.length, probability: 0 })
616
+ })
617
+ }
618
+ return words
619
+ }
603
620
 
604
621
  async function decodeParakeetWindow(
605
622
  deps: HqDecodeDeps,
@@ -693,9 +710,25 @@ async function decodeHqWindow(
693
710
  }
694
711
  } else if (plan.engine === 'parakeet-ultra') {
695
712
  try {
696
- primary = await parakeetGuarded(job, () => decodeParakeetWindow(deps, input, parakeetTimeout(job), 'parakeet-ultra', true))
713
+ // A strict plan has no second engine, so an empty window is an answer (laughter, music,
714
+ // crosstalk), not a reason to fail the whole meeting. As a failure it replayed the same
715
+ // audio on every retry and no later window was ever saved (2026-10-30, two calls).
716
+ primary = await parakeetGuarded(job, () => decodeParakeetWindow(deps, input, parakeetTimeout(job), 'parakeet-ultra', !plan.strictPrimary))
717
+ if (plan.strictPrimary && !primary.text.trim()) {
718
+ const speech = deps.speechSeconds?.(input) ?? null
719
+ if (speech !== null && speech >= PARAKEET_EMPTY_SPEECH_SEC) {
720
+ const live = liveWordsForWindow(segment, entries)
721
+ fallbackReason = live.length > 0 ? 'parakeet_empty_over_speech; kept_live_words' : 'parakeet_empty_over_speech'
722
+ console.warn(`[meeting-batch] Parakeet returned no words for ${segment.startChunkIdx}-${segment.endChunkIdx} over ${speech.toFixed(1)} s of speech; ${live.length > 0 ? 'kept the live words' : 'no live words either'}`)
723
+ if (live.length > 0) primary = { ...primary, text: live.map(w => w.word).join(' '), words: live }
724
+ }
725
+ }
697
726
  } catch (error) {
698
- if (plan.strictPrimary) throw new MacParakeetUnavailableError(error)
727
+ if (plan.strictPrimary) {
728
+ // The window and the reason only: a decode error's message can quote what was said.
729
+ console.warn(`[meeting-batch] Strict Parakeet failed for ${segment.startChunkIdx}-${segment.endChunkIdx}: ${(error as { reason?: unknown })?.reason ?? (error as Error)?.name ?? 'unknown'}`)
730
+ throw new MacParakeetUnavailableError(error)
731
+ }
699
732
  fallbackReason = `parakeet_failed: ${errorReason(error)}`
700
733
  console.warn(`[meeting-batch] Parakeet HQ failed for ${segment.startChunkIdx}-${segment.endChunkIdx}; using Large-v3: ${errorReason(error)}`)
701
734
  const w = await decodeWhisperWindow(deps, input, previousText, 'large-v3', segment, audioDir)
@@ -27,6 +27,7 @@ import { groundWebLinks } from './meeting-ask-links.js'
27
27
  import type { FactCheckClaim } from './meeting-ask-store.js'
28
28
  import { isFactCheckCTA, pageWasRead, COVERAGE, normalizeReviewConfidence, reviewRepairDiagnostics, READ_PAGE_PREFIX } from './meeting-ask-review.js'
29
29
  import { publicSearchGate } from './meeting-ask-web.js'
30
+ import { webLedger } from './web-lookup-ledger.js'
30
31
  import { PRIVATE_QUERY, RESEARCH_WORDS } from './meeting-public-topic.js'
31
32
  import { redactSecrets } from './lens-gist-engines.js'
32
33
  import { getOwnerHomeArea, HOME_AREA_LABEL } from './profile.js'
@@ -413,6 +414,8 @@ export function admitFactCheckClaims(raw: unknown, input: Pick<AskInput, 'turns'
413
414
  export function originQuery(baseQuery: string, origin: OwnerOrigin | null): string | undefined {
414
415
  const full = origin ? `${factWords(origin.text).join(' ')} ${baseQuery}` : baseQuery
415
416
  const gate = publicSearchGate({ question: 'Search the web for ' + full, query: full })
417
+ // The one place a fact check's query meets the public-search gate: a refusal is counted here, once per claim it is asked about.
418
+ if (!gate.ok) webLedger().withheld('factCheck', gate.reason)
416
419
  return gate.ok ? gate.query : undefined
417
420
  }
418
421