@gotcos/glasses-server 6.91.0 → 6.91.1

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,29 @@
1
+ ## 6.91.1 - The meaning guard reads clock times, so a time format you asked for is not refused
2
+
3
+ 2026-10-25: canary C5 on the pair that ships (Sonnet, v4, instructions and the learned style) found the 6.91.0 guard
4
+ refusing answers that followed the person's own instruction "write times in the form 10:30 am": "at ten" written
5
+ "10:00 am" counted "am" and "00" as added numbers, and the unpolished text went out. 6.90.0 pasted that formatting, so
6
+ this was a step backward for any Sonnet user with a time-format instruction. Fixed before npm latest moves. Zero
7
+ `claude -p` calls.
8
+
9
+ - **Clock times** (`server/lib/dictation-meaning-guard.ts`): every time, spoken or written, in the dictation and in the
10
+ answer, is read as minutes since midnight: "ten", "ten thirty", "half past ten", "quarter to five", "ten o'clock",
11
+ "ten oh five", 10, 10:00, 10:30, am, pm, a.m., p.m., "22:30" and "twenty two thirty". Without am or pm an hour from
12
+ 1 to 12 reads both ways, so "ten thirty pm" written "22:30" and "ten thirty" written "22:30" are kept. am, pm and a
13
+ trailing ":00" are format only and never count as added numbers: "at ten" written "at 10:00 am", "ten thirty
14
+ Central" written "10:30 am Central" and "meet at 9" written "meet at 9:00" are kept. A real change still trips:
15
+ "ten thirty" written "11:30", "7 am" written "7 pm", "3:00 pm" written "3", and a time the speaker never said. A year
16
+ that reads like a time ("nineteen fifty" as "1950") still matches its plain form.
17
+ - **C5 re-checked offline:** the 30 stored C5 answers (synthetic) run through the 6.91.1 guard: 0 of 15 stacked and 0
18
+ of 14 plain refused (6.91.0 refused b02 and b09), so what would go out is 11/11 backtracks on both arms. Every other
19
+ stored synthetic output: still 0 false trips in 356. Route test: on Sonnet v4 with the time-format instruction saved,
20
+ b02 and b09 go out polished, and a changed time is still refused.
21
+ - **Eval:** `STACKED_INSTRUCTIONS` is 985 characters (it was 1,033, over the 1,000 cap, so the reader refused it and
22
+ the 6.91.0 Haiku stacked arm ran without instructions), and the eval now stops if the reader would refuse the set.
23
+ - **Known limit, kept:** the largest possible stack (985-character instructions and all 13 learned-style rules) costs
24
+ Sonnet v4 +4.3% at p50 and **+46.7% at p90** (9,260 vs 6,311 ms, C5 2026-10-25, n=15). No user has that today: the
25
+ learned style starts empty and grows a rule at a time. Prompts are unchanged for it.
26
+
1
27
  ## 6.91.0 - Dictation that learns your voice: prompt v4 on Sonnet, Your style, the meaning guard, an opt-in literal register
2
28
 
3
29
  2026-10-24 22:45: extend AI polish into "a Grammarly but further for voice dictation", remove ungrammatical spoken
@@ -133,8 +159,14 @@ builds the capture, the classifier and Your style.
133
159
  (+3.2%); Sonnet p50 3,024 vs 2,949 (+2.5%), p90 4,460 vs 3,997 (+11.6%). Stacked arm (Haiku, v4 + 1,033-character
134
160
  instructions + the largest learned style, 767 characters): p50 3,147 vs 2,794 (+12.6%), p90 3,646 vs 3,520 (+3.6%),
135
161
  backtracks 11/11. C4 cold, Sonnet: v4 p50 4,346 vs v3 4,713. The absolute Haiku bar is measured after install.
136
- **Pending before npm latest:** the stacked arm on the pair that ships, Sonnet with v4, instructions and the learned
137
- style (about 15 calls). The stacked number above is Haiku, which does not get v4 or the style.
162
+ **Pending before npm latest, run 2026-10-25: DISPROVEN (p90).** The stacked arm on the pair that ships, Sonnet with
163
+ v4, 985-character instructions and the largest learned style (13 rules, 767 characters), against plain Sonnet v4, on
164
+ the 15-case backtrack and new-preserve slice, interleaved, warm (29 of 30 warm; 30 calls, 1 plain call timed out at
165
+ 20 s): p50 4,968 vs 4,762 ms (+4.3%), p90 9,260 vs 6,311 ms (+46.7%, over the +25% bar; 4 of 15 stacked calls took
166
+ 7.9 to 18.0 s, 1 of 14 plain). Backtracks 11/11 in the model's answers on both arms; new preserve 4/4 stacked, 3/3
167
+ plain. What would go out: 9/11 stacked, because the meaning guard refused two answers that followed the instruction
168
+ "write times in the form 10:30 am" ("at ten" as "10:00 am" adds "am" and "00"). The Haiku stacked number above ran
169
+ without its instructions: that set is 1,033 characters, over the 1,000 cap, so the reader refused it.
138
170
  - **C6: PROVEN** (route and golden tests). Every new route is 401 without the token; an old client body works; a skip
139
171
  path sends no `polishOutcome`; a port of the 0.5.343 helper and `choose` logs a guarded answer as
140
172
  `fillers_only:failed` or `skipped:failed`, never applied; all four switches off, and Haiku outside a terminal, give the
package/README.md CHANGED
@@ -875,7 +875,10 @@ built-in polish rules, which keep precedence.
875
875
  `polishOutcome: "guarded"`, `guard: "<kind>"` and `polishSkipped: "failed"`.
876
876
  A self-correction may drop what it replaced. "2 million", "25th",
877
877
  "dont", "half past ten" and a numbered list you asked for read as what you
878
- said. The log line carries counts only. `COS_DICTATION_MEANING_GUARD=0` turns
878
+ said. 6.91.1: clock times compare as minutes since midnight, so "at ten"
879
+ written "10:00 am" and "ten thirty pm" written "22:30" are kept (am, pm and
880
+ ":00" are format only), while "ten thirty" written "11:30" trips. The log
881
+ line carries counts only. `COS_DICTATION_MEANING_GUARD=0` turns
879
882
  it off.
880
883
  - **Finalize answers** add, all optional: `polishOutcome` (`polished`,
881
884
  `unchanged` or `guarded`, about the model step; an empty model answer is a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.91.0",
3
+ "version": "6.91.1",
4
4
  "description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,9 +11,16 @@
11
11
  * "cant"), all one NEG token; never, no, none, nothing, nobody, neither, nor, without, unless,
12
12
  * except;
13
13
  * - number: digits, number words, ordinals ("twenty fifth" is 25, like "25th"), %, $, a digit with a
14
- * scale word ("2 million" is two million, like "two million"), clock times ("half past ten" is
15
- * 10:30, "quarter to five" 4:45) and am/pm. A decimal point splits a number on both sides ("one
16
- * point five" and "1.5" are both 1 then 5);
14
+ * scale word ("2 million" is two million, like "two million"), and clock times. A decimal point
15
+ * splits a number on both sides ("one point five" and "1.5" are both 1 then 5);
16
+ * - times (6.91.1): every clock time, spoken or written, in the dictation and in the answer, is read
17
+ * as minutes since midnight: "ten thirty", "half past ten", "quarter to five", "ten o'clock",
18
+ * "10:30", "10:30 am", "ten thirty pm", "22:30", "twenty two thirty". Without am or pm an hour
19
+ * from 1 to 12 reads both ways, so "ten thirty pm" written "22:30" is kept and "ten thirty" written
20
+ * "11:30" is a changed number. am, pm and a trailing ":00" are format only: "at ten" written
21
+ * "at 10:00 am" is kept. A time is kept when the answer has the same time, when it is on the hour
22
+ * and the answer says just that hour (and the dictation said no am or pm), or when every part of
23
+ * it survives as a plain number ("nineteen fifty" written "1950");
17
24
  * - date: a weekday or a month;
18
25
  * - name: a mid-sentence capital, a dictionary term or a speaker name. A sentence-initial word and
19
26
  * "I" are not names unless they are dictionary terms. Skipped for a dictation that is not English.
@@ -52,8 +59,10 @@ export function meaningGuardEnabled(env: NodeJS.ProcessEnv = process.env): boole
52
59
  return !(raw === '0' || raw === 'false' || raw === 'off')
53
60
  }
54
61
 
55
- interface Tok { raw: string; low: string; sentenceStart: boolean; pause: boolean; listMarker: boolean }
56
- interface Item { kind: MeaningGuardKind; key: string; pos: number; list?: boolean }
62
+ interface Tok { raw: string; low: string; sentenceStart: boolean; pause: boolean; listMarker: boolean; colonNext: boolean }
63
+ interface Item { kind: MeaningGuardKind; key: string; pos: number; list?: boolean; inTime?: boolean; time?: TimeSpan }
64
+ /** A clock time: the tokens it covers, its minutes since midnight (two readings without am/pm), its parts. */
65
+ interface TimeSpan { start: number; end: number; times: number[]; explicit: boolean; parts: string[] }
57
66
 
58
67
  const ONES = new Map(['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve',
59
68
  'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen'].map((w, i) => [w, i]))
@@ -148,7 +157,8 @@ function tokenize(text: string): Tok[] {
148
157
  const sentenceStart = before.length === 0 || /[.!?:\n•]$/.test(before) || /\n\s*(?:[-*•]|\d+[.)])?\s*$/.test(text.slice(0, m.index))
149
158
  // "1." or "2)" at the start of a line: a list number.
150
159
  const listMarker = /^\d+$/.test(m[0]) && /(?:^|\n)[ \t]*$/.test(text.slice(0, m.index)) && /^[.)]/.test(text.slice(m.index + m[0].length))
151
- out.push({ raw: m[0], low: m[0].toLowerCase(), sentenceStart, pause: /[,;.!?—–()]/.test(gap), listMarker })
160
+ const colonNext = /^:\d/.test(text.slice(m.index + m[0].length))
161
+ out.push({ raw: m[0], low: m[0].toLowerCase(), sentenceStart, pause: /[,;.!?—–()]/.test(gap), listMarker, colonNext })
152
162
  last = m.index + m[0].length
153
163
  }
154
164
  return out
@@ -230,6 +240,74 @@ function numberRun(tokens: Tok[], i: number): { value: number; end: number } | n
230
240
  return { value: total + group, end: j - 1 }
231
241
  }
232
242
 
243
+ const MERIDIEM = new Set(['am', 'pm'])
244
+
245
+ /** Minutes since midnight: one reading with am/pm or a 24-hour hour, both readings for 1 to 12 without. */
246
+ function clockMinutes(hour: number, minute: number, meridiem: string | undefined): { times: number[]; explicit: boolean } {
247
+ if (meridiem && hour >= 1 && hour <= 12) return { times: [((hour % 12) + (meridiem === 'pm' ? 12 : 0)) * 60 + minute], explicit: true }
248
+ if (hour >= 1 && hour <= 12) return { times: [(hour % 12) * 60 + minute, ((hour % 12) + 12) * 60 + minute], explicit: false }
249
+ return { times: [hour * 60 + minute], explicit: true }
250
+ }
251
+
252
+ /** The readings of a bare hour ("ten" or "10") as a time on the hour. */
253
+ function hourReadings(n: number): number[] {
254
+ if (n >= 1 && n <= 12) return [(n % 12) * 60, ((n % 12) + 12) * 60]
255
+ return n === 0 || (n >= 13 && n <= 23) ? [n * 60] : []
256
+ }
257
+
258
+ /** A spoken hour: one to nineteen, twenty, twenty one to twenty three. */
259
+ function spokenHour(tokens: Tok[], i: number): { value: number; end: number } | null {
260
+ const w = tokens[i]?.low ?? ''
261
+ const v = ONES.get(w)
262
+ if (v !== undefined) return v >= 1 ? { value: v, end: i } : null
263
+ if (w !== 'twenty') return null
264
+ const ones = ONES.get(tokens[i + 1]?.low ?? '')
265
+ return ones !== undefined && ones >= 1 && ones <= 3 ? { value: 20 + ones, end: i + 1 } : { value: 20, end: i }
266
+ }
267
+
268
+ /** Spoken minutes after an hour: "oh five", ten to nineteen, twenty to fifty nine. */
269
+ function spokenMinutes(tokens: Tok[], i: number): { value: number; end: number } | null {
270
+ const w = tokens[i]?.low ?? ''
271
+ const next = ONES.get(tokens[i + 1]?.low ?? '')
272
+ if (w === 'oh' || w === 'o') return next !== undefined && next >= 1 && next <= 9 ? { value: next, end: i + 1 } : null
273
+ const v = ONES.get(w)
274
+ if (v !== undefined) return v >= 10 ? { value: v, end: i } : null
275
+ const tens = TENS.get(w)
276
+ if (tens === undefined || tens > 50) return null
277
+ return next !== undefined && next >= 1 && next <= 9 ? { value: tens + next, end: i + 1 } : { value: tens, end: i }
278
+ }
279
+
280
+ /** One clock time starting at token i, or null. An hour alone is not a time; it needs minutes or am/pm. */
281
+ function timeAt(tokens: Tok[], i: number): Omit<TimeSpan, 'parts'> | null {
282
+ const t = tokens[i]
283
+ let hour: number, end: number
284
+ if (/^\d{1,2}$/.test(t.low)) { hour = Number.parseInt(t.low, 10); end = i }
285
+ else { const h = spokenHour(tokens, i); if (!h) return null; hour = h.value; end = h.end }
286
+ let minute = 0, hasMinutes = false
287
+ const next = tokens[end + 1]?.low ?? ''
288
+ if (tokens[end].colonNext && /^\d{2}$/.test(next)) { minute = Number.parseInt(next, 10); end++; hasMinutes = true }
289
+ else if (!/^\d/.test(t.low)) { const m = spokenMinutes(tokens, end + 1); if (m) { minute = m.value; end = m.end; hasMinutes = true } }
290
+ const meridiem = MERIDIEM.has(tokens[end + 1]?.low ?? '') ? tokens[end + 1].low : undefined
291
+ if (!hasMinutes && !meridiem) return null
292
+ if (meridiem) end++
293
+ if (hour > 23 || minute > 59) return null
294
+ const { times, explicit } = clockMinutes(hour, minute, meridiem)
295
+ return { start: i, end, times, explicit }
296
+ }
297
+
298
+ /** Every clock time in the text, as time items; the number items inside one become its parts. */
299
+ function addTimes(tokens: Tok[], items: Item[]): void {
300
+ for (let i = 0; i < tokens.length; i++) {
301
+ const found = timeAt(tokens, i)
302
+ if (!found) continue
303
+ const parts = items.filter(it => it.kind === 'number' && it.pos >= found.start && it.pos <= found.end)
304
+ parts.forEach(it => { it.inTime = true })
305
+ const time: TimeSpan = { ...found, parts: parts.map(it => it.key) }
306
+ items.push({ kind: 'number', key: `time:${time.times.join('/')}`, pos: found.start, time })
307
+ i = found.end
308
+ }
309
+ }
310
+
233
311
  interface Extract { items: Item[]; tokens: Tok[] }
234
312
 
235
313
  function extract(text: string, knownPhrases: string[][], withNames: boolean): Extract {
@@ -281,6 +359,7 @@ function extract(text: string, knownPhrases: string[][], withNames: boolean): Ex
281
359
  if (!t.sentenceStart || midCapital.has(fold(t.low))) items.push({ kind: 'name', key: fold(t.low), pos: i })
282
360
  }
283
361
  }
362
+ addTimes(tokens, items)
284
363
  // Dictionary terms and speaker names, in any case and position, as whole phrases.
285
364
  if (withNames) {
286
365
  for (const phrase of knownPhrases) {
@@ -390,13 +469,35 @@ export function meaningGuardProblem(cleaned: string, answer: string, known: stri
390
469
  return respelled
391
470
  }
392
471
 
472
+ // 6.91.1: clock times, compared as minutes since midnight (see the header).
473
+ const inTimes = input.items.filter(i => i.time).map(i => i.time!)
474
+ const outTimes = output.items.filter(i => i.time).map(i => i.time!)
475
+ const bareHours = (items: Item[]) => items.filter(i => i.kind === 'number' && !i.time && !i.inTime && /^\d+$/.test(i.key)).map(i => Number(i.key))
476
+ const inBare = bareHours(input.items)
477
+ const outBare = bareHours(output.items)
478
+ const sameTime = (a: TimeSpan, b: TimeSpan) => a.times.some(t => b.times.includes(t))
479
+ // A bare hour matches a time only on that hour: its readings are all on the hour.
480
+ const onTheHour = (time: TimeSpan, n: number) => hourReadings(n).some(t => time.times.includes(t))
481
+ const numberKept = (key: string) => outKeys.get('number')!.has(key) || keptByJoin.has(key)
482
+ const timeKept = (time: TimeSpan) => outTimes.some(o => sameTime(time, o))
483
+ || (!time.explicit && outBare.some(n => onTheHour(time, n)))
484
+ || time.parts.every(numberKept)
485
+ // A time in the answer is not new when the dictation had it, had its hour ("at ten" as "10:00 am"),
486
+ // or had its parts as numbers. am, pm and ":00" are format only.
487
+ const timeAccounted = (time: TimeSpan) => inTimes.some(i => sameTime(i, time))
488
+ || inBare.some(n => onTheHour(time, n))
489
+ || time.parts.filter(p => !MERIDIEM.has(p) && p !== '0').every(p => inKeys.get('number')!.has(p))
490
+ const kept = (item: Item): boolean => item.kind === 'name' ? nameKept(item.key)
491
+ : item.time ? timeKept(item.time)
492
+ : item.kind === 'number' ? numberKept(item.key) || (/^\d+$/.test(item.key) && outTimes.some(o => onTheHour(o, Number(item.key))))
493
+ : outKeys.get(item.kind)!.has(item.key)
494
+
393
495
  // A dropped token with a correction marker after it and a same-kind replacement the answer kept.
394
496
  const exempt = (item: Item): boolean => {
395
497
  if (item.kind === 'negation') return false
396
498
  for (const { start, end, reach } of spans) {
397
499
  if (start <= item.pos || start - item.pos > DROP_TO_MARKER) continue
398
- const replaced = input.items.some(r => r.kind === item.kind && r.key !== item.key && r.pos > end && r.pos - end <= reach
399
- && (r.kind === 'name' ? nameKept(r.key) : outKeys.get(r.kind)!.has(r.key)))
500
+ const replaced = input.items.some(r => r.kind === item.kind && r.key !== item.key && r.pos > end && r.pos - end <= reach && kept(r))
400
501
  if (replaced) return true
401
502
  }
402
503
  return false
@@ -414,9 +515,9 @@ export function meaningGuardProblem(cleaned: string, answer: string, known: stri
414
515
  const id = `${item.kind}:${item.key}`
415
516
  if (seen.has(id)) continue
416
517
  if (item.kind === 'negation' && countNegations) continue
417
- const kept = item.kind === 'name' ? nameKept(item.key)
418
- : outKeys.get(item.kind)!.has(item.key) || (item.kind === 'number' && keptByJoin.has(item.key))
419
- if (kept) { seen.add(id); continue }
518
+ // A number inside a clock time is judged with its time.
519
+ if (item.inTime) continue
520
+ if (kept(item)) { seen.add(id); continue }
420
521
  // Every occurrence must be exempt for a drop to be excused.
421
522
  const occurrences = input.items.filter(i => i.kind === item.kind && i.key === item.key)
422
523
  seen.add(id)
@@ -437,7 +538,9 @@ export function meaningGuardProblem(cleaned: string, answer: string, known: stri
437
538
  const outNumberItems = output.items.filter(i => i.kind === 'number')
438
539
  for (const key of outNumbers) if (outNumberItems.filter(i => i.key === key).every(i => i.list)) listOnly.add(key)
439
540
  }
440
- for (const key of outNumbers) if (!inKeys.get('number')!.has(key) && !joinedOut.has(key) && !listOnly.has(key)) added.number++
541
+ const plainOut = new Set(output.items.filter(i => i.kind === 'number' && !i.time && !i.inTime).map(i => i.key))
542
+ for (const key of plainOut) if (!inKeys.get('number')!.has(key) && !joinedOut.has(key) && !listOnly.has(key)) added.number++
543
+ for (const time of outTimes) if (!timeAccounted(time)) added.number++
441
544
 
442
545
  const kind = MEANING_GUARD_KINDS.find(k => dropped[k] > 0 || added[k] > 0)
443
546
  return kind ? { kind, dropped, added, namesSkipped: !withNames } : null