@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 +34 -2
- package/README.md +4 -1
- package/package.json +1 -1
- package/server/lib/dictation-meaning-guard.ts +115 -12
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
|
|
137
|
-
|
|
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.
|
|
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.
|
|
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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
418
|
-
|
|
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
|
-
|
|
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
|