@gotcos/glasses-server 6.27.3 → 6.27.5

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,57 @@
1
+ ## 6.27.5
2
+ - **Chunk uploads now leave a server-side trace.** On 2026-08-12 a phone upload
3
+ stalled on both media transports and the server was a complete blind spot: nothing
4
+ recorded that a chunk request had arrived, so "the client never got the ack" could
5
+ not be separated from "the server never sent one" without reading a staging file's
6
+ mtime and running `netstat` by hand.
7
+ - `[media-chunk]` now logs one line per REQUEST (never per data event - a 237-chunk
8
+ upload logging per `data` would bury the log): `body-read` when the body finishes
9
+ arriving, `responded` with status when the response is fully flushed to the socket,
10
+ `abandoned` when the client goes away mid-body, and `closed-unanswered` when the
11
+ socket closes with no response written. Each carries bytes received and elapsed ms.
12
+ - `closed-unanswered` is the only case where blaming the server is correct, and it is
13
+ now stated explicitly rather than inferred from absence of evidence. It gates on
14
+ **`res` 'close', not `req` 'close'**: since Node 16 `IncomingMessage` emits 'close'
15
+ when the REQUEST completes rather than when the socket does, so on an async handler
16
+ (`putOriginal` and `putFrame` both are) it lands while `res.writableEnded` is still
17
+ false. Gated on `req` it false-fired on EVERY successful V2 chunk — 237 bogus alarms
18
+ per upload on the single most diagnostic line in the file. Caught by adversarial QA
19
+ before release and pinned by a test that fails if the gate moves back.
20
+ - The two early refusals (`chunk_bytes_required` 400 and declared-Content-Length 413)
21
+ returned BEFORE the tracer existed, so a chunk the server actively rejected logged
22
+ nothing — the same silence as a request that never arrived. The tracer is now
23
+ declared above them and every refusal logs.
24
+ - Diagnosis only. No behaviour change to any upload path.
25
+
26
+ ## 6.27.4
27
+
28
+ ### Fuzzy name corrections reach phone dictation
29
+
30
+ - `applyFuzzyCorrections` is now called inside `cleanOutboundDictation`, so text
31
+ arriving at `POST /dictation/finalize` gets the same Levenshtein pass the
32
+ server-transcription route has always had. It previously had exactly ONE call site
33
+ (`transcribe-audio.ts:252`), which meant phone Moonshine dictation — which sends
34
+ text, never audio — never received it. Verified by call-site enumeration, not by
35
+ reading a single file.
36
+ - Runs BEFORE the autoclean LLM, so the model sees corrected proper nouns instead of
37
+ being asked to guess at them, and it still helps on every path where autoclean is
38
+ off, over the character cap, or breaker-open.
39
+ - Same target construction (`getAllSpeakerNames()` + `getVocabulary()`) and the same
40
+ non-fatal posture as the existing call site: a correction pass is quality
41
+ enhancement, never a durability dependency.
42
+
43
+ **Measured reach, so callers do not assume more than it delivers.** The distance
44
+ budget is 1 edit for 5-8 character words, so it catches single-edit misses
45
+ (`Austen` → Austin, `Nyala` → Niala) but NOT `Miyala` → Niala (2 edits) or
46
+ `Yukoma` → Ukaoma (3). Wiring it does **not** remove the need for explicit
47
+ `whisper_corrections` entries on multi-edit misses; the new test asserts both
48
+ directions so that is not re-derived later.
49
+
50
+ `Austin` deliberately untouched — `correctAustinJustin()` already owns that pair.
51
+
52
+ Pairs with app 6.8.346, which breadcrumbs the finalize call so a missing correction
53
+ can be told apart from a finalize step that never ran.
54
+
1
55
  ## 6.27.3
2
56
 
3
57
  ### Durable, resumable video transport (private canary)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.27.3",
3
+ "version": "6.27.5",
4
4
  "description": "COS Glasses \u2014 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": {
@@ -255,7 +255,42 @@ export function createMediaChunkBodyParser(options: MediaChunkParserOptions = {}
255
255
  return
256
256
  }
257
257
 
258
+ // Chunk-upload lifecycle trace.
259
+ //
260
+ // WHY. On 2026-08-12 a phone upload stalled on BOTH transports and the server side
261
+ // was a complete blind spot: nothing recorded that a chunk request had even
262
+ // arrived, so "the client never got the ack" could not be separated from "the
263
+ // server never sent one" without reading a staging file's mtime and running
264
+ // netstat by hand.
265
+ //
266
+ // SAMPLED like the client's breadcrumbs, and for the same reason. One line per
267
+ // REQUEST is still 2 rows x 248 chunks per video (~500), and at the advertised
268
+ // 2 GB ceiling ~16,400 rows and ~1.5 MB of log for ONE upload, into a LaunchAgent
269
+ // stdout with no rotation. Routine progress is sampled; the DIAGNOSTIC events —
270
+ // abandoned, closed-unanswered, too-large, and any non-2xx — are never sampled
271
+ // away, because those are the rows an investigation actually needs.
272
+ //
273
+ // Declared ABOVE the early refusals below: those used to return before this
274
+ // existed, so a chunk the server actively refused logged nothing at all — the
275
+ // same silence as a request that never arrived.
276
+ const openedAt = Date.now()
277
+ // `?? ''` is load-bearing: a request with neither field (the direct-parser test
278
+ // harness, and any non-Express caller) made `path.slice` throw and took the whole
279
+ // parser down. A tracer must never be able to break the thing it observes.
280
+ const path = req.originalUrl?.split('?')[0] ?? req.url ?? ''
281
+ const traceTarget = `${req.method} ${path}`
282
+ // Trailing segment is the chunk index on both routes
283
+ // (`/media/upload/:id/:index` and `/media/video-upload/:id/original/:index`).
284
+ const chunkIndex = Number(path.slice(path.lastIndexOf('/') + 1))
285
+ const sampled = !Number.isFinite(chunkIndex) || chunkIndex === 0 || chunkIndex % 32 === 0
286
+ let received = 0
287
+ const logLifecycle = (event: string, extra = '', always = false): void => {
288
+ if (!always && !sampled) return
289
+ console.log(`[media-chunk] ${event} ${traceTarget} bytes=${received} +${Date.now() - openedAt}ms${extra}`)
290
+ }
291
+
258
292
  const refuse = (status: number, body: Record<string, unknown>): void => {
293
+ logLifecycle('refused', ` status=${status} error=${String(body.error ?? '')}`, true)
259
294
  res.status(status).json(body)
260
295
  res.once('finish', () => req.destroy())
261
296
  }
@@ -279,7 +314,6 @@ export function createMediaChunkBodyParser(options: MediaChunkParserOptions = {}
279
314
  }
280
315
 
281
316
  const parts: Buffer[] = []
282
- let received = 0
283
317
  let settled = false
284
318
 
285
319
  req.on('data', (chunk: Buffer) => {
@@ -288,6 +322,7 @@ export function createMediaChunkBodyParser(options: MediaChunkParserOptions = {}
288
322
  if (received > maxChunkBytes) {
289
323
  settled = true
290
324
  parts.length = 0
325
+ logLifecycle('too-large', ` max=${maxChunkBytes}`)
291
326
  refuse(413, { error: 'attachment_too_large', maxBytes: maxChunkBytes })
292
327
  return
293
328
  }
@@ -297,12 +332,33 @@ export function createMediaChunkBodyParser(options: MediaChunkParserOptions = {}
297
332
  if (settled) return
298
333
  settled = true
299
334
  parts.length = 0
335
+ // The client went away mid-body. Distinguishes a phone that stopped sending
336
+ // from a server that stopped answering — opposite diagnoses, same silence.
337
+ logLifecycle('abandoned')
300
338
  }
301
339
  req.once('aborted', abandon)
302
340
  req.once('error', abandon)
341
+ // Fires when the response is fully flushed to the socket. Paired with 'body-read'
342
+ // this is the proof that the server answered, and how fast.
343
+ res.once('finish', () => {
344
+ logLifecycle('responded', ` status=${res.statusCode}`)
345
+ })
346
+ // `res` 'close', NOT `req` 'close'. Since Node 16 `IncomingMessage` emits 'close'
347
+ // when the REQUEST completes, not when the socket does — so on any async handler
348
+ // it fires while `res.writableEnded` is still false. `putOriginal`/`putFrame` are
349
+ // both async, so gating on `req` made this false-fire on EVERY successful V2
350
+ // chunk: 237 bogus rows per upload, on the single line the changelog calls "the
351
+ // only case where blaming the server is correct". A watcher that cries wolf on the
352
+ // happy path is worse than no watcher. `res` 'close' fires after the response is
353
+ // finished or genuinely aborted, so the guard means what it says.
354
+ res.once('close', () => {
355
+ if (res.writableEnded) return
356
+ logLifecycle('closed-unanswered', '', true)
357
+ })
303
358
  req.once('end', () => {
304
359
  if (settled) return
305
360
  settled = true
361
+ logLifecycle('body-read')
306
362
  chunkBodies.set(req, Buffer.concat(parts))
307
363
  next()
308
364
  })
@@ -35,6 +35,8 @@ import {
35
35
  applyNegativeRules,
36
36
  } from '../lib/hallucination-filter.js'
37
37
  import { applyCorrections } from '../lib/whisper-local.js'
38
+ import { applyFuzzyCorrections } from '../lib/fuzzy-correct.js'
39
+ import { getAllSpeakerNames } from '../lib/speaker-embeddings.js'
38
40
  import { transcribeWhisperPreview } from '../lib/whisper-preview.js'
39
41
  import { autoCleanDictation, AUTOCLEAN_MAX_CHARS } from '../lib/dictation-clean.js'
40
42
  import { getVocabulary } from '../lib/profile.js'
@@ -104,6 +106,27 @@ function routeAutoClean(req: { body?: any; query?: any }): AutoCleanRequest {
104
106
 
105
107
  async function cleanOutboundDictation(text: string, opts: AutoCleanRequest & { signal?: AbortSignal }): Promise<string> {
106
108
  let cleaned = applyNegativeRules(applyCorrections(text)).replace(/\s+/g, ' ').trim() || text
109
+ // applyCorrections above is an EXACT string map, so it only fixes misspellings someone
110
+ // already hand-authored. A novel miss ("Miyala" for Niala, "Yukoma" for Ukaoma) sails
111
+ // through it. The Levenshtein pass is what catches those, and until now it had exactly
112
+ // ONE call site — transcribe-audio.ts:252, the server-transcription route — so phone
113
+ // Moonshine dictation, which arrives here as text, never got it. Same construction and
114
+ // same non-fatal posture as that site, deliberately: targets are speaker names plus
115
+ // vocabulary, and a throw must never cost the user their transcript.
116
+ //
117
+ // Runs BEFORE the autoclean LLM so the model sees corrected proper nouns rather than
118
+ // being asked to guess at them, and it still helps on every path where autoclean is
119
+ // off, over the char cap, or breaker-open.
120
+ try {
121
+ const fuzzyTargets = [...getAllSpeakerNames(), ...getVocabulary()]
122
+ const { text: corrected, replacements } = applyFuzzyCorrections(cleaned, fuzzyTargets)
123
+ if (replacements > 0) {
124
+ console.log(`[prompt-draft] Fuzzy corrected ${replacements} word(s)`)
125
+ cleaned = corrected
126
+ }
127
+ } catch (fuzzyErr: any) {
128
+ console.warn(`[prompt-draft] Fuzzy correction failed (non-fatal): ${fuzzyErr?.message ?? fuzzyErr}`)
129
+ }
107
130
  if (!(opts.enabled ?? autoCleanDefaultEnabled())) return cleaned
108
131
  if (cleaned.length > AUTOCLEAN_MAX_CHARS || autoCleanBreaker.isOpen() || autoCleanCountToday() >= autoCleanDailyCap()) return cleaned
109
132
  const startedAt = Date.now()