@gotcos/glasses-server 6.61.0 → 6.61.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +9 -7
- package/package.json +2 -1
- package/server/lib/client-liveness.ts +5 -4
- package/server/lib/cos-operations-meetings.ts +208 -25
- package/server/lib/held-naming-batches.ts +3 -0
- package/server/lib/nemotron-final.ts +34 -8
- package/server/lib/nemotron-live.ts +58 -15
- package/server/lib/permission-broker.ts +8 -6
- package/server/lib/speaker-embeddings.ts +2 -1
- package/server/lib/voiceprint-floor.ts +11 -3
- package/server/routes/meeting.ts +17 -2
- package/server/routes/transcribe-stream.ts +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
+
## 6.61.2
|
|
2
|
+
|
|
3
|
+
### Renaming a voice works on merged meetings whose recording was filed in a different folder
|
|
4
|
+
|
|
5
|
+
- **What was broken.** When COS merges a glasses recording into a call's write-up, the two are often filed under different areas: the recording under personal, the call under quilt. The server looked for the merged write-up only beside the recording, so it missed it. On 2026-10-02 that was 65 of the 199 merged meetings on Miles's Mac, among them Chris <> Miles Sync on 9/10 (8 voices). Opening Speakers on one showed the raw recording name, and renaming or removing a voice there either failed with "Meeting source changed; reopen the meeting" (when the request carried the record the meetings list shows) or renamed the voice in the recording only and left the meeting's attendees and transcript saying the old name (what COS Control 0.5.254 does; it reported "meeting markdown unreadable"). Naming held voices on these meetings followed the recording's own pointer instead: for 63 it left the meeting's transcript as it was and only marked it as having newer labels, and for 2 (the 9/10 meeting among them) the pointer named a file that had moved, so it failed.
|
|
6
|
+
- **What works now.** The server looks in every area's folder for the recording's month, and also the next or previous month when the recording is dated the first or last day of a month (none of the 199 needed that; a call that starts just before midnight can). It picks the meeting that says it holds the recording with `<!-- g2-session: <id> -->`. Speakers shows that meeting's title and area, renaming, removing or confirming a voice updates that meeting, naming held voices edits it the way it already did for merges filed beside their recording, and the record matches the meetings list row. Merged meetings beside their recording work as before.
|
|
7
|
+
- **Only an exact marker counts.** A title, a file name or an id that differs by one character never matches. iCloud conflict copies (`Name 2.md`) and duplicate folders (`2026-09 2`) are never read as meetings.
|
|
8
|
+
- **Two meetings claiming one recording: neither is chosen.** The recording keeps its own record, the server logs `merged_scribe_ambiguous` with both paths, a rename says so in `markdownSkipped`, and naming held voices refuses until one is fixed. None of the 199 is in that state.
|
|
9
|
+
- **Speed.** The meetings list does not use this lookup and is unchanged. On a copy of Miles's 2,640 meetings, median of 9 runs, two runs each: the default list 313 and 317 ms on 6.61.1, 312 and 315 ms on 6.61.2; one month 84 and 83 ms, then 84 and 84 ms. Opening Speakers on a merged meeting reads that month's write-ups across every area once after a start or a change (49 to 51 ms the first time, against 35 ms on 6.61.1) and afterwards only checks their timestamps: 28 to 29 ms, the same as 6.61.1.
|
|
10
|
+
- **Checked on copies of the real meetings.** All 65 cross-folder meetings resolve to the meeting the list shows (0 of 65 on 6.61.1), all 134 same-folder ones still do, and the 59 recordings the marker pass left for review resolve exactly as on 6.61.1 (30 to their own write-up, 29 to the recording alone, none to a merged meeting). A rename on a copy of the 9/10 meeting: 6.61.1 refused it with the list's record and changed only the recording with the Speakers record; 6.61.2 applied it to the meeting.
|
|
11
|
+
- **For clients.** No response field was added or removed. For a cross-folder merged meeting, `/api/meeting/:id/speakers` and `/content` now carry the merged meeting's `recordId`, `title`, `filename` and `domain`, the values its list row already had; every other meeting answers as before. COS Glasses 6.9.563 to 6.9.570 and COS Control 0.5.254 need no update.
|
|
12
|
+
- **Comments only.** The code comments and README now say glasses presence is a ring, temple or lens gesture, or a phone touch while COS is in front, and never the worn status (COS Glasses 6.9.570). No behaviour change; `presenceAgeMs` is unchanged.
|
|
13
|
+
- Tests: a route suite over HTTP in the pipeline layout (the list row, the Speakers record, title and area, a rename sent with either record, a removal, a confirmation, the copy text, a same-folder merge, and two claims refused), the lookup itself (every area, an area name this repo never uses, the capture's own write-up still winning, no claim, near-miss ids, a title that is not a claim, two claims in one folder or two, iCloud copies of a write-up, of a recording and of a month folder, the first and last day of a month and a year boundary, a mid-month recording not searching further, the cache seeing a new, edited, emptied or deleted write-up and a deleted month folder, a warm lookup reading nothing, a write-up changed between the scan and the read, an unreadable folder or file in another area, a FIFO never opened, suggestion sides and merged-record sources), and held naming (a cross-folder merge, two claims refused). On 6.61.1 the route suite fails 7 of 9 and the two held naming tests fail.
|
|
14
|
+
- `node server/scripts/merged-scribe-mutation-gate.mjs` (repository only, not in the package) runs from a green baseline (85 tests in 3 files), matches each target exactly once, and each mutant must fail the test named for it: 41 of 41 killed. The 6.61.0 and 6.61.1 gate (`nemotron-mutation-gate.mjs`, including the finalizing refusal and the re-read before the final pass writes) rerun on this build: baseline 169 tests in 11 files, 104 of 104 killed. Full suite 5,761 passed, 2 skipped (378 files); typecheck clean; `npm audit --omit=dev` 0 vulnerabilities.
|
|
15
|
+
|
|
16
|
+
## 6.61.1
|
|
17
|
+
|
|
18
|
+
### Fixes 6.61.0 labelling other people as the wearer: a split track is named only by a confident voiceprint, never by the glasses' default label
|
|
19
|
+
|
|
20
|
+
- Miles, 2026-10-02 10:19, in a live meeting on 6.61.0: "MU is being assigned to loudest speaker vs my actual voice signature ... I saw Jeremy Sokolic voice showing up as MU." In that session (`meeting_1790953219564_yhwyxs`, read from a copy), 40 turns read MU. 25 were genuine (voiceprint at 0.65 or more, Even Hub `self`). 11 had similarity 0 in two-track chunks where Even Hub heard mostly someone else ("Okay.", "I can send a message. Yeah.", "to have as we play into calendar."): a short second track took the client's label, and the client's label is the glasses' default wearer label (`MU`), not evidence of who spoke. 2 more ("Of course. All right." at 0.612, and 0.625) were a track's own voiceprint check returning the wearer from the full search, which accepts any profile from 0.55, below the 0.65 at which the voiceprint verifies the wearer.
|
|
21
|
+
- **The client's label never names a track.** In a split chunk, a short track that is not the dominant one is `Ext`. The client's label is still what 6.60.0 used it for: a whole chunk no voiceprint could run on (under 2 s, or no voiceprint model). A one-track chunk is labelled exactly as 6.60.0 labelled the chunk.
|
|
22
|
+
- **The wearer needs owner-verify confidence in a split chunk.** A track named the wearer (`owner_speaker_label`) by its own voiceprint check, or a short dominant track that would take the whole chunk's voiceprint, keeps the wearer's name only at `OWNER_VERIFY_SIMILARITY` (0.65, now one constant shared with the voiceprint's owner verify); between 0.55 and 0.65 it is `Ext`. Any other name keeps the search threshold, as before. The two 0.665 and 0.716 turns where Even Hub heard mostly someone else are above the floor and stay, as reviewed.
|
|
23
|
+
- **The final pass applies the same rule.** Its channel map was already built only from chunks at 0.65 or more. Its per-chunk fallback (the live turn a word sat in, else the chunk's label) now treats a wearer name in a split chunk below 0.65 as `Ext`, so a 6.61.0 turn like these is never carried into a final transcript.
|
|
24
|
+
- **Logged and counted.** Every chunk where a would-be wearer name was refused logs one line, `[diarizer] owner-guard chunk #N refused=K client_label=X weak_owner=Y`; the chunk's `diarizer.ownerGuard` lists the reasons, and `readiness.diarizer.ownerGuard` counts them since start.
|
|
25
|
+
- **Replayed on the field session (read-only copies of its 195 Nemotron chunks and their audio, the real CLI and the real voiceprint):** all 28 genuine wearer turns (0.65 or more, Even Hub `self`) stay MU; 0 of the 11 similarity-0 MU turns and 0 of the 2 weak ones (0.612, 0.625) are MU any more; the 2 borderline turns stay MU; the guard refused 13 client labels and 2 weak wearer matches; no chunk fell back.
|
|
26
|
+
- Tests: the field chunks rebuilt from their records ("Okay." with Even Hub self 19/121, "I can send a message. Yeah." at self 3/125 with Kyle keeping his own track, "Of course. All right." at 0.612), the wearer staying the wearer in a one-track chunk (0.703) and a split one (0.716), a weak wearer match refused on both paths (the whole-chunk voiceprint a short dominant turn takes, and a track's own check), the client label never naming a track, the owner-guard log line and count, the route passing `owner_speaker_label`, and the final pass refusing a 6.61.0 similarity-0 MU turn while keeping a confident one. The mutation gate adds a case for each guard and retargets the three 6.61.0 cases the guard replaced: baseline 169 tests in 11 files, 104 of 104 killed. Full suite 5,723 passed, 2 skipped.
|
|
27
|
+
- No Install hooks and no COS Control change.
|
|
28
|
+
|
|
1
29
|
## 6.61.0
|
|
2
30
|
|
|
3
31
|
### Nemotron is the default diarizer, for real: it splits two-voice chunks on the HUD during the call and in the final transcript, and the voiceprint names each voice
|
package/README.md
CHANGED
|
@@ -265,21 +265,22 @@ or the away hold's.
|
|
|
265
265
|
|
|
266
266
|
**The away hold (6.60.0).** A request that starts while you are away (the desk idle 90 s,
|
|
267
267
|
the same threshold as above) is held for up to 10 minutes, but only when the glasses or the
|
|
268
|
-
phone that show held questions report that you
|
|
268
|
+
phone that show held questions report that you touched them in the last 30 minutes.
|
|
269
269
|
The client contract (COS Glasses 6.9.563 and later):
|
|
270
270
|
|
|
271
271
|
- Poll `GET /api/session-questions?client=glasses|phone&hold=1&presenceAgeMs=<int>`.
|
|
272
272
|
`hold=1` says this client shows held questions. `presenceAgeMs` is the milliseconds since
|
|
273
|
-
the latest real wearer evidence (glasses: a ring or
|
|
274
|
-
|
|
275
|
-
|
|
273
|
+
the latest real wearer evidence (glasses: a ring, temple or lens gesture; phone: a touch
|
|
274
|
+
while COS is in front), a whole number from 0 to 86,400,000, and is left out when there is
|
|
275
|
+
none since the app started. The worn status never counts (COS Glasses 6.9.570 and later):
|
|
276
|
+
wear detection can be turned off, and then it always reads not worn.
|
|
276
277
|
- Only a poll with `hold=1` AND a valid `presenceAgeMs` counts toward the hold. The server
|
|
277
278
|
keeps the newest such presence in memory, on the monotonic clock and the wall clock, and
|
|
278
279
|
reads its age as the larger of the two (so a Mac that slept never makes it look newer).
|
|
279
280
|
Recent means within `COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S` (default 1800, clamped to 0
|
|
280
281
|
through 86400; under 30 it means the 30 s live window). After a server restart nothing is
|
|
281
282
|
recent until such a poll.
|
|
282
|
-
- A `hold=1` poll without presence (a connected lens nobody
|
|
283
|
+
- A `hold=1` poll without presence (a connected lens nobody touches) makes no client live and
|
|
283
284
|
holds nothing. A poll without `hold=1` (COS Glasses 6.9.562 and earlier, every older phone
|
|
284
285
|
build) keeps the 6.59.0 meaning exactly: live for 30 s, a 110 s hold, and never the away
|
|
285
286
|
hold.
|
|
@@ -597,8 +598,9 @@ is split into turns instead of carrying one label.
|
|
|
597
598
|
`segments: [{ speaker, text, startSec, endSec, similarity }]` in time order;
|
|
598
599
|
their texts joined with one space equal `text`. A track with at least 2 s of
|
|
599
600
|
its own audio is named by the voiceprint on that audio; a shorter one takes the
|
|
600
|
-
chunk's voiceprint when it is the dominant voice, otherwise
|
|
601
|
-
|
|
601
|
+
chunk's voiceprint when it is the dominant voice, otherwise it is `Ext`. The
|
|
602
|
+
glasses' default wearer label never names a track, and in a split chunk the
|
|
603
|
+
wearer is named only at owner-verify confidence (0.65; since 6.61.1). The
|
|
602
604
|
segments are stored on the chunk, so the saved meeting keeps them.
|
|
603
605
|
- **The final transcript.** When a meeting is saved, one Nemotron pass over the
|
|
604
606
|
whole meeting maps each channel to a name from the chunks the voiceprint was
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.61.
|
|
3
|
+
"version": "6.61.2",
|
|
4
4
|
"description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"!server/scripts/work-progress-mutation-gate.mjs",
|
|
48
48
|
"!server/scripts/away-hold-mutation-gate.mjs",
|
|
49
49
|
"!server/scripts/nemotron-mutation-gate.mjs",
|
|
50
|
+
"!server/scripts/merged-scribe-mutation-gate.mjs",
|
|
50
51
|
"!server/scripts/nemotron-replay.ts",
|
|
51
52
|
"!server/scripts/mutation-gate.mjs",
|
|
52
53
|
"!server/lib/meeting-engine/__fixtures__/**",
|
|
@@ -27,10 +27,11 @@
|
|
|
27
27
|
// 6.60.0 (QA round 1, the contract shared with COS Glasses 6.9.563): the away hold needs
|
|
28
28
|
// more than a poll. A poll QUALIFIES only with `hold=1` (this client shows held questions;
|
|
29
29
|
// 6.9.563 and later send it) AND a finite `presenceAgeMs` within [0, 24 h]: the ms since
|
|
30
|
-
// the latest real wearer evidence (glasses: a ring or
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
30
|
+
// the latest real wearer evidence (glasses: a ring, temple or lens gesture; phone: a touch
|
|
31
|
+
// while COS is in front). The worn status never counts: Glasses 6.9.570 dropped it because
|
|
32
|
+
// wear detection can be turned off, and then it always reads not worn. A connected lens
|
|
33
|
+
// nobody touches sends no `presenceAgeMs`, and an older client sends no `hold=1`, so neither
|
|
34
|
+
// can make the Mac hold a question for 10 minutes.
|
|
34
35
|
//
|
|
35
36
|
// Kept apart, all in memory (a restart forgets them, which fails toward 6.59.0):
|
|
36
37
|
// - the newest counting poll of any kind (`lastQuestionsPollAt`, for /api/health);
|
|
@@ -478,6 +478,12 @@ function withMeetingListInsights(meta: CosOperationsMeetingMeta, content: string
|
|
|
478
478
|
* but the readable meeting is now the merged scribe, so this returns the orphan's
|
|
479
479
|
* sidecar path with the merged scribe's path, filename and title. Without that,
|
|
480
480
|
* naming a voice on a merged meeting opened a file that no longer existed.
|
|
481
|
+
*
|
|
482
|
+
* ...AND ITS DOMAIN AND MONTH (6.61.2). The merged scribe may sit in another
|
|
483
|
+
* domain folder than the sidecar, and `domain`, `month` and `filename` are what
|
|
484
|
+
* every caller builds the `ops:` recordId from, so all three describe the merged
|
|
485
|
+
* scribe: the same recordId the meetings list shows for that row. `sidecarPath`
|
|
486
|
+
* stays the capture's, wherever it is.
|
|
481
487
|
*/
|
|
482
488
|
export function findCosOperationsMeetingBySessionId(sessionId: string): {
|
|
483
489
|
sidecarPath: string
|
|
@@ -486,22 +492,31 @@ export function findCosOperationsMeetingBySessionId(sessionId: string): {
|
|
|
486
492
|
domain: string
|
|
487
493
|
month: string
|
|
488
494
|
title: string
|
|
495
|
+
/**
|
|
496
|
+
* 6.61.2. Set only when MORE THAN ONE meeting declares this session through
|
|
497
|
+
* `<!-- g2-session -->`: their operations-relative paths. The record then
|
|
498
|
+
* describes the capture alone, exactly as an unresolved merge does, because
|
|
499
|
+
* picking one of them would write a correction into a meeting chosen by
|
|
500
|
+
* directory order.
|
|
501
|
+
*/
|
|
502
|
+
mergedScribeConflict?: string[]
|
|
489
503
|
} | null {
|
|
490
504
|
const operationsDir = resolveCosOperationsDir()
|
|
491
505
|
if (!operationsDir) return null
|
|
492
506
|
|
|
493
|
-
|
|
507
|
+
const domains = discoverMeetingDomains(operationsDir)
|
|
508
|
+
for (const domain of domains) {
|
|
494
509
|
const meetingsBase = join(operationsDir, domain, 'meetings')
|
|
495
510
|
let months: string[]
|
|
496
511
|
try {
|
|
497
|
-
months = readdirSync(meetingsBase).filter(d =>
|
|
512
|
+
months = readdirSync(meetingsBase).filter(d => MONTH_PATTERN.test(d)).sort().reverse()
|
|
498
513
|
} catch { continue }
|
|
499
514
|
|
|
500
515
|
for (const month of months) {
|
|
501
516
|
const monthDir = join(meetingsBase, month)
|
|
502
517
|
let sidecars: string[]
|
|
503
518
|
try {
|
|
504
|
-
sidecars = readdirSync(monthDir).filter(f => f.endsWith('.g2-chunks.json') &&
|
|
519
|
+
sidecars = readdirSync(monthDir).filter(f => f.endsWith('.g2-chunks.json') && !ICLOUD_CONFLICT_COPY.test(f)).sort().reverse()
|
|
505
520
|
} catch { continue }
|
|
506
521
|
|
|
507
522
|
for (const sidecarName of sidecars) {
|
|
@@ -510,17 +525,26 @@ export function findCosOperationsMeetingBySessionId(sessionId: string): {
|
|
|
510
525
|
const sidecarPath = join(monthDir, sidecarName)
|
|
511
526
|
let meetingPath = join(monthDir, meetingFilename)
|
|
512
527
|
let resolvedFilename = meetingFilename
|
|
528
|
+
let resolvedDomain = domain
|
|
529
|
+
let resolvedMonth = month
|
|
530
|
+
let mergedScribeConflict: string[] | undefined
|
|
513
531
|
let content: string | null = null
|
|
514
532
|
try {
|
|
515
533
|
content = readFileSync(meetingPath, 'utf-8')
|
|
516
534
|
} catch {
|
|
517
|
-
// The capture's own scribe is gone. Only now is it worth
|
|
518
|
-
//
|
|
519
|
-
|
|
520
|
-
|
|
535
|
+
// The capture's own scribe is gone. Only now is it worth looking for
|
|
536
|
+
// the merged scribe that declares this session, in EVERY domain: the
|
|
537
|
+
// pipeline re-files a merge by its Fireflies call, so a capture filed
|
|
538
|
+
// under personal is routinely merged into a scribe under quilt.
|
|
539
|
+
const merged = resolveMergedScribe(operationsDir, domains, month, sidecarName, sessionId)
|
|
540
|
+
if (merged.status === 'resolved') {
|
|
541
|
+
resolvedDomain = merged.domain
|
|
542
|
+
resolvedMonth = merged.month
|
|
521
543
|
resolvedFilename = merged.filename
|
|
522
|
-
meetingPath =
|
|
544
|
+
meetingPath = merged.path
|
|
523
545
|
content = merged.content
|
|
546
|
+
} else if (merged.status === 'ambiguous') {
|
|
547
|
+
mergedScribeConflict = merged.claimants
|
|
524
548
|
}
|
|
525
549
|
}
|
|
526
550
|
let title = resolvedFilename.replace(/\.md$/, '')
|
|
@@ -530,9 +554,10 @@ export function findCosOperationsMeetingBySessionId(sessionId: string): {
|
|
|
530
554
|
sidecarPath,
|
|
531
555
|
meetingPath,
|
|
532
556
|
filename: resolvedFilename,
|
|
533
|
-
domain,
|
|
534
|
-
month,
|
|
557
|
+
domain: resolvedDomain,
|
|
558
|
+
month: resolvedMonth,
|
|
535
559
|
title,
|
|
560
|
+
...(mergedScribeConflict ? { mergedScribeConflict } : {}),
|
|
536
561
|
}
|
|
537
562
|
}
|
|
538
563
|
}
|
|
@@ -540,30 +565,188 @@ export function findCosOperationsMeetingBySessionId(sessionId: string): {
|
|
|
540
565
|
return null
|
|
541
566
|
}
|
|
542
567
|
|
|
568
|
+
// ── Merged-scribe resolution across domains (6.61.2) ────────────────────────
|
|
569
|
+
//
|
|
570
|
+
// 6.47.0 looked for the merged scribe only in the sidecar's own folder. On
|
|
571
|
+
// 2026-10-02, 65 of the 199 merged scribes on the author's Mac sat in a
|
|
572
|
+
// different DOMAIN folder from their capture (38 personal -> quilt, 11 personal
|
|
573
|
+
// -> hermit_crabs, 9 personal -> sprocket_rocket, 7 between hermit_crabs,
|
|
574
|
+
// sprocket_rocket and quilt), so those sessions resolved to the retired
|
|
575
|
+
// standalone filename: the speaker review named a record the meetings list does
|
|
576
|
+
// not show, and every correction either 409'd on `record_source_mismatch` or
|
|
577
|
+
// renamed the voice in the sidecar alone.
|
|
578
|
+
//
|
|
579
|
+
// IDENTITY IS THE MARKER. A scribe holds a session only if it carries
|
|
580
|
+
// `<!-- g2-session: <exact id> -->`. No filename, title or date is ever a match,
|
|
581
|
+
// and two scribes declaring one session resolve to neither.
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* iCloud conflict copies ("Chris_Miles_Sync 2.md", "x 2.g2-chunks.json").
|
|
585
|
+
* Never canonical, and a copy of a merged scribe carries the same markers as the
|
|
586
|
+
* original, so counting it would turn every conflict copy into a duplicate claim.
|
|
587
|
+
*/
|
|
588
|
+
const ICLOUD_CONFLICT_COPY = / \d+(\.[A-Za-z0-9-]+)*\.(md|json)$/
|
|
589
|
+
|
|
590
|
+
function shiftMonth(month: string, by: number): string {
|
|
591
|
+
const [year, monthNumber] = month.split('-').map(Number)
|
|
592
|
+
const index = year * 12 + (monthNumber - 1) + by
|
|
593
|
+
return `${String(Math.floor(index / 12)).padStart(4, '0')}-${String((index % 12) + 1).padStart(2, '0')}`
|
|
594
|
+
}
|
|
595
|
+
|
|
543
596
|
/**
|
|
544
|
-
* The
|
|
597
|
+
* The month folders a merged scribe for this capture can sit in.
|
|
545
598
|
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
599
|
+
* The capture's own month, always. Measured on 2026-10-02: all 199 merged
|
|
600
|
+
* scribes carry the capture's own date (0 of 199 a day off) and the nearest any
|
|
601
|
+
* capture came to a month edge was 7.5 hours. So a merge can land in another
|
|
602
|
+
* month only when the capture sits on a month's first or last day (a call that
|
|
603
|
+
* began before midnight and a capture that began after it, or the reverse), and
|
|
604
|
+
* those two days also search the neighbouring month. A sidecar whose name does
|
|
605
|
+
* not carry a date inside its own month searches both neighbours, because
|
|
606
|
+
* nothing says which way it leans. Never a `2026-09 2` folder: every name here
|
|
607
|
+
* is built, not listed.
|
|
548
608
|
*/
|
|
549
|
-
function
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
609
|
+
export function mergedScribeMonthsFor(sidecarMonth: string, sidecarFilename: string): string[] {
|
|
610
|
+
if (!MONTH_PATTERN.test(sidecarMonth)) return []
|
|
611
|
+
const date = /^(\d{4}-\d{2})-(\d{2})/.exec(sidecarFilename)
|
|
612
|
+
if (!date || date[1] !== sidecarMonth) {
|
|
613
|
+
return [sidecarMonth, shiftMonth(sidecarMonth, -1), shiftMonth(sidecarMonth, 1)]
|
|
614
|
+
}
|
|
615
|
+
const day = Number(date[2])
|
|
616
|
+
const [year, monthNumber] = sidecarMonth.split('-').map(Number)
|
|
617
|
+
const lastDay = new Date(Date.UTC(year, monthNumber, 0)).getUTCDate()
|
|
618
|
+
const months = [sidecarMonth]
|
|
619
|
+
if (day === 1) months.push(shiftMonth(sidecarMonth, -1))
|
|
620
|
+
if (day === lastDay) months.push(shiftMonth(sidecarMonth, 1))
|
|
621
|
+
return months
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// The cost guard. A cold lookup reads every scribe in the months above across
|
|
625
|
+
// every domain (about 300 files and 11 MB for a busy month on the author's Mac);
|
|
626
|
+
// a warm one stats them and reads nothing. A month's listing is reused while the
|
|
627
|
+
// folder's own stamp is unchanged, and a scribe's markers while ITS stamp is,
|
|
628
|
+
// so an in-place edit that leaves the folder's mtime alone is still seen.
|
|
629
|
+
interface ScribeClaimEntry { stamp: string; sessions: readonly string[] }
|
|
630
|
+
interface MonthListingEntry { stamp: string; names: string[] }
|
|
631
|
+
const scribeClaimCache = new Map<string, ScribeClaimEntry>()
|
|
632
|
+
const monthListingCache = new Map<string, MonthListingEntry>()
|
|
633
|
+
let scribeClaimReads = 0
|
|
634
|
+
|
|
635
|
+
function statStamp(stat: { ino: number; size: number; mtimeMs: number; ctimeMs: number }): string {
|
|
636
|
+
return `${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Every canonical scribe in one month folder that declares at least one session. */
|
|
640
|
+
function scribeClaimsIn(monthDir: string): Array<{ filename: string; sessions: readonly string[] }> {
|
|
641
|
+
let dirStamp: string
|
|
554
642
|
try {
|
|
555
|
-
|
|
643
|
+
dirStamp = statStamp(statSync(monthDir))
|
|
556
644
|
} catch {
|
|
557
|
-
|
|
645
|
+
for (const name of monthListingCache.get(monthDir)?.names ?? []) scribeClaimCache.delete(join(monthDir, name))
|
|
646
|
+
monthListingCache.delete(monthDir)
|
|
647
|
+
return []
|
|
558
648
|
}
|
|
559
|
-
|
|
560
|
-
|
|
649
|
+
let listing = monthListingCache.get(monthDir)
|
|
650
|
+
if (!listing || listing.stamp !== dirStamp) {
|
|
651
|
+
let names: string[]
|
|
561
652
|
try {
|
|
562
|
-
|
|
653
|
+
names = readdirSync(monthDir)
|
|
654
|
+
.filter(name => name.endsWith('.md') && !ICLOUD_CONFLICT_COPY.test(name))
|
|
655
|
+
.sort()
|
|
656
|
+
.slice(0, MAX_LIST_CANDIDATES)
|
|
657
|
+
} catch {
|
|
658
|
+
return []
|
|
659
|
+
}
|
|
660
|
+
const kept = new Set(names)
|
|
661
|
+
for (const old of listing?.names ?? []) if (!kept.has(old)) scribeClaimCache.delete(join(monthDir, old))
|
|
662
|
+
listing = { stamp: dirStamp, names }
|
|
663
|
+
monthListingCache.set(monthDir, listing)
|
|
664
|
+
}
|
|
665
|
+
const claims: Array<{ filename: string; sessions: readonly string[] }> = []
|
|
666
|
+
for (const filename of listing.names) {
|
|
667
|
+
const path = join(monthDir, filename)
|
|
668
|
+
let stat: ReturnType<typeof statSync>
|
|
669
|
+
try {
|
|
670
|
+
stat = statSync(path)
|
|
563
671
|
} catch { continue }
|
|
564
|
-
|
|
672
|
+
// A FIFO or a folder named *.md is never read: a FIFO would block the server.
|
|
673
|
+
if (!stat.isFile()) continue
|
|
674
|
+
const stamp = statStamp(stat)
|
|
675
|
+
let entry = scribeClaimCache.get(path)
|
|
676
|
+
if (!entry || entry.stamp !== stamp) {
|
|
677
|
+
let content: string
|
|
678
|
+
try {
|
|
679
|
+
content = readFileSync(path, 'utf-8')
|
|
680
|
+
} catch { continue }
|
|
681
|
+
scribeClaimReads++
|
|
682
|
+
entry = { stamp, sessions: mergedScribeSessions(content) }
|
|
683
|
+
scribeClaimCache.set(path, entry)
|
|
684
|
+
}
|
|
685
|
+
if (entry.sessions.length > 0) claims.push({ filename, sessions: entry.sessions })
|
|
565
686
|
}
|
|
566
|
-
return
|
|
687
|
+
return claims
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** How much work the marker cache has done. Diagnostics and the cost tests read it. */
|
|
691
|
+
export function mergedScribeCacheStats(): { reads: number; scribes: number; months: number } {
|
|
692
|
+
return { reads: scribeClaimReads, scribes: scribeClaimCache.size, months: monthListingCache.size }
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Drop the marker cache. Tests use it to measure a cold lookup. */
|
|
696
|
+
export function resetMergedScribeCache(): void {
|
|
697
|
+
scribeClaimCache.clear()
|
|
698
|
+
monthListingCache.clear()
|
|
699
|
+
scribeClaimReads = 0
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
export type MergedScribeResolution =
|
|
703
|
+
| { status: 'resolved'; domain: string; month: string; filename: string; path: string; content: string }
|
|
704
|
+
| { status: 'none' }
|
|
705
|
+
| { status: 'ambiguous'; claimants: string[] }
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* The ONE scribe, in any domain, that declares `sessionId` through
|
|
709
|
+
* `<!-- g2-session -->`. Two or more is `ambiguous` and resolves to none of them.
|
|
710
|
+
*
|
|
711
|
+
* `read` is the final read of the chosen scribe. Injectable so a test can stand
|
|
712
|
+
* in for a scribe rewritten between the scan and this read.
|
|
713
|
+
*/
|
|
714
|
+
export function resolveMergedScribe(
|
|
715
|
+
operationsDir: string,
|
|
716
|
+
domains: readonly string[],
|
|
717
|
+
sidecarMonth: string,
|
|
718
|
+
sidecarFilename: string,
|
|
719
|
+
sessionId: string,
|
|
720
|
+
read: (path: string) => string = path => readFileSync(path, 'utf-8'),
|
|
721
|
+
): MergedScribeResolution {
|
|
722
|
+
const months = mergedScribeMonthsFor(sidecarMonth, sidecarFilename)
|
|
723
|
+
const found: Array<{ domain: string; month: string; filename: string }> = []
|
|
724
|
+
for (const domain of domains) {
|
|
725
|
+
for (const month of months) {
|
|
726
|
+
for (const claim of scribeClaimsIn(join(operationsDir, domain, 'meetings', month))) {
|
|
727
|
+
if (claim.sessions.includes(sessionId)) found.push({ domain, month, filename: claim.filename })
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
if (found.length === 0) return { status: 'none' }
|
|
732
|
+
if (found.length > 1) {
|
|
733
|
+
const claimants = found.map(f => `${f.domain}/meetings/${f.month}/${f.filename}`)
|
|
734
|
+
// Once per lookup, and lookups happen on a person's action (opening the
|
|
735
|
+
// speaker panel, a correction), never in a loop.
|
|
736
|
+
console.warn(`[meetings] merged_scribe_ambiguous: ${claimants.length} meetings declare session ${sessionId} (${claimants.join(', ')}); using the capture alone until one is fixed`)
|
|
737
|
+
return { status: 'ambiguous', claimants }
|
|
738
|
+
}
|
|
739
|
+
const only = found[0]
|
|
740
|
+
const path = join(operationsDir, only.domain, 'meetings', only.month, only.filename)
|
|
741
|
+
let content: string
|
|
742
|
+
try {
|
|
743
|
+
content = read(path)
|
|
744
|
+
} catch {
|
|
745
|
+
return { status: 'none' }
|
|
746
|
+
}
|
|
747
|
+
// The cache is a hint; the file just read is the identity.
|
|
748
|
+
if (!mergedScribeSessions(content).includes(sessionId)) return { status: 'none' }
|
|
749
|
+
return { status: 'resolved', ...only, path, content }
|
|
567
750
|
}
|
|
568
751
|
|
|
569
752
|
export type MeetingLibraryRecord = NonNullable<ReturnType<typeof findCosOperationsMeetingBySessionId>> & {
|
|
@@ -188,6 +188,9 @@ function resolveCopies(sessionId:string):ResolvedCopy[] {
|
|
|
188
188
|
if (operations) {
|
|
189
189
|
const found = findCosOperationsMeetingBySessionId(sessionId)
|
|
190
190
|
if (!found) throw new Error('Operations meeting copy is missing; retry after sync')
|
|
191
|
+
// 6.61.2: two meetings declare this session. Fail closed rather than let the
|
|
192
|
+
// sidecar's own blended_into pointer below pick one of them.
|
|
193
|
+
if (found.mergedScribeConflict) throw new Error(`${found.mergedScribeConflict.length} meetings declare this session; fix the duplicate before naming`)
|
|
191
194
|
paths.push({copy:'operations',sidecarPath:found.sidecarPath,meetingPath:found.meetingPath})
|
|
192
195
|
} else if (process.env.COS_OPERATIONS_DIR?.trim() || process.env.COS_SCRIPTS_DIR?.trim()) {
|
|
193
196
|
// An explicitly configured but unavailable iCloud root is not standalone.
|
|
@@ -41,6 +41,8 @@ import {
|
|
|
41
41
|
prepareNemotronModelsDir,
|
|
42
42
|
} from './diarizer-backend.js'
|
|
43
43
|
import { CONFIDENT_SIMILARITY, isUnattributed } from './meeting-speaker-review.js'
|
|
44
|
+
import { getOwnerSpeakerLabel } from './profile.js'
|
|
45
|
+
import { OWNER_VERIFY_SIMILARITY } from './voiceprint-floor.js'
|
|
44
46
|
import {
|
|
45
47
|
NEMOTRON_TMP_PREFIX,
|
|
46
48
|
NemotronRunError,
|
|
@@ -265,6 +267,10 @@ export function crowdIdentities(windows: TimelineWindow[]): number {
|
|
|
265
267
|
return [...counts.values()].filter(count => count >= FINAL_MIN_SUPPORT).length
|
|
266
268
|
}
|
|
267
269
|
|
|
270
|
+
function ownerLabelOrNull(): string | null {
|
|
271
|
+
try { return getOwnerSpeakerLabel() } catch { return null }
|
|
272
|
+
}
|
|
273
|
+
|
|
268
274
|
function isName(label: unknown): label is string {
|
|
269
275
|
return typeof label === 'string' && !isUnattributed(label.trim())
|
|
270
276
|
}
|
|
@@ -332,7 +338,21 @@ export function mapChannelsToNames(windows: TimelineWindow[], a: Activity, optio
|
|
|
332
338
|
}
|
|
333
339
|
|
|
334
340
|
/** The live segment that held chunk-relative time t, else null. */
|
|
335
|
-
|
|
341
|
+
/**
|
|
342
|
+
* 6.61.1 owner guard for per-chunk names. 6.61.0 named split tracks as the wearer
|
|
343
|
+
* from the glasses' default label (similarity 0) or a weak search match
|
|
344
|
+
* (0.55 to 0.65). In a chunk with more than one live turn, the wearer's name
|
|
345
|
+
* counts only at owner-verify confidence; otherwise it is Ext. A one-turn chunk
|
|
346
|
+
* keeps its label exactly as 6.60.0 gave it.
|
|
347
|
+
*/
|
|
348
|
+
export function guardOwnerName(name: string, similarity: unknown, chunk: SidecarChunk, owner: string | null | undefined): string {
|
|
349
|
+
if (!owner || name !== owner) return name
|
|
350
|
+
const split = Array.isArray(chunk.segments) && chunk.segments.length > 1
|
|
351
|
+
if (!split) return name
|
|
352
|
+
return Number(similarity) >= OWNER_VERIFY_SIMILARITY ? name : 'Ext'
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function liveSpeakerAt(chunk: SidecarChunk, t: number, owner?: string | null): string | null {
|
|
336
356
|
const segments = chunk.segments
|
|
337
357
|
if (!Array.isArray(segments) || segments.length === 0) return null
|
|
338
358
|
let best: ChunkSpeakerSegment | null = null
|
|
@@ -344,7 +364,7 @@ function liveSpeakerAt(chunk: SidecarChunk, t: number): string | null {
|
|
|
344
364
|
const d = t < s ? s - t : t > e ? t - e : 0
|
|
345
365
|
if (d < distance) { distance = d; best = segment }
|
|
346
366
|
}
|
|
347
|
-
return best && typeof best.speaker === 'string' ? best.speaker : null
|
|
367
|
+
return best && typeof best.speaker === 'string' ? guardOwnerName(best.speaker, best.similarity, chunk, owner) : null
|
|
348
368
|
}
|
|
349
369
|
|
|
350
370
|
export interface RelabelStats {
|
|
@@ -383,6 +403,7 @@ export function relabelBatchSegments(
|
|
|
383
403
|
timeline: MeetingTimeline,
|
|
384
404
|
a: Activity,
|
|
385
405
|
map: Map<number, string>,
|
|
406
|
+
owner?: string | null,
|
|
386
407
|
): { segments: SidecarBatchSegment[]; stats: RelabelStats } {
|
|
387
408
|
const stats: RelabelStats = { total: 0, relabeled: 0, viaChannel: 0, kept: 0 }
|
|
388
409
|
const out = segments.map(segment => ({
|
|
@@ -399,7 +420,8 @@ export function relabelBatchSegments(
|
|
|
399
420
|
}
|
|
400
421
|
if (name === null) {
|
|
401
422
|
stats.kept++
|
|
402
|
-
name = (start && liveSpeakerAt(start.window.chunk, start.inChunk))
|
|
423
|
+
name = (start && liveSpeakerAt(start.window.chunk, start.inChunk, owner))
|
|
424
|
+
?? (start ? guardOwnerName(original, start.window.chunk.similarity, start.window.chunk, owner) : original)
|
|
403
425
|
}
|
|
404
426
|
if (name !== word.speaker) stats.relabeled++
|
|
405
427
|
const next: SpeakerWord = { ...word, speaker: name }
|
|
@@ -441,6 +463,7 @@ export function relabelStreamingChunks(
|
|
|
441
463
|
timeline: MeetingTimeline,
|
|
442
464
|
a: Activity,
|
|
443
465
|
map: Map<number, string>,
|
|
466
|
+
owner?: string | null,
|
|
444
467
|
): { finals: Map<number, { speaker: string; segments: ChunkSpeakerSegment[] }>; stats: RelabelStats } {
|
|
445
468
|
const stats: RelabelStats = { total: 0, relabeled: 0, viaChannel: 0, kept: 0 }
|
|
446
469
|
const finals = new Map<number, { speaker: string; segments: ChunkSpeakerSegment[] }>()
|
|
@@ -449,17 +472,17 @@ export function relabelStreamingChunks(
|
|
|
449
472
|
if (typeof chunk.text !== 'string' || !chunk.text.trim()) continue
|
|
450
473
|
const tokens = splitTokens(chunk.text)
|
|
451
474
|
const times = streamingTokenTimes(chunk, tokens, window, a)
|
|
452
|
-
const base = typeof chunk.originalSpeaker === 'string' ? chunk.originalSpeaker : chunk.speaker
|
|
475
|
+
const base = guardOwnerName(typeof chunk.originalSpeaker === 'string' ? chunk.originalSpeaker : chunk.speaker, chunk.similarity, chunk, owner)
|
|
453
476
|
const labels: string[] = []
|
|
454
477
|
let carry: string | null = null
|
|
455
478
|
for (let i = 0; i < tokens.length; i++) {
|
|
456
|
-
if (!isWordToken(tokens[i]) || !times) { labels.push(carry ?? liveSpeakerAt(chunk, 0) ?? base); continue }
|
|
479
|
+
if (!isWordToken(tokens[i]) || !times) { labels.push(carry ?? liveSpeakerAt(chunk, 0, owner) ?? base); continue }
|
|
457
480
|
stats.total++
|
|
458
481
|
const span = times[i]
|
|
459
482
|
const channel = channelForSpan(a, window.start + span.start, window.start + Math.max(span.end, span.start + 0.01))
|
|
460
483
|
let name: string
|
|
461
484
|
if (channel !== null && map.has(channel)) { name = map.get(channel)!; stats.viaChannel++ }
|
|
462
|
-
else { name = liveSpeakerAt(chunk, span.start) ?? base; stats.kept++ }
|
|
485
|
+
else { name = liveSpeakerAt(chunk, span.start, owner) ?? base; stats.kept++ }
|
|
463
486
|
labels.push(name)
|
|
464
487
|
carry = name
|
|
465
488
|
}
|
|
@@ -646,6 +669,8 @@ export interface FinalPassOptions {
|
|
|
646
669
|
tmpRoot?: string
|
|
647
670
|
log?: (line: string) => void
|
|
648
671
|
now?: () => number
|
|
672
|
+
/** The wearer's label, for the 6.61.1 owner guard (default: the profile's). */
|
|
673
|
+
owner?: string | null
|
|
649
674
|
/** Seam: how files are written (durable atomic by default). */
|
|
650
675
|
write?: (path: string, data: string) => void
|
|
651
676
|
/** Seam: the batch relabeller (word-aligned in place by default). */
|
|
@@ -758,6 +783,7 @@ async function finalPassNow(options: FinalPassOptions): Promise<FinalPassOutcome
|
|
|
758
783
|
|
|
759
784
|
const sidecarText = readFileSync(options.sidecarPath, 'utf8')
|
|
760
785
|
const sidecar = JSON.parse(sidecarText) as Record<string, any>
|
|
786
|
+
const owner = options.owner !== undefined ? options.owner : ownerLabelOrNull()
|
|
761
787
|
if (Number(sidecar.correctionRevision ?? 0) > 0) return { status: 'skipped', reason: 'human_corrected' }
|
|
762
788
|
const markdown = readFileSync(options.meetingPath, 'utf8')
|
|
763
789
|
const transcript = readTranscriptSection(markdown)
|
|
@@ -833,7 +859,7 @@ async function finalPassNow(options: FinalPassOptions): Promise<FinalPassOutcome
|
|
|
833
859
|
mode = 'batch'
|
|
834
860
|
const segments = (Array.isArray(sidecar.batchSegments) ? sidecar.batchSegments : []) as SidecarBatchSegment[]
|
|
835
861
|
if (!segments.length) return { status: 'skipped', reason: 'no_speaker_words' }
|
|
836
|
-
const relabeled = relabelBatchSegments(segments, timeline, activity, mapping.map)
|
|
862
|
+
const relabeled = relabelBatchSegments(segments, timeline, activity, mapping.map, owner)
|
|
837
863
|
stats = relabeled.stats
|
|
838
864
|
// finalizeBatch wrote labelled turns only when every segment had speaker words.
|
|
839
865
|
const selected = selectBatchTranscriptForPersistence(
|
|
@@ -850,7 +876,7 @@ async function finalPassNow(options: FinalPassOptions): Promise<FinalPassOutcome
|
|
|
850
876
|
if (typeof sidecar.batchTranscript === 'string') nextSidecar.batchTranscript = next
|
|
851
877
|
} else {
|
|
852
878
|
mode = 'streaming'
|
|
853
|
-
const relabeled = relabelStreamingChunks(timeline, activity, mapping.map)
|
|
879
|
+
const relabeled = relabelStreamingChunks(timeline, activity, mapping.map, owner)
|
|
854
880
|
stats = relabeled.stats
|
|
855
881
|
const textEntries = entries.filter(entry => typeof entry.chunk?.text === 'string' && entry.chunk.text.trim())
|
|
856
882
|
next = relabelStreamingTranscript(transcript, textEntries.map(entry => ({ chunkIndex: entry.chunkIndex, text: entry.chunk.text })), relabeled.finals)
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
type TokenTime,
|
|
56
56
|
} from './nemotron-segments.js'
|
|
57
57
|
import type { WhisperWord } from './whisper-local.js'
|
|
58
|
+
import { OWNER_VERIFY_SIMILARITY } from './voiceprint-floor.js'
|
|
58
59
|
|
|
59
60
|
/** The chunk response contract shared with COS Glasses 6.9.567. Keep this shape. */
|
|
60
61
|
export interface ChunkSpeakerSegment {
|
|
@@ -78,8 +79,10 @@ export interface LiveNamingInput {
|
|
|
78
79
|
audio: Buffer
|
|
79
80
|
/** Today's whole-chunk voiceprint result. */
|
|
80
81
|
chunkVoiceprint: VoiceprintResult
|
|
81
|
-
/** The client's own label (amplitude / Even Hub), e.g. the owner or Ext. */
|
|
82
|
+
/** The client's own label (amplitude / Even Hub), e.g. the owner or Ext. Never names a split track. */
|
|
82
83
|
clientSpeaker: string
|
|
84
|
+
/** The wearer's label (`owner_speaker_label`), for the owner guard. */
|
|
85
|
+
owner?: string | null
|
|
83
86
|
identify: (wav: Buffer) => VoiceprintResult | null
|
|
84
87
|
}
|
|
85
88
|
|
|
@@ -92,6 +95,8 @@ export type LiveNamingResult =
|
|
|
92
95
|
tracks: number
|
|
93
96
|
timing: 'words' | 'estimated'
|
|
94
97
|
identified: number
|
|
98
|
+
/** Would-be wearer names refused in this chunk, by reason. */
|
|
99
|
+
ownerGuard: { client_label: number; weak_owner: number }
|
|
95
100
|
}
|
|
96
101
|
| { ok: false; reason: 'no_text' | 'no_speech' }
|
|
97
102
|
|
|
@@ -105,14 +110,19 @@ function isRealLabel(label: string): boolean {
|
|
|
105
110
|
/**
|
|
106
111
|
* Split one chunk's text into named segments. Pure apart from `identify`.
|
|
107
112
|
*
|
|
108
|
-
* Naming rules (2026-10-02 brief):
|
|
109
|
-
* - one track: it is the whole chunk, so it takes the chunk's voiceprint result
|
|
113
|
+
* Naming rules (2026-10-02 brief, 6.61.1 owner guard):
|
|
114
|
+
* - one track: it is the whole chunk, so it takes the chunk's voiceprint result,
|
|
115
|
+
* exactly as 6.60.0 labelled the chunk (including the client's label when no
|
|
116
|
+
* voiceprint could run);
|
|
110
117
|
* - two or more: each track with at least NAME_TRACK_MIN_SEC of its own audio is
|
|
111
|
-
* named by the voiceprint on that audio;
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
118
|
+
* named by the voiceprint on that audio; a shorter dominant track takes the
|
|
119
|
+
* chunk's voiceprint; any other short track is Ext;
|
|
120
|
+
* - in a split chunk the WEARER is named only at owner-verify confidence
|
|
121
|
+
* (OWNER_VERIFY_SIMILARITY). A full search can return the wearer from 0.55,
|
|
122
|
+
* and the client's label is the glasses' default wearer label, not identity
|
|
123
|
+
* evidence: on 2026-10-02 both named other people (Jeremy, Kyle) as the
|
|
124
|
+
* wearer. Either becomes Ext and is counted (`ownerGuard`).
|
|
125
|
+
* A name is never invented.
|
|
116
126
|
* The segments' texts joined with one space equal `text` exactly.
|
|
117
127
|
*/
|
|
118
128
|
export function nameChunkSegments(input: LiveNamingInput): LiveNamingResult {
|
|
@@ -139,6 +149,7 @@ export function nameChunkSegments(input: LiveNamingInput): LiveNamingResult {
|
|
|
139
149
|
const used = new Set(labels)
|
|
140
150
|
|
|
141
151
|
const names = new Map<number, VoiceprintResult>()
|
|
152
|
+
const ownerGuard = { client_label: 0, weak_owner: 0 }
|
|
142
153
|
let identified = 0
|
|
143
154
|
const pcm = readPcm16Mono(input.audio)
|
|
144
155
|
const ordered = tracks.filter(track => used.has(track.channel))
|
|
@@ -146,16 +157,25 @@ export function nameChunkSegments(input: LiveNamingInput): LiveNamingResult {
|
|
|
146
157
|
if (ordered.length === 1) {
|
|
147
158
|
names.set(ordered[0].channel, input.chunkVoiceprint)
|
|
148
159
|
} else {
|
|
160
|
+
const owner = input.owner?.trim() || null
|
|
161
|
+
const ownerSafe = (named: VoiceprintResult): VoiceprintResult => {
|
|
162
|
+
if (!owner || named.speaker !== owner || named.similarity >= OWNER_VERIFY_SIMILARITY) return named
|
|
163
|
+
ownerGuard[named.similarity > 0 ? 'weak_owner' : 'client_label']++
|
|
164
|
+
return { speaker: 'Ext', similarity: 0 }
|
|
165
|
+
}
|
|
149
166
|
for (const track of ordered) {
|
|
150
167
|
if (track.exclusiveSec >= NAME_TRACK_MIN_SEC && pcm) {
|
|
151
168
|
const wav = exclusiveTrackWav(activity, track.channel, pcm)
|
|
152
169
|
const named = wav ? input.identify(wav) : null
|
|
153
|
-
if (named) { names.set(track.channel, named); identified++; continue }
|
|
170
|
+
if (named) { names.set(track.channel, ownerSafe(named)); identified++; continue }
|
|
154
171
|
}
|
|
155
|
-
if (track.channel === dominant) { names.set(track.channel, input.chunkVoiceprint); continue }
|
|
172
|
+
if (track.channel === dominant) { names.set(track.channel, ownerSafe(input.chunkVoiceprint)); continue }
|
|
173
|
+
// The client's label is the glasses' default wearer label, not evidence of
|
|
174
|
+
// who this voice is (6.61.0 named Jeremy Sokolic as MU through it).
|
|
156
175
|
const dominantName = names.get(dominant)?.speaker ?? input.chunkVoiceprint.speaker
|
|
157
176
|
const client = input.clientSpeaker.trim()
|
|
158
|
-
|
|
177
|
+
if (isRealLabel(client) && client !== dominantName) ownerGuard.client_label++
|
|
178
|
+
names.set(track.channel, { speaker: 'Ext', similarity: 0 })
|
|
159
179
|
}
|
|
160
180
|
}
|
|
161
181
|
|
|
@@ -196,7 +216,7 @@ export function nameChunkSegments(input: LiveNamingInput): LiveNamingResult {
|
|
|
196
216
|
let best = -1
|
|
197
217
|
for (const [name, sec] of voiced) if (sec > best) { best = sec; speaker = name }
|
|
198
218
|
const similarity = [...names.values()].find(named => named.speaker === speaker)?.similarity ?? 0
|
|
199
|
-
return { ok: true, speaker, similarity: round(similarity, 3), segments, tracks: tracks.length, timing, identified }
|
|
219
|
+
return { ok: true, speaker, similarity: round(similarity, 3), segments, tracks: tracks.length, timing, identified, ownerGuard }
|
|
200
220
|
}
|
|
201
221
|
|
|
202
222
|
/**
|
|
@@ -244,6 +264,8 @@ export interface LiveLabelInput {
|
|
|
244
264
|
audio: Buffer
|
|
245
265
|
voiceprint: VoiceprintResult
|
|
246
266
|
clientSpeaker: string
|
|
267
|
+
/** The wearer's label, for the owner guard on split chunks. */
|
|
268
|
+
owner?: string | null
|
|
247
269
|
identify: (wav: Buffer) => VoiceprintResult | null
|
|
248
270
|
clock?: () => number
|
|
249
271
|
}
|
|
@@ -260,10 +282,12 @@ export interface LiveLabels {
|
|
|
260
282
|
tracks?: number
|
|
261
283
|
timing?: 'words' | 'estimated'
|
|
262
284
|
ms?: number
|
|
285
|
+
/** 6.61.1: would-be wearer names refused in this chunk (client_label, weak_owner). */
|
|
286
|
+
ownerGuard?: string[]
|
|
263
287
|
}
|
|
264
288
|
/** What NemotronLiveRuntime.record should log; absent when the voiceprint was chosen by config. */
|
|
265
289
|
record?:
|
|
266
|
-
| { outcome: 'nemotron'; ms: number; segments: number; tracks: number; timing: string; identified: number }
|
|
290
|
+
| { outcome: 'nemotron'; ms: number; segments: number; tracks: number; timing: string; identified: number; ownerGuard?: { client_label: number; weak_owner: number } }
|
|
267
291
|
| { outcome: 'voiceprint'; reason: string; ms: number | null; detail?: string }
|
|
268
292
|
}
|
|
269
293
|
|
|
@@ -306,10 +330,15 @@ export async function resolveLiveLabels(input: LiveLabelInput): Promise<LiveLabe
|
|
|
306
330
|
audio: input.audio,
|
|
307
331
|
chunkVoiceprint: input.voiceprint,
|
|
308
332
|
clientSpeaker: input.clientSpeaker,
|
|
333
|
+
owner: input.owner,
|
|
309
334
|
identify: input.identify,
|
|
310
335
|
})
|
|
311
336
|
if (!named.ok) return voiceprintOnly(named.reason, ms)
|
|
312
337
|
const changed = named.speaker !== input.voiceprint.speaker
|
|
338
|
+
const guarded = [
|
|
339
|
+
...Array(named.ownerGuard.client_label).fill('client_label'),
|
|
340
|
+
...Array(named.ownerGuard.weak_owner).fill('weak_owner'),
|
|
341
|
+
] as string[]
|
|
313
342
|
return {
|
|
314
343
|
speaker: named.speaker,
|
|
315
344
|
similarity: named.similarity,
|
|
@@ -320,8 +349,12 @@ export async function resolveLiveLabels(input: LiveLabelInput): Promise<LiveLabe
|
|
|
320
349
|
timing: named.timing,
|
|
321
350
|
ms,
|
|
322
351
|
...(changed ? { voiceprintSpeaker: input.voiceprint.speaker, voiceprintSimilarity: input.voiceprint.similarity } : {}),
|
|
352
|
+
...(guarded.length ? { ownerGuard: guarded } : {}),
|
|
353
|
+
},
|
|
354
|
+
record: {
|
|
355
|
+
outcome: 'nemotron', ms, segments: named.segments.length, tracks: named.tracks, timing: named.timing, identified: named.identified,
|
|
356
|
+
...(guarded.length ? { ownerGuard: named.ownerGuard } : {}),
|
|
323
357
|
},
|
|
324
|
-
record: { outcome: 'nemotron', ms, segments: named.segments.length, tracks: named.tracks, timing: named.timing, identified: named.identified },
|
|
325
358
|
}
|
|
326
359
|
} catch (error) {
|
|
327
360
|
return voiceprintOnly('error', ms, error instanceof Error ? error.message : String(error))
|
|
@@ -414,6 +447,8 @@ export interface LiveSnapshot {
|
|
|
414
447
|
/** Whether the CLI binary is the v0.17.4 build the offline guarantee was verified on. */
|
|
415
448
|
cliKnown: boolean | null
|
|
416
449
|
chunks: { nemotron: number; voiceprint: number; reasons: Record<string, number> }
|
|
450
|
+
/** 6.61.1: would-be wearer names refused on split chunks since start. */
|
|
451
|
+
ownerGuard: { client_label: number; weak_owner: number }
|
|
417
452
|
last: { at: string; outcome: 'nemotron' | 'voiceprint'; reason: string | null; ms: number | null } | null
|
|
418
453
|
}
|
|
419
454
|
|
|
@@ -440,6 +475,7 @@ export class NemotronLiveRuntime {
|
|
|
440
475
|
private modelsDir: string | null = null
|
|
441
476
|
private identity: { path: string; sha256: string; known: boolean } | null = null
|
|
442
477
|
private counts = { nemotron: 0, voiceprint: 0, reasons: {} as Record<string, number> }
|
|
478
|
+
private ownerGuard = { client_label: 0, weak_owner: 0 }
|
|
443
479
|
private recent: Array<{ outcome: 'nemotron' | 'voiceprint'; reason: string | null }> = []
|
|
444
480
|
private last: LiveSnapshot['last'] = null
|
|
445
481
|
private lastStateLog: string | null = null
|
|
@@ -605,7 +641,7 @@ export class NemotronLiveRuntime {
|
|
|
605
641
|
|
|
606
642
|
/** Record one chunk's outcome. Run failures are logged per chunk; state reasons once per change. */
|
|
607
643
|
record(chunk: { sessionId: string; chunkIndex: number }, outcome:
|
|
608
|
-
| { outcome: 'nemotron'; ms: number; segments: number; tracks: number; timing: string; identified: number }
|
|
644
|
+
| { outcome: 'nemotron'; ms: number; segments: number; tracks: number; timing: string; identified: number; ownerGuard?: { client_label: number; weak_owner: number } }
|
|
609
645
|
| { outcome: 'voiceprint'; reason: string; ms: number | null; detail?: string },
|
|
610
646
|
): void {
|
|
611
647
|
const at = new Date(this.now()).toISOString()
|
|
@@ -615,6 +651,12 @@ export class NemotronLiveRuntime {
|
|
|
615
651
|
this.pushRecent('nemotron', null)
|
|
616
652
|
this.lastStateLog = null
|
|
617
653
|
this.log(`[diarizer] chunk #${chunk.chunkIndex} nemotron ${outcome.ms} ms tracks=${outcome.tracks} segments=${outcome.segments} timing=${outcome.timing} named=${outcome.identified}`)
|
|
654
|
+
const guard = outcome.ownerGuard
|
|
655
|
+
if (guard && guard.client_label + guard.weak_owner > 0) {
|
|
656
|
+
this.ownerGuard.client_label += guard.client_label
|
|
657
|
+
this.ownerGuard.weak_owner += guard.weak_owner
|
|
658
|
+
this.log(`[diarizer] owner-guard chunk #${chunk.chunkIndex} refused=${guard.client_label + guard.weak_owner} client_label=${guard.client_label} weak_owner=${guard.weak_owner}`)
|
|
659
|
+
}
|
|
618
660
|
return
|
|
619
661
|
}
|
|
620
662
|
this.counts.voiceprint++
|
|
@@ -676,6 +718,7 @@ export class NemotronLiveRuntime {
|
|
|
676
718
|
warm: { state: this.state, ms: this.warmMs, loadSec: this.warmLoadSec, error: this.warmError },
|
|
677
719
|
cliKnown: this.identity ? this.identity.known : null,
|
|
678
720
|
chunks: { nemotron: this.counts.nemotron, voiceprint: this.counts.voiceprint, reasons: { ...this.counts.reasons } },
|
|
721
|
+
ownerGuard: { ...this.ownerGuard },
|
|
679
722
|
last: this.last,
|
|
680
723
|
}
|
|
681
724
|
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// exactly as if no hook existed. Every path here that is not an answer ends in `{}`.
|
|
17
17
|
//
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
19
|
-
// WHAT GETS PARKED (Miles's Q3 and Q4, 2026-09-19): away AND
|
|
19
|
+
// WHAT GETS PARKED (Miles's Q3 and Q4, 2026-09-19): away AND recently present
|
|
20
20
|
// ---------------------------------------------------------------------------
|
|
21
21
|
// The hook has no matcher, so every permission request at an idle desk reaches the route.
|
|
22
22
|
// Everything outside the gate answers `{}` at once (target: under 100 ms). A request is
|
|
@@ -67,10 +67,12 @@
|
|
|
67
67
|
// 2026-09-30 19:54: "Hold only if the glasses or phone were connected in the last 30
|
|
68
68
|
// min. A Claude Remote Control session with no glasses gets its question right away,
|
|
69
69
|
// as before."). The contract shared with COS Glasses 6.9.563 (lib/client-liveness): a
|
|
70
|
-
// poll `?client=glasses|phone&hold=1&presenceAgeMs=<int>`, the ms since a ring or
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
70
|
+
// poll `?client=glasses|phone&hold=1&presenceAgeMs=<int>`, the ms since a ring, temple or
|
|
71
|
+
// lens gesture, or a phone touch while COS is in front. The worn status is never presence
|
|
72
|
+
// (Glasses 6.9.570, Miles 2026-10-02: wear detection can be turned off, and then it always
|
|
73
|
+
// reads not worn). Recent means within `COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S`
|
|
74
|
+
// (default 1800; below 30 s, the 30 s live window). A connected lens nobody touches sends
|
|
75
|
+
// no presence; an older client sends
|
|
74
76
|
// no `hold=1` and keeps the 6.59.0 liveness, never the away hold. In memory: after a
|
|
75
77
|
// restart nothing is recent until such a poll.
|
|
76
78
|
// Held that way, a request stays answerable while no client is polling, so a lens or phone
|
|
@@ -1057,7 +1059,7 @@ export class PermissionBroker {
|
|
|
1057
1059
|
* - `recent`: within the window, so the away hold may apply;
|
|
1058
1060
|
* - `holdLive`: a `hold=1` client polled within the live window AND its presence is
|
|
1059
1061
|
* recent, so it counts as a live client the way an older client's poll does. A
|
|
1060
|
-
* connected lens nobody
|
|
1062
|
+
* connected lens nobody touches never counts (QA round 1, Skeptic W1).
|
|
1061
1063
|
*/
|
|
1062
1064
|
private presence(now: number): { ageMs: number | null; recent: boolean; holdLive: boolean; windowMs: number } {
|
|
1063
1065
|
let ageMs: number | null = null
|
|
@@ -42,6 +42,7 @@ const require = createRequire(import.meta.url)
|
|
|
42
42
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
43
43
|
|
|
44
44
|
import { DATA_DIR } from './data-dir.js'
|
|
45
|
+
import { OWNER_VERIFY_SIMILARITY } from './voiceprint-floor.js'
|
|
45
46
|
|
|
46
47
|
export const SPEAKER_MODEL_FILENAME = '3dspeaker_speech_eres2net_sv_en_voxceleb_16k.onnx'
|
|
47
48
|
|
|
@@ -86,7 +87,7 @@ const PROFILES_PATH = resolve(DATA_DIR, 'voice-profiles.json')
|
|
|
86
87
|
const CALIBRATION_LOG = resolve(DATA_DIR, 'speaker-calibration.jsonl')
|
|
87
88
|
|
|
88
89
|
// Thresholds
|
|
89
|
-
const VERIFY_THRESHOLD =
|
|
90
|
+
const VERIFY_THRESHOLD = OWNER_VERIFY_SIMILARITY
|
|
90
91
|
const SEARCH_THRESHOLD = 0.55
|
|
91
92
|
/** The bar `autoEnroll` enrols at. From 6.45.4 also the bar at which a held
|
|
92
93
|
* group is offered as a person with one click ("high"): the identifier would
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
1
|
+
// Voiceprint floors shared by every path that names a voice, so they can never
|
|
2
|
+
// drift apart.
|
|
3
|
+
|
|
4
|
+
/** The least audio the voiceprint names a voice from: the whole-chunk check in
|
|
5
|
+
* transcribe-stream and the per-track check in Nemotron naming. */
|
|
4
6
|
export const VOICEPRINT_MIN_AUDIO_SEC = 2.0
|
|
7
|
+
|
|
8
|
+
/** The similarity at which the voiceprint verifies the WEARER (speaker-embeddings'
|
|
9
|
+
* owner verify). A full search accepts any profile from 0.55, so a voice can come
|
|
10
|
+
* back as the wearer between 0.55 and 0.65 without ever passing owner verify;
|
|
11
|
+
* 6.61.1 refuses that for a split track (it named other people as the wearer). */
|
|
12
|
+
export const OWNER_VERIFY_SIMILARITY = 0.65
|
package/server/routes/meeting.ts
CHANGED
|
@@ -171,6 +171,21 @@ function cosOpsPipelineConfigured(): boolean {
|
|
|
171
171
|
return Boolean(process.env.COS_SCRIPTS_DIR?.trim())
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Why a correction left the meeting's markdown alone when it could not read it.
|
|
176
|
+
*
|
|
177
|
+
* 6.61.2: when two meetings declare the same session the lookup chooses neither
|
|
178
|
+
* and the record falls back to the capture, whose own scribe the merge retired.
|
|
179
|
+
* "Unreadable" would send the reader looking for a missing file; the real
|
|
180
|
+
* answer is the duplicate claim, so the response names it.
|
|
181
|
+
*/
|
|
182
|
+
function markdownUnreadableReason(operations: { mergedScribeConflict?: string[] } | null): string {
|
|
183
|
+
const conflict = operations?.mergedScribeConflict
|
|
184
|
+
return conflict && conflict.length > 1
|
|
185
|
+
? `no merged meeting chosen: ${conflict.length} meetings declare this session (${conflict.join(', ')})`
|
|
186
|
+
: 'meeting markdown unreadable'
|
|
187
|
+
}
|
|
188
|
+
|
|
174
189
|
function requestedRecordMatches(requested: unknown, expected: string): boolean {
|
|
175
190
|
return requested == null || requested === '' || requested === expected
|
|
176
191
|
}
|
|
@@ -1331,7 +1346,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1331
1346
|
proseHits: md?.proseHits ?? [],
|
|
1332
1347
|
markdownSkipped: !plan.value.coveredAllWithLabel
|
|
1333
1348
|
? 'partial relabel: transcript turns cannot be mapped to chunk indices'
|
|
1334
|
-
: markdownRaw === null ?
|
|
1349
|
+
: markdownRaw === null ? markdownUnreadableReason(operations) : null,
|
|
1335
1350
|
}
|
|
1336
1351
|
|
|
1337
1352
|
if (req.body?.dryRun === true || req.body?.confirm !== true) {
|
|
@@ -1684,7 +1699,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1684
1699
|
// markdown changes with no hint the document was never touched.
|
|
1685
1700
|
markdownSkipped: !plan.value.coveredAllWithLabel
|
|
1686
1701
|
? 'partial de-attribution: transcript turns cannot be mapped to chunk indices'
|
|
1687
|
-
: markdownUnreadable ?
|
|
1702
|
+
: markdownUnreadable ? markdownUnreadableReason(operations) : null,
|
|
1688
1703
|
scope: 'meeting' as const,
|
|
1689
1704
|
chunks: plan.value.changed,
|
|
1690
1705
|
surfaces,
|
|
@@ -9,7 +9,7 @@ import { chmodSync, readFileSync, writeFileSync, existsSync, lstatSync, mkdirSyn
|
|
|
9
9
|
import { writeFile } from 'node:fs/promises'
|
|
10
10
|
import { resolve } from 'node:path'
|
|
11
11
|
import { fileURLToPath } from 'node:url'
|
|
12
|
-
import { getVocabulary, getOwnerName } from '../lib/profile.js'
|
|
12
|
+
import { getVocabulary, getOwnerName, getOwnerSpeakerLabel } from '../lib/profile.js'
|
|
13
13
|
import { getOpenAIKey } from '../lib/openai-key.js'
|
|
14
14
|
import { getTranscriptionPolicySnapshot, isOpenAIWhisperFallbackReady } from '../lib/transcription-policy.js'
|
|
15
15
|
import { STRANDED_STALE_MS } from '../lib/stranded-sessions.js'
|
|
@@ -1908,6 +1908,11 @@ async function transcribeWithServerWhisper(audioBuffer: Buffer, whisperAudio: Bu
|
|
|
1908
1908
|
}
|
|
1909
1909
|
}
|
|
1910
1910
|
|
|
1911
|
+
/** The wearer's label for the 6.61.1 owner guard; null when the profile cannot say. */
|
|
1912
|
+
function ownerLabelOrNull(): string | null {
|
|
1913
|
+
try { return getOwnerSpeakerLabel() } catch { return null }
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1911
1916
|
function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex: number, clientSpeaker: string): { speaker: string; similarity: number } {
|
|
1912
1917
|
const expectedSpeakers = undefined
|
|
1913
1918
|
const audioDurationSec = Math.max(0, (audioBuffer.length - 44)) / 32000
|
|
@@ -2155,6 +2160,7 @@ async function processStreamChunk(opts: {
|
|
|
2155
2160
|
audio: audioBuffer,
|
|
2156
2161
|
voiceprint,
|
|
2157
2162
|
clientSpeaker,
|
|
2163
|
+
owner: ownerLabelOrNull(),
|
|
2158
2164
|
identify: wav => {
|
|
2159
2165
|
const named = identifySpeaker(wav, undefined, { event: 'nemotron-track' })
|
|
2160
2166
|
return named ? { speaker: named.speaker, similarity: named.similarity } : null
|