@gotcos/glasses-server 6.92.0 → 6.93.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/.env.example +30 -1
- package/CHANGELOG.md +57 -29
- package/README.md +58 -0
- package/docs/active-query-jobs.md +1 -1
- package/docs/test-plan-6.71.7.md +1 -1
- package/managed-runtime-contract.json +2 -0
- package/package.json +2 -1
- package/server/lib/ask-session-id.ts +2 -1
- package/server/lib/embedding-eviction.ts +60 -2
- package/server/lib/fuzzy-correct.ts +1 -1
- package/server/lib/hallucination-filter.ts +1 -1
- package/server/lib/imported-library-rows.ts +4 -2
- package/server/lib/live-cos-ask.ts +3 -2
- package/server/lib/mac-live-voices.ts +57 -0
- package/server/lib/mac-recording-asr.ts +16 -8
- package/server/lib/mac-speaker-projection.ts +211 -0
- package/server/lib/mac-stream-diarizer.ts +185 -0
- package/server/lib/mac-voice-assignment.ts +361 -0
- package/server/lib/mac-voice-labels.ts +70 -8
- package/server/lib/mac-window-replay.ts +271 -0
- package/server/lib/mac-window-voices.ts +390 -0
- package/server/lib/meeting-actions.ts +1 -1
- package/server/lib/meeting-ask-owner.ts +4 -4
- package/server/lib/meeting-ask-transcript-sources.ts +41 -3
- package/server/lib/meeting-audio-archive.ts +4 -0
- package/server/lib/meeting-batch-transcribe.ts +7 -1
- package/server/lib/meeting-context-keys.ts +6 -3
- package/server/lib/meeting-corrections.ts +4 -4
- package/server/lib/meeting-fact-check.ts +3 -3
- package/server/lib/meeting-parse.ts +1 -1
- package/server/lib/meeting-relabel-enrolment.ts +8 -8
- package/server/lib/meeting-relabel.ts +3 -3
- package/server/lib/meeting-scribe-content.ts +1 -1
- package/server/lib/meeting-speaker-review.ts +8 -8
- package/server/lib/meeting-store.ts +19 -9
- package/server/lib/nemotron-final.ts +114 -9
- package/server/lib/nemotron-live.ts +58 -6
- package/server/lib/phone-recording.ts +114 -0
- package/server/lib/prompt-tail-guard.ts +1 -1
- package/server/lib/speaker-embeddings.ts +154 -28
- package/server/lib/speaker-name.ts +1 -1
- package/server/lib/stranded-session-actions.ts +7 -2
- package/server/lib/training-audio-provenance.ts +3 -2
- package/server/lib/transcript-cleanup-context.ts +3 -3
- package/server/lib/transcript-cleanup-model.ts +1 -1
- package/server/lib/transcript-cleanup-spans.ts +10 -10
- package/server/lib/transcript-cleanup-text.ts +4 -4
- package/server/lib/transcript-cleanup.ts +1 -1
- package/server/lib/unsaved-audio-quarantine.ts +10 -1
- package/server/lib/voice-directory.ts +1 -1
- package/server/lib/voice-evidence-guard.ts +2 -1
- package/server/lib/voice-profile-store.ts +11 -5
- package/server/lib/whisper-local.ts +1 -1
- package/server/routes/health.ts +8 -0
- package/server/routes/meeting.ts +189 -19
- package/server/routes/meetings.ts +9 -5
- package/server/routes/transcribe-stream.ts +140 -15
- package/server/routes/voice.ts +12 -4
- package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +225 -0
- package/server/scripts/mac-window-voices-6930-mutation-gate.mjs +193 -0
|
@@ -16,10 +16,10 @@
|
|
|
16
16
|
// near_name a word the engine was unsure of (below 0.9) that is a capitalised word
|
|
17
17
|
// one edit past fuzzy-correct's limit from a known name, or that sounds
|
|
18
18
|
// like one (same consonant skeleton and onset); two or three short
|
|
19
|
-
// adjacent pieces joined ("U
|
|
20
|
-
// leading-y rule at any confidence ("
|
|
19
|
+
// adjacent pieces joined ("U cmya"), none a function word; or the
|
|
20
|
+
// leading-y rule at any confidence ("Yukmya" for Ukamya)
|
|
21
21
|
// oov_capitalized a capitalised word mid-sentence that is no known name and no common
|
|
22
|
-
// word, within two words of a known name ("
|
|
22
|
+
// word, within two words of a known name ("Faith Eucmya")
|
|
23
23
|
// repeat a phrase of 2 to 4 words (or one long word) said three or more
|
|
24
24
|
// times in a row: the classic decoder loop
|
|
25
25
|
// Flagged words one word apart merge; a span holds at most 6 words (12 for a repeat)
|
|
@@ -104,7 +104,7 @@ const POSSESSIVE = /(['’]s)$/i
|
|
|
104
104
|
* attendees as targets Slack still became Black twice; they reach the model as context
|
|
105
105
|
* and drive near-name spans instead), and the curated dictionary and the owner
|
|
106
106
|
* ("strong" targets) rewrite only a word the engine was unsure of. With those rules the
|
|
107
|
-
* 68 meetings got 27 fixes, all real (
|
|
107
|
+
* 68 meetings got 27 fixes, all real (Mohun to Mohan 9 times, MarketPOS to MarktPOS).
|
|
108
108
|
* - a term from THIS meeting's screen or files (the per-meeting vocabulary) may rewrite a
|
|
109
109
|
* capitalised word at any confidence: it is what was on screen while it was said.
|
|
110
110
|
* Never into an inflection of the word, and a word that only opens a sentence only when the
|
|
@@ -159,11 +159,11 @@ interface NearInput { key: string; capitalized: boolean; probability?: number; j
|
|
|
159
159
|
|
|
160
160
|
/**
|
|
161
161
|
* The known name `key` is plausibly a mishearing of, or null.
|
|
162
|
-
* - the leading-y rule (meeting-ask-owner.ts): "
|
|
162
|
+
* - the leading-y rule (meeting-ask-owner.ts): "Yukmya" is one edit from "Ukamya" once its y goes
|
|
163
163
|
* - otherwise only for a word the engine was unsure of (below NEAR_NAME_UNSURE, or unscored):
|
|
164
164
|
* a capitalised single word one edit past fuzzy-correct's own limit, or
|
|
165
165
|
* any word (or short joined pieces) that sounds like the name (soundsAlike)
|
|
166
|
-
* - joined pieces may also be within fuzzy-correct's own limit ("U
|
|
166
|
+
* - joined pieces may also be within fuzzy-correct's own limit ("U cmya" read as one word)
|
|
167
167
|
*/
|
|
168
168
|
function nearName(input: NearInput, targets: readonly NameTarget[], known: ReadonlySet<string>): string | null {
|
|
169
169
|
const { key } = input
|
|
@@ -174,7 +174,7 @@ function nearName(input: NearInput, targets: readonly NameTarget[], known: Reado
|
|
|
174
174
|
for (const t of targets) {
|
|
175
175
|
if (Math.abs(t.key.length - key.length) > 2) continue
|
|
176
176
|
const d = levenshtein(key, t.key)
|
|
177
|
-
// A single word spelled exactly like a name is the name. Pieces that join into one exactly ("Uka
|
|
177
|
+
// A single word spelled exactly like a name is the name. Pieces that join into one exactly ("Uka mya") are the
|
|
178
178
|
// strongest sign of a split name there is.
|
|
179
179
|
if (d === 0 && !input.join) return null
|
|
180
180
|
// "setting" next to a "Settings" label is the same word, not a misheard name.
|
|
@@ -260,12 +260,12 @@ export function selectSpans(
|
|
|
260
260
|
if (near) flag(t.i, 'near_name', near)
|
|
261
261
|
}
|
|
262
262
|
}
|
|
263
|
-
// Two or three adjacent words joined: "U
|
|
263
|
+
// Two or three adjacent words joined: "U cmya", "Yu ko ma". One of them must be short (a split fragment).
|
|
264
264
|
for (let i = 0; i + 1 < tokens.length; i++) {
|
|
265
265
|
for (const width of [2, 3]) {
|
|
266
266
|
const group = tokens.slice(i, i + width)
|
|
267
267
|
if (group.length < width || group.some(g => g.line !== tokens[i].line || !g.key || fixed.has(g.i) || known.has(g.key))) continue
|
|
268
|
-
// A split name is short pieces ("U
|
|
268
|
+
// A split name is short pieces ("U cmya", "Yu ko ma"); a long word plus a short one is just two words,
|
|
269
269
|
// and a function word is never a piece of a name.
|
|
270
270
|
if (!group.some(g => g.key.length <= 3) || group.some(g => g.key.length > 5 || FUNCTION_WORDS.has(g.key))) continue
|
|
271
271
|
const joined = group.map(g => g.key).join('')
|
|
@@ -303,7 +303,7 @@ export function selectSpans(
|
|
|
303
303
|
while (k < flagged.length) {
|
|
304
304
|
const next = flagged[k]
|
|
305
305
|
if (tokens[next].line !== tokens[from].line || next - to > 1 || next + 1 - from > SPAN_LIMITS.maxSpanWords) break
|
|
306
|
-
// Never across a clause: "
|
|
306
|
+
// Never across a clause: "Yukmya and eucmya, then U cmya" is two passages, not one.
|
|
307
307
|
if (tokens.slice(to - 1, next).some(t => /[,.;:!?]["')\]]*$/.test(t.raw))) break
|
|
308
308
|
if ([...Array(next - to).keys()].some(g => repeatTokens.has(to + g))) break
|
|
309
309
|
for (const r of flags.get(next)!.reasons) reasons.add(r)
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Tokens are whitespace runs inside a line's text. Each carries its `core`: the token
|
|
8
8
|
// without leading and trailing punctuation, which is what is compared, flagged and
|
|
9
|
-
// replaced, so "
|
|
9
|
+
// replaced, so "Ukmya," becomes "Ukamya," and the comma stays exactly where it was.
|
|
10
10
|
//
|
|
11
11
|
// No IO and no model here.
|
|
12
12
|
|
|
@@ -156,8 +156,8 @@ export function alignWordInfo(tokens: Token[], words: readonly WordInfo[]): numb
|
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
/**
|
|
159
|
-
* A consonant skeleton: how a word sounds with its vowels unsure.
|
|
160
|
-
*
|
|
159
|
+
* A consonant skeleton: how a word sounds with its vowels unsure. Ukamya, Yukmya,
|
|
160
|
+
* eucmya and "U cmya" all read "km". Only ever used together with an onset check and
|
|
161
161
|
* a length bound, because a two-letter skeleton alone matches far too much.
|
|
162
162
|
*/
|
|
163
163
|
export function skeleton(word: string): string {
|
|
@@ -168,7 +168,7 @@ export function skeleton(word: string): string {
|
|
|
168
168
|
return s.replace(/(.)\1+/g, '$1')
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
/** Vowel onset, counting a leading y before a vowel ("
|
|
171
|
+
/** Vowel onset, counting a leading y before a vowel ("Yukmya") as a vowel. */
|
|
172
172
|
export function vowelOnset(word: string): boolean {
|
|
173
173
|
const f = fold(word)
|
|
174
174
|
return /^[aeiou]/.test(f) || /^y[aeiou]/.test(f)
|
|
@@ -405,7 +405,7 @@ export function recordSuggestions(applied: readonly AppliedEdit[], pack: Context
|
|
|
405
405
|
for (const edit of applied) {
|
|
406
406
|
const heardKeys = new Set([...edit.original.matchAll(WORDS)].map(m => fold(m[0])))
|
|
407
407
|
const ws = [...edit.replacement.matchAll(WORDS)].map(m => m[0])
|
|
408
|
-
// Longest first: "
|
|
408
|
+
// Longest first: "Faith Ukamya" is one suggestion, not that plus "Ukamya".
|
|
409
409
|
const candidates = [...new Set<string>([edit.replacement, ...ws.slice(0, -1).map((w, i) => `${w} ${ws[i + 1]}`), ...ws])]
|
|
410
410
|
.sort((a, b) => b.split(' ').length - a.split(' ').length)
|
|
411
411
|
const covered = new Set<string>()
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readPhoneRecording, PHONE_SOURCE_TYPE } from './phone-recording.js'
|
|
1
2
|
// Quarantine for unsaved meeting audio.
|
|
2
3
|
//
|
|
3
4
|
// Until 6.19.0 the session-audio purge (boot sweep + 60s interval) DELETED any
|
|
@@ -54,6 +55,8 @@ export const DISCARDED_RETENTION_MS = 24 * 60 * 60 * 1000
|
|
|
54
55
|
export interface QuarantineReceipt { outcome: 'no_speech'; discarded: true; chunkFiles?: number; note: string }
|
|
55
56
|
|
|
56
57
|
export interface UnsavedCapture {
|
|
58
|
+
sourceType?: typeof PHONE_SOURCE_TYPE
|
|
59
|
+
provenanceState?: 'admitted' | 'unverified'
|
|
57
60
|
sessionId: string
|
|
58
61
|
dirName: string
|
|
59
62
|
quarantinedAt: string | null
|
|
@@ -250,8 +253,14 @@ export function listUnsavedCaptures(
|
|
|
250
253
|
const manifest = readManifest(dirPath)
|
|
251
254
|
const atMs = quarantinedAtMs(dirPath, manifest)
|
|
252
255
|
const ownRetentionMs = effectiveRetentionMs(manifest, retentionMs)
|
|
256
|
+
const sessionId = manifest?.sessionId ?? dir.replace(/\.\d+$/, '')
|
|
257
|
+
let phoneSource: { sourceType?: typeof PHONE_SOURCE_TYPE; provenanceState?: 'admitted' | 'unverified' } = {}
|
|
258
|
+
if (sessionId.startsWith('phone_')) {
|
|
259
|
+
try { phoneSource = readPhoneRecording(sessionId) ? { sourceType: PHONE_SOURCE_TYPE, provenanceState: 'admitted' } : { provenanceState: 'unverified' } } catch { phoneSource = { provenanceState: 'unverified' } }
|
|
260
|
+
}
|
|
253
261
|
captures.push({
|
|
254
|
-
sessionId
|
|
262
|
+
sessionId,
|
|
263
|
+
...phoneSource,
|
|
255
264
|
dirName: dir,
|
|
256
265
|
quarantinedAt: atMs != null ? new Date(atMs).toISOString() : null,
|
|
257
266
|
ageHours: atMs != null ? Math.round(((Date.now() - atMs) / 3_600_000) * 10) / 10 : null,
|
|
@@ -117,7 +117,7 @@ let building: Promise<VoiceDirectorySnapshot> | null = null
|
|
|
117
117
|
function sourceCounts(profile: VoiceProfile): Record<string, number> {
|
|
118
118
|
const counts: Record<string, number> = {}
|
|
119
119
|
for (const source of profile.sources ?? []) {
|
|
120
|
-
const key = source.startsWith('auto:') ? 'auto' : source
|
|
120
|
+
const key = source.startsWith('auto:') ? 'auto' : source.startsWith('mac-assign:') ? 'mac-assign' : source
|
|
121
121
|
counts[key] = (counts[key] ?? 0) + 1
|
|
122
122
|
}
|
|
123
123
|
return counts
|
|
@@ -29,7 +29,7 @@ export const IMPORTED_EVIDENCE_ID_PATTERN = /^(imported|blended):/
|
|
|
29
29
|
/** The two sidecars of the imported library. Never chunk audio, never `.g2-chunks.json`. */
|
|
30
30
|
export const IMPORTED_EVIDENCE_PATH_PATTERN = /\.(?:import|derived)\.json$/i
|
|
31
31
|
|
|
32
|
-
export type VoiceEvidenceReason = 'imported_read_only' | 'blended_derived_record'
|
|
32
|
+
export type VoiceEvidenceReason = 'imported_read_only' | 'blended_derived_record' | 'phone_anonymous_capture'
|
|
33
33
|
|
|
34
34
|
export interface VoiceEvidenceRefusal {
|
|
35
35
|
status: 409
|
|
@@ -66,6 +66,7 @@ export function isImportedVoiceEvidence(value: unknown): boolean {
|
|
|
66
66
|
*/
|
|
67
67
|
export function assertVoiceEvidenceSource(values: ReadonlyArray<unknown>): VoiceEvidenceRefusal | null {
|
|
68
68
|
for (const value of values) {
|
|
69
|
+
if (typeof value === 'string' && /(?:^|[:/])phone_/.test(value)) return { status: 409, body: { error: 'iPhone microphone recordings are anonymous and cannot name or train voices', reason: 'phone_anonymous_capture', source: value } }
|
|
69
70
|
const kind = importedEvidenceKind(value)
|
|
70
71
|
if (!kind) continue
|
|
71
72
|
return {
|
|
@@ -15,7 +15,7 @@ import { checkSpeakerName, type SpeakerNameRejection } from './speaker-name.js'
|
|
|
15
15
|
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'
|
|
16
16
|
import { basename, dirname, join, resolve } from 'node:path'
|
|
17
17
|
import { durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
|
|
18
|
-
import { EVICTION_ORDER, provenanceTier } from './embedding-eviction.js'
|
|
18
|
+
import { EVICTION_ORDER, isMacAssign, macAssignCapFor, provenanceTier } from './embedding-eviction.js'
|
|
19
19
|
|
|
20
20
|
export interface VoiceProfile {
|
|
21
21
|
name: string
|
|
@@ -194,7 +194,7 @@ export function junkNameReason(name: string): SpeakerNameRejection | null {
|
|
|
194
194
|
const check = checkSpeakerName(name, { ownerLabel: name })
|
|
195
195
|
if (check.ok) return null
|
|
196
196
|
if (check.reason === 'too_long' || check.reason === 'too_many_words') return check.reason
|
|
197
|
-
// Sentence punctuation alone is not enough: "
|
|
197
|
+
// Sentence punctuation alone is not enough: "Theo M." is a legitimate legacy
|
|
198
198
|
// alias. Three or more words with punctuation is speech, not a name.
|
|
199
199
|
if (check.reason === 'sentence_like' && name.trim().split(/\s+/).length >= 3) return check.reason
|
|
200
200
|
return null
|
|
@@ -555,9 +555,15 @@ export function mergeProfilesInStore(
|
|
|
555
555
|
|
|
556
556
|
// Preserve stronger label provenance. Within one
|
|
557
557
|
// source tier retain newer evidence, matching weakest-first/oldest eviction.
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
558
|
+
// A Mac-assigned sample ranks BELOW every other tier, the same rule chooseEviction applies (embedding-eviction.ts).
|
|
559
|
+
const rank = (i: number): number => isMacAssign(sources[i]) ? -1 : EVICTION_ORDER.indexOf(provenanceTier(sources[i]))
|
|
560
|
+
const kept = embeddings.map((_, i) => i).sort((a, b) => rank(b) - rank(a) || b - a).slice(0, cap)
|
|
561
|
+
// The merged profile obeys the Mac cap too: no more than 10, and never more than its other samples. Surplus Mac
|
|
562
|
+
// samples go oldest first, which is what chooseEviction does, so a merge cannot hold what an enrolment would refuse.
|
|
563
|
+
const macKept = kept.filter(i => isMacAssign(sources[i])).sort((a, b) => a - b)
|
|
564
|
+
const macAllowed = macAssignCapFor(kept.length, macKept.length)
|
|
565
|
+
const trimmed = new Set(macKept.slice(0, Math.max(0, macKept.length - macAllowed)))
|
|
566
|
+
const keep = kept.filter(i => !trimmed.has(i)).sort((a, b) => a - b)
|
|
561
567
|
outcome.droppedToCap = embeddings.length - keep.length
|
|
562
568
|
target.embeddings = keep.map(i => embeddings[i])
|
|
563
569
|
target.sources = keep.map(i => sources[i])
|
|
@@ -1398,7 +1398,7 @@ function buildCorrections(): Array<[RegExp, string]> {
|
|
|
1398
1398
|
* The profile's order, except that a key goes ahead of any earlier key whose words it
|
|
1399
1399
|
* contains (6.83.1): "forward slash build and price" runs before "forward slash build", or
|
|
1400
1400
|
* the shorter key would cut the longer phrase short. Chains the person relies on
|
|
1401
|
-
* ("Myles" then "Miles
|
|
1401
|
+
* ("Myles" then "Miles Ukmya") keep their order, since neither key contains the other.
|
|
1402
1402
|
*/
|
|
1403
1403
|
export function correctionOrder(corrections: Record<string, string>): Array<[string, string]> {
|
|
1404
1404
|
const norm = (key: string) => ` ${key.trim().toLowerCase().split(/[\s-]+/).join(' ')} `
|
package/server/routes/health.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { phoneMicrophoneMeetingsCapability } from '../lib/phone-recording.js'
|
|
2
|
+
import { macLiveVoicesSnapshot } from '../lib/mac-window-replay.js'
|
|
1
3
|
import { DICTATION_CLEAN_MODELS } from '../lib/dictation-clean-models.js'
|
|
2
4
|
import { customInstructionsCapability, learnedStyleCapability, polishOptionsCapability } from './dictation-polish-settings.js'
|
|
3
5
|
import { PAIRING_PROTOCOL_VERSION } from '../lib/glasses-pairing.js'
|
|
@@ -550,6 +552,9 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
550
552
|
finalization: getMeetingFinalizationSnapshot(),
|
|
551
553
|
},
|
|
552
554
|
...(localFirstMeetings ? { localFirstMeetings } : {}),
|
|
555
|
+
phoneMicrophoneMeetings: phoneMicrophoneMeetingsCapability(getServerInstanceId()),
|
|
556
|
+
// 6.93.0 W8: which live Mac Voice N path is on (window by default since 6.93.0; anchors; off) and the window path's counts.
|
|
557
|
+
macLiveVoices: macLiveVoicesSnapshot(),
|
|
553
558
|
...(morningBrief ? { morningBrief } : {}),
|
|
554
559
|
tasks: {
|
|
555
560
|
gate: taskBridgeAvailable() ? 'ready' : 'disabled',
|
|
@@ -741,6 +746,9 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
741
746
|
},
|
|
742
747
|
},
|
|
743
748
|
...(localFirstMeetings ? { localFirstMeetings } : {}),
|
|
749
|
+
phoneMicrophoneMeetings: phoneMicrophoneMeetingsCapability(getServerInstanceId()),
|
|
750
|
+
// 6.93.0 W8: which live Mac Voice N path is on (window by default since 6.93.0; anchors; off) and the window path's counts.
|
|
751
|
+
macLiveVoices: macLiveVoicesSnapshot(),
|
|
744
752
|
tasks: {
|
|
745
753
|
gate: taskBridgeAvailable() ? 'ready' : 'disabled',
|
|
746
754
|
},
|