@gotcos/glasses-server 6.21.18 → 6.21.19

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,3 +1,19 @@
1
+ ## 6.21.19
2
+
3
+ - Review playback falls back to ext-audio. The 7-day archive introduced in
4
+ 6.21.18 is FORWARD-ONLY — it starts filling when a meeting is saved under that
5
+ version — so on upgrade day the panel had no play buttons at all, which is what
6
+ Miles hit. ext-audio already holds 72 hours of unrecognised-speaker audio keyed
7
+ by the same raw capture index; measured across 14 real meetings, 90-100% of
8
+ those files correspond to a chunk the sidecar labels `Ext`. That is exactly the
9
+ set a reviewer most needs to hear.
10
+ - `GET /meeting/:id/audio` merges both sources and reports `archivedChunks` and
11
+ `extAudioChunks` separately, because the windows differ (7 days vs 72 hours)
12
+ and a single retention figure would be wrong for half the list.
13
+ - Published under its own version rather than re-cutting 6.21.18: that version is
14
+ already on npm, and two different artifacts sharing a version number is a
15
+ defect in its own right.
16
+
1
17
  ## 6.21.18
2
18
 
3
19
  Everything a human needs to correct who spoke, and to hear the voice before
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.21.18",
3
+ "version": "6.21.19",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -213,6 +213,56 @@ export function meetingAudioChunkPath(sessionId: string, chunkIndex: number): st
213
213
  return existsSync(path) ? path : null
214
214
  }
215
215
 
216
+ /**
217
+ * Fallback source: audio the capture path saved for an UNRECOGNISED speaker.
218
+ *
219
+ * `ext-audio/<sessionId>/ext_chunk<N>_<ts>.wav` is written live whenever the
220
+ * identifier finds no match, and `<N>` is the same RAW capture index the review
221
+ * addresses. Verified across 14 real meetings: 90-100% of these files correspond
222
+ * to a chunk the sidecar labels `Ext`.
223
+ *
224
+ * This matters because the 7-day archive is FORWARD-ONLY — it starts filling
225
+ * when a meeting is saved under 6.21.18, so on the day of that upgrade there is
226
+ * nothing to play. ext-audio already holds the unidentified voices from the last
227
+ * 72 hours, which is exactly the set a reviewer most needs to hear.
228
+ *
229
+ * Shorter window than the archive (72h vs 7 days), so the listing reports which
230
+ * source a chunk came from rather than implying one retention rule.
231
+ */
232
+ export function extAudioChunkPath(sessionId: string, chunkIndex: number): string | null {
233
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) return null
234
+ const dir = join(dataPath('ext-audio'), sessionId.replace(/:/g, '_'))
235
+ if (!resolve(dir).startsWith(resolve(dataPath('ext-audio')) + '/') || !existsSync(dir)) return null
236
+ try {
237
+ // Timestamped suffix, so match on the index and take the newest.
238
+ const prefix = `ext_chunk${chunkIndex}_`
239
+ const hits = readdirSync(dir).filter(n => n.startsWith(prefix) && n.endsWith('.wav')).sort()
240
+ const pick = hits[hits.length - 1]
241
+ if (!pick) return null
242
+ const path = resolve(dir, pick)
243
+ return resolve(path).startsWith(resolve(dir) + '/') ? path : null
244
+ } catch {
245
+ return null
246
+ }
247
+ }
248
+
249
+ /** Raw chunk indices this session has ext-audio for, ascending. */
250
+ export function listExtAudioChunks(sessionId: string): number[] {
251
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) return []
252
+ const dir = join(dataPath('ext-audio'), sessionId.replace(/:/g, '_'))
253
+ if (!existsSync(dir)) return []
254
+ try {
255
+ const out = new Set<number>()
256
+ for (const n of readdirSync(dir)) {
257
+ const m = /^ext_chunk(\d+)_.*\.wav$/.exec(n)
258
+ if (m) out.add(Number(m[1]))
259
+ }
260
+ return [...out].sort((a, b) => a - b)
261
+ } catch {
262
+ return []
263
+ }
264
+ }
265
+
216
266
  /** Chunk indices retained for a session, ascending. */
217
267
  export function listMeetingAudioChunks(sessionId: string): number[] {
218
268
  const dir = sessionDir(sessionId)
@@ -11,6 +11,8 @@ import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
11
11
  import { appendCorrection, pendingCorrections } from '../lib/meeting-corrections.js'
12
12
  import { isSampleFromSession, untraceableSampleCount } from '../lib/training-audio-provenance.js'
13
13
  import {
14
+ extAudioChunkPath,
15
+ listExtAudioChunks,
14
16
  listMeetingAudioChunks,
15
17
  meetingAudioChunkPath,
16
18
  meetingAudioRetentionDays,
@@ -1151,9 +1153,18 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1151
1153
  res.status(400).json({ error: 'Invalid chunkIndex', reason: 'invalid_chunk_index' })
1152
1154
  return
1153
1155
  }
1156
+ // Archive first, then the live ext-audio the capture path already saved for
1157
+ // unrecognised speakers. The archive is forward-only, so without this
1158
+ // fallback there is nothing to play on any meeting predating 6.21.18 — while
1159
+ // 72 hours of unidentified-voice audio is sitting right there, and an
1160
+ // unidentified voice is exactly what a reviewer needs to hear.
1154
1161
  const path = meetingAudioChunkPath(sessionId, chunkIndex)
1162
+ ?? extAudioChunkPath(sessionId, chunkIndex)
1155
1163
  if (!path) {
1156
- const retained = listMeetingAudioChunks(sessionId)
1164
+ const retained = [...new Set([
1165
+ ...listMeetingAudioChunks(sessionId),
1166
+ ...listExtAudioChunks(sessionId),
1167
+ ])].sort((a, b) => a - b)
1157
1168
  res.status(404).json({
1158
1169
  error: retained.length === 0
1159
1170
  ? 'No audio retained for this meeting'
@@ -1180,11 +1191,17 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1180
1191
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
1181
1192
  return
1182
1193
  }
1183
- const chunks = listMeetingAudioChunks(sessionId)
1194
+ const archived = listMeetingAudioChunks(sessionId)
1195
+ const ext = listExtAudioChunks(sessionId)
1196
+ const chunks = [...new Set([...archived, ...ext])].sort((a, b) => a - b)
1184
1197
  res.json({
1185
1198
  sessionId,
1186
1199
  retained: chunks.length > 0,
1187
1200
  chunks,
1201
+ // Reported separately: ext-audio runs a 72h window, the archive 7 days, so
1202
+ // one retention number would be wrong for half the list.
1203
+ archivedChunks: archived.length,
1204
+ extAudioChunks: ext.length,
1188
1205
  // Config read, not a filesystem walk: this route used to stat every
1189
1206
  // retained chunk to report one number.
1190
1207
  retentionDays: meetingAudioRetentionDays(),