@gotcos/glasses-server 6.7.0 → 6.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.9.0
4
+
5
+ Live recoverable prompt transcription for COS Glasses builds 200+.
6
+
7
+ - **Words appear while speaking.** After each audio chunk is durably acknowledged,
8
+ its sanitized fast/local transcript is published on the existing authenticated,
9
+ replayable display stream as `prompt_transcript`; the phone/G2 client can fill
10
+ the Listening body without adding another recorder, polling loop, or ASR job.
11
+ - **Recovery remains authoritative.** The event is optional presentation state.
12
+ Stored WAV chunks, final HQ transcription, glossary cleanup, editing, retry,
13
+ and send behavior remain unchanged and continue even if no display client is
14
+ connected.
15
+ - **Stale retries cannot repaint.** The server rechecks the exact draft, chunk
16
+ index, and audio bytes after warm transcription. Replaced audio never emits
17
+ its obsolete words, while client-side draft scoping, ordering, and replay
18
+ deduplication handle reconnects safely.
19
+ - **Public boundary retained.** This release adds no private COS paths, personal
20
+ data, LaunchAgent controls, remote restart authority, or machine-management
21
+ endpoints.
22
+
23
+ ## 6.8.0
24
+
25
+ Public-safe meeting finalization for COS Glasses build 199.
26
+
27
+ - **Authenticated meeting save.** `POST /api/meeting/save` finalizes an existing
28
+ `transcribe-stream` session without adding coaching, private classification,
29
+ personal paths, or COS-only enrichment to the public package. Lost-chunk gaps,
30
+ original client timing, provider evidence, and sparse raw-audio indices remain
31
+ intact through deferred iPhone replay and save.
32
+ - **Durable standalone archive.** Canonical markdown and structured sidecars are
33
+ published atomically under `dataPath('recordings', 'YYYY-MM')`. Directories are
34
+ `0700`, files are `0600`, filenames are path-safe and session-unique, and an
35
+ fsync-backed sidecar-first/markdown-last commit keeps incomplete pairs hidden.
36
+ - **Review on the current client.** Authenticated `GET /api/meetings`, literal
37
+ `GET /api/meetings/detail`, and the build199-compatible dynamic detail route
38
+ list and read standalone recordings after process/package restarts. Traversal,
39
+ unsafe filenames, symlinked roots/months/files, absolute-path disclosure, and
40
+ cross-domain detail mismatches fail closed.
41
+ - **Transcript-quality bouncer.** Post-meeting batch text must preserve at least
42
+ 50% live coverage, provide independent evidence when no live baseline exists,
43
+ and avoid repeated long segments/sentences/prefixes. Mixed timestamp coverage
44
+ falls back to complete batch text instead of dropping text-only segments.
45
+ - **Recovery evidence wins.** Canonical streaming text remains untouched when a
46
+ batch is rejected or cannot be applied. Pending WAVs are deleted only after
47
+ accepted text and its sidecar decision are both durable; every other outcome
48
+ retains audio for bounded two-hour cleanup. HQ batch decoders serialize and
49
+ refresh their cleanup lease while queued or active.
50
+ - **Capability detection.** `/api/health` now advertises
51
+ `features.meetingFinalization` for compatible clients.
52
+
3
53
  ## 6.7.0
4
54
 
5
55
  Durable prompt recovery and self-healing local transcription for COS Glasses
package/README.md CHANGED
@@ -64,7 +64,9 @@ The built-in IP allowlist blocks public-internet traffic regardless.
64
64
  - Send phone photos with queued prompts, and review assistant-selected generated,
65
65
  research, or explicitly used email images in Messages and on the G2 lens
66
66
  - Recover long voice prompts after phone, network, or server interruptions. Audio
67
- chunks are saved before transcription and retained locally for 72 hours.
67
+ chunks are saved before transcription and retained locally for 72 hours. On
68
+ compatible app builds, their warm transcript also appears live while speaking;
69
+ final HQ transcription remains authoritative.
68
70
  - Live voice capture + transcription during meetings
69
71
  - Local whisper.cpp transcription (free) with OpenAI fallback (optional)
70
72
  - Tasks / calendar / people context **if** you run the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.7.0",
3
+ "version": "6.9.0",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by your local Claude Code or Codex CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -18,6 +18,8 @@ import { queryRouter } from './routes/query.js'
18
18
  import { transcribeRouter } from './routes/transcribe.js'
19
19
  import { displayRouter } from './routes/display.js'
20
20
  import { transcribeStreamRouter } from './routes/transcribe-stream.js'
21
+ import { meetingRouter } from './routes/meeting.js'
22
+ import { meetingsRouter } from './routes/meetings.js'
21
23
  import { openaiCompatRouter } from './routes/openai-compat.js'
22
24
  import { openaiKeyRouter } from './routes/openai-key.js'
23
25
  import { messageRefRouter } from './routes/message-ref.js'
@@ -141,6 +143,8 @@ app.use('/api', queryRouter)
141
143
  app.use('/api', transcribeRouter)
142
144
  app.use('/api', displayRouter)
143
145
  app.use('/api', transcribeStreamRouter)
146
+ app.use('/api', meetingRouter)
147
+ app.use('/api', meetingsRouter)
144
148
  app.use('/api', openaiKeyRouter)
145
149
  // v6.3.0 — Message History, cross-day 'reference message N', and history
146
150
  // recovery for public npx users (previously full-COS-server only).
@@ -7,7 +7,20 @@
7
7
  // Use for sessions.json, archive/*.json, and any other durable JSON we can't
8
8
  // afford to lose.
9
9
 
10
- import { writeFileSync, renameSync, existsSync, readFileSync } from 'node:fs'
10
+ import {
11
+ closeSync,
12
+ constants,
13
+ existsSync,
14
+ fchmodSync,
15
+ fsyncSync,
16
+ openSync,
17
+ readFileSync,
18
+ renameSync,
19
+ unlinkSync,
20
+ writeFileSync,
21
+ } from 'node:fs'
22
+ import { randomBytes } from 'node:crypto'
23
+ import { basename, dirname, join } from 'node:path'
11
24
 
12
25
  export function atomicWriteFileSync(path: string, data: string | Buffer, options: { mode?: number } = {}): void {
13
26
  const tmp = `${path}.tmp`
@@ -15,6 +28,69 @@ export function atomicWriteFileSync(path: string, data: string | Buffer, options
15
28
  renameSync(tmp, path)
16
29
  }
17
30
 
31
+ /**
32
+ * Publish private durable state without ever exposing a partially-written file.
33
+ *
34
+ * The existing `atomicWriteFileSync` intentionally remains lightweight for
35
+ * high-frequency caches. Meeting finalization uses this stronger variant:
36
+ * bytes and file metadata are fsync'd before rename, the destination mode is
37
+ * forced after publish, and the containing directory is fsync'd where the
38
+ * platform supports it. A randomized exclusive temp name also prevents two
39
+ * independent writers from sharing `<path>.tmp`.
40
+ */
41
+ export function durableAtomicWriteFileSync(
42
+ path: string,
43
+ data: string | Buffer,
44
+ options: { mode?: number } = {},
45
+ ): void {
46
+ const mode = options.mode ?? 0o600
47
+ const dir = dirname(path)
48
+ const tmp = join(
49
+ dir,
50
+ `.${basename(path)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`,
51
+ )
52
+ let fd: number | null = null
53
+
54
+ try {
55
+ const noFollow = constants.O_NOFOLLOW ?? 0
56
+ fd = openSync(
57
+ tmp,
58
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow,
59
+ mode,
60
+ )
61
+ writeFileSync(fd, data)
62
+ fchmodSync(fd, mode)
63
+ fsyncSync(fd)
64
+ closeSync(fd)
65
+ fd = null
66
+
67
+ // The exclusive temp inode already has its final private mode. Apply chmod
68
+ // before the commit so no failure can be reported after rename succeeded.
69
+ renameSync(tmp, path)
70
+
71
+ // Persist the directory entry as well as the file contents. Some virtual
72
+ // filesystems do not support directory fsync; the already-fsync'd file and
73
+ // atomic rename still provide the strongest available behavior there.
74
+ let dirFd: number | null = null
75
+ try {
76
+ dirFd = openSync(dir, constants.O_RDONLY)
77
+ fsyncSync(dirFd)
78
+ } catch {
79
+ // Best available durability on filesystems that reject directory fsync.
80
+ } finally {
81
+ if (dirFd !== null) {
82
+ try { closeSync(dirFd) } catch { /* commit already succeeded */ }
83
+ }
84
+ }
85
+ } catch (error) {
86
+ if (fd !== null) {
87
+ try { closeSync(fd) } catch { /* already closed */ }
88
+ }
89
+ try { unlinkSync(tmp) } catch { /* publish may already have renamed it */ }
90
+ throw error
91
+ }
92
+ }
93
+
18
94
  /**
19
95
  * Read + JSON.parse with explicit missing-vs-corrupt distinction.
20
96
  * - File missing: returns { status: 'missing' } — caller starts fresh silently
@@ -0,0 +1,243 @@
1
+ import type { WhisperWord } from './whisper-local.js'
2
+
3
+ export interface BatchSegment {
4
+ startChunkIdx: number
5
+ endChunkIdx: number
6
+ startElapsed: number
7
+ endElapsed: number
8
+ speakers: string[]
9
+ }
10
+
11
+ export interface BatchResult {
12
+ segment: BatchSegment
13
+ text: string
14
+ words: WhisperWord[]
15
+ speakerWords: Array<{ word: string; start: number; end: number; speaker: string }>
16
+ }
17
+
18
+ export interface BatchTranscription {
19
+ transcriptionQuality: 'batch' | 'streaming'
20
+ batchTranscript?: string
21
+ batchSegments?: BatchResult[]
22
+ qualityReport?: BatchQualityReport
23
+ }
24
+
25
+ export interface BatchTranscriptSelection {
26
+ text: string
27
+ source: 'speaker-words' | 'batch-text'
28
+ }
29
+
30
+ export type BatchQualityReason =
31
+ | 'accepted'
32
+ | 'insufficient-coverage'
33
+ | 'insufficient-batch-evidence'
34
+ | 'repetitive-output'
35
+
36
+ export interface BatchQualityReport {
37
+ accepted: boolean
38
+ reason: BatchQualityReason
39
+ batchWordCount: number
40
+ streamingWordCount: number
41
+ coverageRatio: number
42
+ timedWordCount: number
43
+ timedWordRatio: number
44
+ maxRepeatedUnitCount: number
45
+ duplicateWordRatio: number
46
+ repeatedUnit?: string
47
+ }
48
+
49
+ interface RepetitionStats {
50
+ maxRepeatedUnitCount: number
51
+ duplicateWordRatio: number
52
+ repeatedUnit?: string
53
+ }
54
+
55
+ function countWords(text: string): number {
56
+ return text.trim().split(/\s+/).filter(Boolean).length
57
+ }
58
+
59
+ /**
60
+ * Speaker timestamps are optional on fallback transcription backends. Use the
61
+ * attributed form only when every non-empty segment is represented; otherwise
62
+ * persist the complete batch text so text-only segments cannot disappear.
63
+ */
64
+ export function selectBatchTranscriptForPersistence(
65
+ batchTranscript: string,
66
+ batchSegments: readonly Pick<BatchResult, 'text' | 'speakerWords'>[] | undefined,
67
+ ): BatchTranscriptSelection {
68
+ const nonEmpty = (batchSegments ?? []).filter(segment => countWords(segment.text) > 0)
69
+ const completeSpeakerCoverage = nonEmpty.length > 0 && nonEmpty.every(segment => {
70
+ const textWords = countWords(segment.text)
71
+ return segment.speakerWords.length >= Math.max(1, Math.floor(textWords * 0.75))
72
+ })
73
+
74
+ if (!completeSpeakerCoverage) return { text: batchTranscript, source: 'batch-text' }
75
+
76
+ const lines: string[] = []
77
+ for (const segment of nonEmpty) {
78
+ let currentSpeaker = ''
79
+ let currentWords: string[] = []
80
+ for (const speakerWord of segment.speakerWords) {
81
+ const word = speakerWord.word.trim()
82
+ if (!word) continue
83
+ const speaker = speakerWord.speaker || 'Ext'
84
+ if (speaker !== currentSpeaker) {
85
+ if (currentWords.length > 0) lines.push(`[${currentSpeaker}]: ${currentWords.join(' ')}`)
86
+ currentSpeaker = speaker
87
+ currentWords = [word]
88
+ } else {
89
+ currentWords.push(word)
90
+ }
91
+ }
92
+ if (currentWords.length > 0) lines.push(`[${currentSpeaker}]: ${currentWords.join(' ')}`)
93
+ }
94
+
95
+ return lines.length > 0
96
+ ? { text: lines.join('\n'), source: 'speaker-words' }
97
+ : { text: batchTranscript, source: 'batch-text' }
98
+ }
99
+
100
+ function normalizeQualityUnit(text: string): string {
101
+ return text
102
+ .toLowerCase()
103
+ .replace(/[^\p{L}\p{N}\s]/gu, ' ')
104
+ .replace(/\s+/g, ' ')
105
+ .trim()
106
+ // A repeated template with only a changing index/date/amount is still the
107
+ // same unit and must not manufacture apparent transcript coverage.
108
+ .replace(/\b\d+\b/g, '<n>')
109
+ }
110
+
111
+ function toLongQualityUnit(raw: string): { normalized: string; sample: string; wordCount: number } | null {
112
+ const sample = raw.replace(/\s+/g, ' ').trim()
113
+ const normalized = normalizeQualityUnit(sample)
114
+ const wordCount = countWords(normalized)
115
+ // Exempt short acknowledgements and ordinary meeting refrains. The observed
116
+ // failure repeated a complete, long prompted sentence dozens of times.
117
+ if (wordCount < 8 || normalized.length < 40) return null
118
+ return { normalized, sample, wordCount }
119
+ }
120
+
121
+ function toLongPrefixUnit(raw: string): { normalized: string; sample: string; wordCount: number } | null {
122
+ const unit = toLongQualityUnit(raw)
123
+ if (!unit) return null
124
+ const prefixWords = unit.normalized.split(/\s+/).slice(0, 12)
125
+ if (prefixWords.length < 12) return null
126
+ return { normalized: prefixWords.join(' '), sample: unit.sample, wordCount: prefixWords.length }
127
+ }
128
+
129
+ function measureRepetition(
130
+ units: readonly { normalized: string; sample: string; wordCount: number }[],
131
+ batchWordCount: number,
132
+ ): RepetitionStats {
133
+ const counts = new Map<string, { count: number; wordCount: number; sample: string }>()
134
+ for (const unit of units) {
135
+ const current = counts.get(unit.normalized)
136
+ if (current) current.count += 1
137
+ else counts.set(unit.normalized, { count: 1, wordCount: unit.wordCount, sample: unit.sample })
138
+ }
139
+
140
+ let maxRepeatedUnitCount = 0
141
+ let duplicateWords = 0
142
+ let repeatedUnit: string | undefined
143
+ for (const entry of counts.values()) {
144
+ if (entry.count <= 1) continue
145
+ duplicateWords += (entry.count - 1) * entry.wordCount
146
+ if (entry.count > maxRepeatedUnitCount) {
147
+ maxRepeatedUnitCount = entry.count
148
+ repeatedUnit = entry.sample.slice(0, 160)
149
+ }
150
+ }
151
+
152
+ return {
153
+ maxRepeatedUnitCount,
154
+ duplicateWordRatio: batchWordCount > 0 ? Math.min(1, duplicateWords / batchWordCount) : 0,
155
+ ...(repeatedUnit ? { repeatedUnit } : {}),
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Deterministic bouncer for a post-meeting transcript candidate. Coverage by
161
+ * itself is unsafe: Whisper can repeat one prompted sentence many times and
162
+ * appear to recover the whole meeting. Measure long exact units at segment,
163
+ * sentence, and prefix granularity while leaving short conversational repeats
164
+ * alone.
165
+ */
166
+ export function evaluateBatchQuality(
167
+ batchResults: readonly Pick<BatchResult, 'text' | 'words'>[],
168
+ streamingWordCount: number,
169
+ ): BatchQualityReport {
170
+ const batchText = batchResults.map(result => result.text).join(' ')
171
+ const batchWordCount = countWords(batchText)
172
+ const safeStreamingWordCount = Math.max(0, streamingWordCount)
173
+ const coverageRatio = safeStreamingWordCount > 0
174
+ ? batchWordCount / safeStreamingWordCount
175
+ : (batchWordCount > 0 ? 1 : 0)
176
+ const timedWordCount = batchResults.reduce((sum, result) => sum + (result.words?.length ?? 0), 0)
177
+ const timedWordRatio = batchWordCount > 0 ? timedWordCount / batchWordCount : 0
178
+ const nonEmptySegmentCount = batchResults.filter(result => countWords(result.text) > 0).length
179
+
180
+ const segmentUnits = batchResults
181
+ .map(result => toLongQualityUnit(result.text))
182
+ .filter((unit): unit is NonNullable<typeof unit> => unit !== null)
183
+ const segmentStats = measureRepetition(segmentUnits, batchWordCount)
184
+
185
+ const sentenceUnits: Array<{ normalized: string; sample: string; wordCount: number }> = []
186
+ for (const result of batchResults) {
187
+ for (const sentence of result.text.match(/[^.!?\n]+(?:[.!?]+|$)/g) ?? []) {
188
+ const unit = toLongQualityUnit(sentence)
189
+ if (unit) sentenceUnits.push(unit)
190
+ }
191
+ }
192
+ const sentenceStats = measureRepetition(sentenceUnits, batchWordCount)
193
+
194
+ // Keep segment and sentence prefix populations separate. A one-sentence
195
+ // segment otherwise counts twice and can turn two valid repeats into four.
196
+ const segmentPrefixStats = measureRepetition(
197
+ batchResults
198
+ .map(result => toLongPrefixUnit(result.text))
199
+ .filter((unit): unit is NonNullable<typeof unit> => unit !== null),
200
+ batchWordCount,
201
+ )
202
+ const sentencePrefixStats = measureRepetition(
203
+ batchResults.flatMap(result => (
204
+ result.text.match(/[^.!?\n]+(?:[.!?]+|$)/g) ?? []
205
+ ).map(sentence => toLongPrefixUnit(sentence)))
206
+ .filter((unit): unit is NonNullable<typeof unit> => unit !== null),
207
+ batchWordCount,
208
+ )
209
+
210
+ const repetition = [segmentStats, sentenceStats, segmentPrefixStats, sentencePrefixStats]
211
+ .reduce((strongest, candidate) => (
212
+ candidate.duplicateWordRatio > strongest.duplicateWordRatio ? candidate : strongest
213
+ ))
214
+
215
+ const repetitiveOutput = (
216
+ repetition.maxRepeatedUnitCount >= 4 && repetition.duplicateWordRatio >= 0.08
217
+ ) || (
218
+ repetition.maxRepeatedUnitCount >= 8 && repetition.duplicateWordRatio >= 0.03
219
+ )
220
+
221
+ const reason: BatchQualityReason = safeStreamingWordCount === 0 && (
222
+ batchWordCount < 50 || nonEmptySegmentCount < 2
223
+ )
224
+ ? 'insufficient-batch-evidence'
225
+ : coverageRatio < 0.5
226
+ ? 'insufficient-coverage'
227
+ : repetitiveOutput
228
+ ? 'repetitive-output'
229
+ : 'accepted'
230
+
231
+ return {
232
+ accepted: reason === 'accepted',
233
+ reason,
234
+ batchWordCount,
235
+ streamingWordCount: safeStreamingWordCount,
236
+ coverageRatio: Number(coverageRatio.toFixed(4)),
237
+ timedWordCount,
238
+ timedWordRatio: Number(timedWordRatio.toFixed(4)),
239
+ maxRepeatedUnitCount: repetition.maxRepeatedUnitCount,
240
+ duplicateWordRatio: Number(repetition.duplicateWordRatio.toFixed(4)),
241
+ ...(repetition.repeatedUnit ? { repeatedUnit: repetition.repeatedUnit } : {}),
242
+ }
243
+ }
@@ -8,7 +8,7 @@ const bus = new EventEmitter()
8
8
  bus.setMaxListeners(20) // Multiple glasses clients
9
9
 
10
10
  export interface DisplayEvent {
11
- type: 'chunk' | 'done' | 'error' | 'tool_status' | 'start' | 'session_restore' | 'transcript_chunk' | 'recording_start' | 'recording_stop' | 'coaching_nudge'
11
+ type: 'chunk' | 'done' | 'error' | 'tool_status' | 'start' | 'session_restore' | 'transcript_chunk' | 'prompt_transcript' | 'recording_start' | 'recording_stop' | 'coaching_nudge'
12
12
  data: Record<string, unknown>
13
13
  }
14
14
 
@@ -0,0 +1,53 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
3
+ import type { BatchTranscription } from './batch-transcript-quality.js'
4
+
5
+ /** Replace only the final canonical transcript section. Missing markers fail closed. */
6
+ export function replaceMeetingTranscriptAtomic(meetingPath: string, transcript: string): boolean {
7
+ const content = readFileSync(meetingPath, 'utf8')
8
+ if (!/## Transcript\n\n[\s\S]*$/.test(content)) return false
9
+ const updated = content
10
+ .replace(/\| \*\*Transcription quality\*\* \| [^|\n]+ \|/i, '| **Transcription quality** | batch |')
11
+ .replace(/## Transcript\n\n[\s\S]*$/, `## Transcript\n\n${transcript}\n`)
12
+ durableAtomicWriteFileSync(meetingPath, updated, { mode: 0o600 })
13
+ return true
14
+ }
15
+
16
+ /** Persist the batch decision without ever storing a rejected candidate as canonical. */
17
+ export function persistBatchDecisionSidecar(
18
+ sidecarPath: string,
19
+ batchResult: BatchTranscription,
20
+ batchApplied: boolean,
21
+ ): boolean {
22
+ if (!existsSync(sidecarPath)) return false
23
+ const parsed = JSON.parse(readFileSync(sidecarPath, 'utf8')) as unknown
24
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false
25
+ const sidecar = parsed as Record<string, unknown>
26
+ sidecar.transcriptionQuality = batchApplied ? 'batch' : 'streaming'
27
+ sidecar.batchApplied = batchApplied
28
+ if (batchResult.qualityReport) sidecar.batchQualityReport = batchResult.qualityReport
29
+
30
+ if (batchApplied) {
31
+ sidecar.batchTranscript = batchResult.batchTranscript
32
+ sidecar.batchSegments = batchResult.batchSegments?.map(result => ({
33
+ startChunkIdx: result.segment.startChunkIdx,
34
+ endChunkIdx: result.segment.endChunkIdx,
35
+ startElapsed: result.segment.startElapsed,
36
+ endElapsed: result.segment.endElapsed,
37
+ text: result.text,
38
+ words: result.words,
39
+ speakerWords: result.speakerWords,
40
+ }))
41
+ } else {
42
+ delete sidecar.batchTranscript
43
+ delete sidecar.batchSegments
44
+ }
45
+
46
+ durableAtomicWriteFileSync(sidecarPath, JSON.stringify(sidecar, null, 2), { mode: 0o600 })
47
+ return true
48
+ }
49
+
50
+ /** Raw audio is disposable only after canonical text and decision metadata are durable. */
51
+ export function canDeletePendingBatchAudio(batchApplied: boolean, metadataPersisted: boolean): boolean {
52
+ return batchApplied && metadataPersisted
53
+ }