@gotcos/glasses-server 6.95.0 → 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.
- package/CHANGELOG.md +157 -0
- package/README.md +4 -0
- package/managed-runtime-contract.json +13 -0
- package/package.json +3 -1
- package/server/index.ts +12 -0
- package/server/lib/attached-provider-adapter.ts +35 -1
- package/server/lib/claude-bridge.ts +47 -0
- package/server/lib/codex-bridge.ts +11 -1
- package/server/lib/codex-rollout-watermark.ts +42 -0
- package/server/lib/cursor-bridge.ts +2 -1
- package/server/lib/decision-provider.ts +4 -2
- package/server/lib/dictation-clean-models.ts +2 -0
- package/server/lib/dictation-clean.ts +2 -2
- package/server/lib/dictation-polish-pipeline.ts +22 -2
- package/server/lib/dictation-prewarm.ts +16 -0
- package/server/lib/direct-bridge.ts +14 -2
- package/server/lib/direct-sse.ts +6 -1
- package/server/lib/display-bus.ts +1 -1
- package/server/lib/fork-thread.ts +32 -1
- package/server/lib/held-naming-batches.ts +44 -7
- package/server/lib/held-voice-groups.ts +5 -4
- package/server/lib/job-audit-caller.ts +20 -0
- package/server/lib/keyed-sessions.ts +21 -1
- package/server/lib/lens-gist-engines.ts +17 -5
- package/server/lib/live-cos-ask.ts +29 -6
- package/server/lib/live-cue-verdicts.ts +43 -8
- package/server/lib/live-cues-engine.ts +4 -1
- package/server/lib/live-cues-filter.ts +50 -0
- package/server/lib/live-cues-general-prompts.ts +137 -0
- package/server/lib/live-cues-general-topic.ts +245 -0
- package/server/lib/live-cues-general.ts +715 -0
- package/server/lib/live-cues-v2-data.ts +453 -0
- package/server/lib/live-cues-v2-grounded.ts +262 -0
- package/server/lib/live-cues-v2-judge-b.ts +59 -0
- package/server/lib/live-cues-v2-names.ts +49 -0
- package/server/lib/live-cues-v2-notes.ts +8 -4
- package/server/lib/live-cues-v2-open-items.ts +132 -0
- package/server/lib/live-cues-v2-pacing.ts +45 -0
- package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
- package/server/lib/live-cues-v2-settings.ts +89 -10
- package/server/lib/live-cues-v2.ts +570 -47
- package/server/lib/mac-recording-asr.ts +6 -3
- package/server/lib/mac-speaker-call-human.ts +371 -0
- package/server/lib/mac-speaker-call-interval-human.ts +499 -0
- package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
- package/server/lib/mac-speaker-call-original-view.ts +328 -0
- package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
- package/server/lib/mac-speaker-call-producer.ts +539 -0
- package/server/lib/mac-speaker-call-store.ts +264 -0
- package/server/lib/mac-speaker-call-view.ts +373 -0
- package/server/lib/mac-speaker-live-compute.ts +72 -0
- package/server/lib/mac-speaker-live-memory.ts +414 -0
- package/server/lib/mac-speaker-live-projection.ts +126 -0
- package/server/lib/mac-speaker-live-save.ts +90 -0
- package/server/lib/mac-speaker-live-store.ts +71 -0
- package/server/lib/mac-speaker-live-worker.ts +58 -0
- package/server/lib/mac-speaker-live.ts +175 -0
- package/server/lib/mac-speaker-profile-extractor.ts +15 -10
- package/server/lib/mac-speaker-shadow-repair.ts +307 -0
- package/server/lib/mac-voice-assignment.ts +46 -27
- package/server/lib/mac-voice-display.ts +195 -0
- package/server/lib/mac-voice-naming-batch.ts +1067 -0
- package/server/lib/mac-voice-suggestions.ts +548 -0
- package/server/lib/maintenance-lifecycle.ts +2 -0
- package/server/lib/media-store.ts +2 -1
- package/server/lib/meeting-ask-pasted.ts +135 -0
- package/server/lib/meeting-ask-review.ts +8 -1
- package/server/lib/meeting-ask-runner.ts +4 -1
- package/server/lib/meeting-ask-runtime.ts +189 -17
- package/server/lib/meeting-ask-store.ts +2 -0
- package/server/lib/meeting-ask-web.ts +284 -13
- package/server/lib/meeting-batch-progress.ts +51 -5
- package/server/lib/meeting-batch-transcribe.ts +35 -2
- package/server/lib/meeting-corrections.ts +2 -1
- package/server/lib/meeting-fact-check.ts +3 -0
- package/server/lib/meeting-finalization-jobs.ts +69 -2
- package/server/lib/meeting-public-topic.ts +6 -1
- package/server/lib/meeting-relabel-enrolment.ts +2 -1
- package/server/lib/meeting-relabel.ts +17 -10
- package/server/lib/meeting-speaker-review.ts +187 -21
- package/server/lib/meeting-store.ts +6 -1
- package/server/lib/parakeet.ts +51 -5
- package/server/lib/pending-batch-retention.ts +58 -0
- package/server/lib/private-reference.ts +38 -0
- package/server/lib/provider-usage.ts +561 -0
- package/server/lib/public-https-url.ts +32 -0
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/lib/rich-media-safety.ts +23 -2
- package/server/lib/session-find-ask.ts +288 -0
- package/server/lib/session-find-collect.ts +715 -0
- package/server/lib/session-find-redact.ts +254 -0
- package/server/lib/session-find.ts +345 -0
- package/server/lib/speaker-calibration-log.ts +3 -2
- package/server/lib/speaker-embeddings.ts +46 -35
- package/server/lib/speaker-name-nfc.ts +37 -0
- package/server/lib/speaker-rename-fanout.ts +7 -6
- package/server/lib/speaker-shadow-decide.ts +59 -22
- package/server/lib/speaker-shadow-pair-source.ts +91 -0
- package/server/lib/speaker-shadow-paired.ts +268 -0
- package/server/lib/speaker-shadow-report.ts +2 -2
- package/server/lib/speaker-shadow-worker.ts +50 -15
- package/server/lib/speaker-shadow.ts +80 -19
- package/server/lib/speaker-trainer.ts +2 -1
- package/server/lib/token-audit.ts +36 -6
- package/server/lib/transcript-cleanup.ts +3 -2
- package/server/lib/voice-directory.ts +10 -5
- package/server/lib/voice-profile-store.ts +24 -14
- package/server/lib/web-lookup-ledger.ts +151 -0
- package/server/lib/work-review-runtime.ts +2 -0
- package/server/lib/work-search.ts +3 -1
- package/server/lib/xlsx-text.ts +277 -0
- package/server/routes/decision-provider.ts +4 -1
- package/server/routes/jev.ts +5 -1
- package/server/routes/live-cues.ts +36 -14
- package/server/routes/meeting.ts +515 -61
- package/server/routes/session-find.ts +73 -0
- package/server/routes/transcribe-stream.ts +114 -33
- package/server/routes/voice.ts +28 -18
- package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
- package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
- package/shared/live-cues-v2.ts +38 -5
- package/shared/media-attachment.ts +2 -0
- 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
|
|
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
|
|
82
|
-
const
|
|
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
|
-
|
|
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(?:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 >=
|
|
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 >=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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 @@
|
|
|
27
27
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
28
28
|
import { join, resolve } from 'node:path'
|
|
29
29
|
import { dataPath } from './data-dir.js'
|
|
30
|
+
import { sameSpeakerName } from './speaker-name-nfc.js'
|
|
30
31
|
|
|
31
32
|
export const CORRECTIONS_DIR = 'meeting-corrections'
|
|
32
33
|
|
|
@@ -268,7 +269,7 @@ export function currentLabelFor(sessionId: string, originalLabel: string): strin
|
|
|
268
269
|
for (let hop = 0; hop < applied.length; hop++) {
|
|
269
270
|
// Latest applied correction FROM the current label wins: a later human
|
|
270
271
|
// decision supersedes an earlier one.
|
|
271
|
-
const next = [...applied].reverse().find(r => r.from
|
|
272
|
+
const next = [...applied].reverse().find(r => sameSpeakerName(r.from, label))
|
|
272
273
|
if (!next || seen.has(next.to)) break
|
|
273
274
|
label = next.to
|
|
274
275
|
seen.add(label)
|
|
@@ -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
|
|