@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.
Files changed (60) hide show
  1. package/.env.example +30 -1
  2. package/CHANGELOG.md +57 -29
  3. package/README.md +58 -0
  4. package/docs/active-query-jobs.md +1 -1
  5. package/docs/test-plan-6.71.7.md +1 -1
  6. package/managed-runtime-contract.json +2 -0
  7. package/package.json +2 -1
  8. package/server/lib/ask-session-id.ts +2 -1
  9. package/server/lib/embedding-eviction.ts +60 -2
  10. package/server/lib/fuzzy-correct.ts +1 -1
  11. package/server/lib/hallucination-filter.ts +1 -1
  12. package/server/lib/imported-library-rows.ts +4 -2
  13. package/server/lib/live-cos-ask.ts +3 -2
  14. package/server/lib/mac-live-voices.ts +57 -0
  15. package/server/lib/mac-recording-asr.ts +16 -8
  16. package/server/lib/mac-speaker-projection.ts +211 -0
  17. package/server/lib/mac-stream-diarizer.ts +185 -0
  18. package/server/lib/mac-voice-assignment.ts +361 -0
  19. package/server/lib/mac-voice-labels.ts +70 -8
  20. package/server/lib/mac-window-replay.ts +271 -0
  21. package/server/lib/mac-window-voices.ts +390 -0
  22. package/server/lib/meeting-actions.ts +1 -1
  23. package/server/lib/meeting-ask-owner.ts +4 -4
  24. package/server/lib/meeting-ask-transcript-sources.ts +41 -3
  25. package/server/lib/meeting-audio-archive.ts +4 -0
  26. package/server/lib/meeting-batch-transcribe.ts +7 -1
  27. package/server/lib/meeting-context-keys.ts +6 -3
  28. package/server/lib/meeting-corrections.ts +4 -4
  29. package/server/lib/meeting-fact-check.ts +3 -3
  30. package/server/lib/meeting-parse.ts +1 -1
  31. package/server/lib/meeting-relabel-enrolment.ts +8 -8
  32. package/server/lib/meeting-relabel.ts +3 -3
  33. package/server/lib/meeting-scribe-content.ts +1 -1
  34. package/server/lib/meeting-speaker-review.ts +8 -8
  35. package/server/lib/meeting-store.ts +19 -9
  36. package/server/lib/nemotron-final.ts +114 -9
  37. package/server/lib/nemotron-live.ts +58 -6
  38. package/server/lib/phone-recording.ts +114 -0
  39. package/server/lib/prompt-tail-guard.ts +1 -1
  40. package/server/lib/speaker-embeddings.ts +154 -28
  41. package/server/lib/speaker-name.ts +1 -1
  42. package/server/lib/stranded-session-actions.ts +7 -2
  43. package/server/lib/training-audio-provenance.ts +3 -2
  44. package/server/lib/transcript-cleanup-context.ts +3 -3
  45. package/server/lib/transcript-cleanup-model.ts +1 -1
  46. package/server/lib/transcript-cleanup-spans.ts +10 -10
  47. package/server/lib/transcript-cleanup-text.ts +4 -4
  48. package/server/lib/transcript-cleanup.ts +1 -1
  49. package/server/lib/unsaved-audio-quarantine.ts +10 -1
  50. package/server/lib/voice-directory.ts +1 -1
  51. package/server/lib/voice-evidence-guard.ts +2 -1
  52. package/server/lib/voice-profile-store.ts +11 -5
  53. package/server/lib/whisper-local.ts +1 -1
  54. package/server/routes/health.ts +8 -0
  55. package/server/routes/meeting.ts +189 -19
  56. package/server/routes/meetings.ts +9 -5
  57. package/server/routes/transcribe-stream.ts +140 -15
  58. package/server/routes/voice.ts +12 -4
  59. package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +225 -0
  60. 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 coma"), none a function word; or the
20
- // leading-y rule at any confidence ("Yukoma" for Ukaoma)
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 ("Grace Eucoma")
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 (Manuj to Manoj 9 times, MarketPOS to MarktPOS).
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): "Yukoma" is one edit from "Ukaoma" once its y goes
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 coma" read as one word)
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 oma") are the
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 coma", "Yu ko ma". One of them must be short (a split fragment).
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 coma", "Yu ko ma"); a long word plus a short one is just two words,
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: "Yukoma and eucoma, then U coma" is two passages, not one.
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 "Ukoma," becomes "Ukaoma," and the comma stays exactly where it was.
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. Ukaoma, Yukoma,
160
- * eucoma and "U coma" all read "km". Only ever used together with an onset check and
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 ("Yukoma") as 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: "Grace Ukaoma" is one suggestion, not that plus "Ukaoma".
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: manifest?.sessionId ?? dir.replace(/\.\d+$/, ''),
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: "Luke H." is a legitimate legacy
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
- const keep = embeddings.map((_, i) => i).sort((a, b) =>
559
- EVICTION_ORDER.indexOf(provenanceTier(sources[b])) - EVICTION_ORDER.indexOf(provenanceTier(sources[a])) || b - a,
560
- ).slice(0, cap).sort((a, b) => a - b)
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 Ukoma") keep their order, since neither key contains the other.
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(' ')} `
@@ -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
  },