@gotcos/glasses-server 6.36.17 → 6.36.20

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,72 @@
1
+ ## 6.36.20
2
+
3
+ Follow-up to 6.36.19, which was never published. QA found three things in it.
4
+
5
+ **The reset CLI mutated production archives just by importing.**
6
+ `message-era-reset.ts` imported `endSession`/`getActiveSessions` from
7
+ `conversation.js` and used neither -- but that module's scope runs
8
+ `loadFromDisk()` and a boot `runDailyArchiveMirror()`. In the one-shot CLI that
9
+ is a second process concurrently loading and rewriting the archives the live
10
+ server owns, and `appendToArchive` appends rather than upserts, so it can
11
+ duplicate prior-day chats. The import is gone, and a test now asserts it stays
12
+ gone. The previous canary injected an `archiveAndRelease` and asserted it was
13
+ never called, which only ever caught a regression routed through the callback --
14
+ the code it replaced called `endSession()` directly as its default. `sessions`
15
+ and `archiveAndRelease` are deleted from the input type rather than left as a
16
+ silent no-op for a future caller.
17
+
18
+ **A corrupt `message-era.json` was indistinguishable from a reset.**
19
+ `currentMessageEraState` fell back to `legacy` on missing OR invalid content.
20
+ Reverting to `legacy` after a real era existed is the worst available answer: it
21
+ re-reads every era-stamped exchange as a PREVIOUS era, hiding it from
22
+ `/all-messages`, while promoting unstamped ones to current -- and to a client it
23
+ looks exactly like a reset nobody asked for. Missing still means `legacy`, which
24
+ is correct for a first upgrade whose exchanges carry no stamp at all. Corrupt
25
+ now rotates explicitly, says so on stderr, and degrades to `legacy` without
26
+ caching only if the replacement cannot be persisted.
27
+
28
+ **The header described a code path that had been removed.** It still said
29
+ rotation stops "if an archive write fails". There is no archive write. It now
30
+ also records what 6.36.19 quietly changed: this path does NOT archive, and the
31
+ daily mirror skips today by design, so a same-day copy exists only after an
32
+ explicit session end or `POST /api/archive/now`.
33
+
34
+ The 409 copy on both query paths no longer tells the wearer to reopen for a
35
+ "fresh message list" -- as of app 6.8.423 the cards stay.
36
+
37
+ Requires app 6.8.423. Suite 2985 / 211 files, tsc 0. The conversation-import
38
+ canary is mutation-verified: reintroducing the import fails it.
39
+
40
+ ## 6.36.19
41
+ - **Resetting the spoken message count no longer ends the conversation.**
42
+ `resetLiveMessageEra` used to `endSession` every live session before rotating
43
+ the era, so "reset the message count" also emptied CHAT and killed the thread
44
+ the wearer was in the middle of. It now rotates the era and nothing else.
45
+ - Numbers stay unique because they were never bare ints: a number is
46
+ `{messageEra, globalMsgNum}`. The era rotates, the current-era ceiling
47
+ restarts at 0, and leftover cards keep their old era and their old number.
48
+ Lookup already prefers the current era (`message-ref.ts:231-234`) and the
49
+ counter is already era-scoped (`message-ref.ts:242-251`), so "message N" stays
50
+ unambiguous without a second ID scheme.
51
+ - `archived` stays in the response and is now always `0`. Callers read the field,
52
+ so it is kept — but copy that reports it must stop claiming sessions were
53
+ archived. The `archive_failed` 503 path is **gone**, not relocated: there is no
54
+ archive step left to fail.
55
+ - Tests inverted to match. A supplied `archiveAndRelease` is now asserted
56
+ *never to be called*, and a refusing one can no longer block the rotation.
57
+ Mutation-verified: reintroducing the release loop fails the suite.
58
+
59
+ **Ship gate:** do not POST `/api/message-era/reset` from any surface until app
60
+ 6.8.422 is also live. Server-first plus the old companion still wipes local
61
+ messages, and `/sessions/today/all-messages` is era-filtered so recover cannot
62
+ bring the leftover cards back.
63
+
64
+ ## 6.36.18
65
+ - **Meetings list now carries voice-assignment tags.** Each row includes
66
+ `voiceReview` from the sidecar head (`speakers[]`) plus whether a human
67
+ correction landed in the ledger. Control paints NEW / N to name / REVIEWED
68
+ without opening each meeting. Still a 4 KB head read — not a chunk parse.
69
+
1
70
  ## 6.36.17
2
71
  - **Naming a new person from a wrong existing label now creates their voice profile.**
3
72
  Enrolment after `POST /relabel` only fired when `from` was a placeholder (`Ext`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.36.17",
3
+ "version": "6.36.20",
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": {
@@ -18,6 +18,7 @@ import { basename, dirname, join, resolve } from 'node:path'
18
18
  import { discoveredDomains, domainAbbreviation as deriveAbbr, isSafeDomainName as safeName } from './domains.js'
19
19
  import type { MeetingDetail, MeetingMeta } from './meeting-store.js'
20
20
  import { MEETING_SOURCE_MAX_BYTES, meetingDayCountsFromNames, meetingListLimit } from './meeting-store.js'
21
+ import { meetingVoiceReview, parseSidecarListHead } from './meeting-voice-review.js'
21
22
 
22
23
  /**
23
24
  * The four domains of ONE user's COS. Retained as the documented example layout
@@ -151,30 +152,35 @@ const SIDECAR_HEAD_BYTES = 4096
151
152
  * them whole would make listing cost scale with total transcript size — and this
152
153
  * lister already reads every markdown file it finds.
153
154
  */
154
- export function sidecarSessionId(monthDir: string, meetingFilename: string): string | undefined {
155
+ export function sidecarListHints(monthDir: string, meetingFilename: string): {
156
+ sessionId?: string
157
+ speakers: string[]
158
+ } {
155
159
  const sidecarName = meetingFilename.replace(/\.md$/, '.g2-chunks.json')
156
- if (sidecarName === meetingFilename) return undefined
160
+ if (sidecarName === meetingFilename) return { speakers: [] }
157
161
  const path = join(monthDir, sidecarName)
158
162
  let fd: number | null = null
159
163
  try {
160
164
  const linkStat = lstatSync(path)
161
- if (linkStat.isSymbolicLink() || !linkStat.isFile() || linkStat.size === 0) return undefined
165
+ if (linkStat.isSymbolicLink() || !linkStat.isFile() || linkStat.size === 0) return { speakers: [] }
162
166
  const real = realpathSync(path)
163
- if (dirname(real) !== realpathSync(monthDir)) return undefined
167
+ if (dirname(real) !== realpathSync(monthDir)) return { speakers: [] }
164
168
  const stat = statSync(real)
165
169
  fd = openSync(path, 'r')
166
170
  const buffer = Buffer.alloc(Math.min(SIDECAR_HEAD_BYTES, stat.size))
167
171
  const read = readSync(fd, buffer, 0, buffer.length, 0)
168
- const match = buffer.subarray(0, read).toString('utf8')
169
- .match(/"sessionId"\s*:\s*"([A-Za-z0-9:_-]{3,96})"/)
170
- return match ? match[1] : undefined
172
+ return parseSidecarListHead(buffer.subarray(0, read).toString('utf8'))
171
173
  } catch {
172
- return undefined
174
+ return { speakers: [] }
173
175
  } finally {
174
176
  if (fd !== null) { try { closeSync(fd) } catch { /* already closed */ } }
175
177
  }
176
178
  }
177
179
 
180
+ export function sidecarSessionId(monthDir: string, meetingFilename: string): string | undefined {
181
+ return sidecarListHints(monthDir, meetingFilename).sessionId
182
+ }
183
+
178
184
  function envPath(name: string): string | null {
179
185
  const raw = process.env[name]?.trim()
180
186
  if (!raw) return null
@@ -563,8 +569,11 @@ export function listCosOperationsMeetings(options: {
563
569
  meta.recordId = `ops:${domain}:${month}:${file}`
564
570
  meta.mutable = true
565
571
  meta.canonicalRecord = `operations/${domain}/meetings/${month}/${file}`
566
- const sessionId = sidecarSessionId(monthDir, file)
567
- if (sessionId) meta.sessionId = sessionId
572
+ const hints = sidecarListHints(monthDir, file)
573
+ if (hints.sessionId) meta.sessionId = hints.sessionId
574
+ if (hints.speakers.length > 0) {
575
+ meta.voiceReview = meetingVoiceReview(hints.speakers, hints.sessionId)
576
+ }
568
577
  if (options.day && meta.date !== options.day) continue
569
578
  allMeetings.push(meta)
570
579
  } catch { /* skip unreadable files */ }
@@ -94,6 +94,13 @@ export interface MeetingMeta {
94
94
  mutable?: boolean
95
95
  /** Present only when the server can state a truthful local record. */
96
96
  canonicalRecord?: string
97
+ /** Additive. Unique sidecar speakers + whether a human correction landed. */
98
+ voiceReview?: {
99
+ voices: number
100
+ unattributedVoices: number
101
+ namedVoices: number
102
+ humanTouched: boolean
103
+ }
97
104
  }
98
105
 
99
106
  export interface MeetingActionItem {
@@ -0,0 +1,53 @@
1
+ // Cheap voice-assignment stats for the meetings LIST.
2
+ //
3
+ // The Speakers panel's "Meetings to review" row cannot open every sidecar just
4
+ // to paint a tag. The unique `speakers` array sits at the top of the sidecar,
5
+ // so a 4 KB head read already used for `sessionId` is enough. Segment counts
6
+ // stay on the per-meeting review route.
7
+
8
+ import { isUnattributed } from './meeting-speaker-review.js'
9
+ import { readCorrections } from './meeting-corrections.js'
10
+
11
+ export interface MeetingVoiceReview {
12
+ voices: number
13
+ unattributedVoices: number
14
+ namedVoices: number
15
+ humanTouched: boolean
16
+ }
17
+
18
+ export function parseSidecarListHead(head: string): {
19
+ sessionId?: string
20
+ speakers: string[]
21
+ } {
22
+ const sessionId = head.match(/"sessionId"\s*:\s*"([A-Za-z0-9:_-]{3,96})"/)?.[1]
23
+ const block = head.match(/"speakers"\s*:\s*\[([^\]]*)\]/)
24
+ const speakers = block
25
+ ? [...block[1].matchAll(/"((?:\\.|[^"\\])*)"/g)].map(match => match[1].replace(/\\"/g, '"'))
26
+ : []
27
+ return { sessionId, speakers }
28
+ }
29
+
30
+ export function meetingVoiceReview(
31
+ speakers: string[],
32
+ sessionId?: string,
33
+ ): MeetingVoiceReview {
34
+ const labels = [...new Set(speakers.map(name => name.trim()).filter(Boolean))]
35
+ const unattributedVoices = labels.filter(isUnattributed).length
36
+ return {
37
+ voices: labels.length,
38
+ unattributedVoices,
39
+ namedVoices: Math.max(0, labels.length - unattributedVoices),
40
+ humanTouched: sessionWasHumanTouched(sessionId),
41
+ }
42
+ }
43
+
44
+ export function sessionWasHumanTouched(sessionId?: string): boolean {
45
+ if (!sessionId) return false
46
+ try {
47
+ return readCorrections(sessionId).rows.some(
48
+ row => row.phase === 'applied' || row.phase === 'confirmed',
49
+ )
50
+ } catch {
51
+ return false
52
+ }
53
+ }
@@ -1,15 +1,23 @@
1
- // Archive live sessions, then start short-numbering at #1.
2
- // History is retained in day archives; only the current era's ceiling resets.
3
- // Disk mtime on message-era.json is enough — the live server re-reads it.
4
- // Do not rotate the era if a query is in flight or an archive write fails.
5
-
6
- import { endSession, getActiveSessions } from './conversation.js'
1
+ // Rotate the short-number namespace. Live sessions are NOT ended and nothing
2
+ // is archived here: the companion keeps every card, so the reset is a numbering
3
+ // change rather than a history change.
4
+ //
5
+ // Do not rotate the era if a query is in flight.
6
+ //
7
+ // NOTE: this file deliberately imports nothing from ./conversation.js. Importing
8
+ // it runs its module scope -- loadFromDisk() and a boot runDailyArchiveMirror()
9
+ // -- which in the one-shot CLI means a second process concurrently loading and
10
+ // rewriting the same archives the live server owns. appendToArchive appends
11
+ // rather than upserts, so that duplicates prior-day chats.
12
+ //
13
+ // Today's exchanges are NOT archived by this path. The daily mirror skips
14
+ // today by design (conversation.ts: `sessionDay >= todayLocal`), so a same-day
15
+ // copy exists only after an explicit session end or POST /api/archive/now.
7
16
  import {
8
17
  createMessageEra,
9
18
  currentMessageEraState,
10
19
  type MessageEraState,
11
20
  } from './message-era.js'
12
- import type { SessionToArchive } from './archive.js'
13
21
 
14
22
  export class MessageEraResetError extends Error {
15
23
  readonly code: string
@@ -37,8 +45,6 @@ export interface MessageEraResetInput {
37
45
  now?: number
38
46
  activeRuns?: number
39
47
  shuttingDown?: boolean
40
- sessions?: SessionToArchive[]
41
- archiveAndRelease?: (session: SessionToArchive) => Promise<boolean>
42
48
  }
43
49
 
44
50
  async function resolveJobHealth(input: MessageEraResetInput): Promise<{ activeRuns: number; shuttingDown: boolean }> {
@@ -79,26 +85,29 @@ export async function resetLiveMessageEra(input: MessageEraResetInput): Promise<
79
85
  }
80
86
 
81
87
  const previous = currentMessageEraState()
82
- const sessions = input.sessions ?? getActiveSessions()
83
- const archiveAndRelease = input.archiveAndRelease ?? (async (session: SessionToArchive) => {
84
- const result = await endSession(session.id)
85
- if (!result) return true
86
- if (result.exchangeCount > 0 && !result.logged) return false
87
- return true
88
- })
89
88
 
90
- let archived = 0
91
- for (const session of sessions) {
92
- const released = await archiveAndRelease(session)
93
- if (!released) {
94
- throw new MessageEraResetError(
95
- 'archive_failed',
96
- 'Archive failed; message count was not reset.',
97
- 503,
98
- )
99
- }
100
- archived++
101
- }
89
+ // 6.36.19 rotate the era, do NOT end the thread.
90
+ //
91
+ // This used to endSession() every live session before rotating, which is what
92
+ // made "reset the message count" also empty CHAT and kill the conversation the
93
+ // wearer was in the middle of. Miles wants the opposite shape: the next message
94
+ // is #1, the old cards keep their numbers, and the thread survives.
95
+ //
96
+ // Numbers stay unique because they are {messageEra, globalMsgNum}, not a bare
97
+ // int: the era rotates, the current-era ceiling restarts at 0, and leftover
98
+ // cards keep their old era. Lookup already prefers the current era
99
+ // (message-ref.ts:231-234) and the counter is already era-scoped
100
+ // (message-ref.ts:242-251), so nothing downstream needs to change to keep
101
+ // "message N" unambiguous.
102
+ //
103
+ // `archived` stays in the result and is now always 0. Control and the app read
104
+ // it, so removing the field would break them; the copy that reports it has to
105
+ // stop claiming sessions were archived.
106
+ //
107
+ // Deliberately no archive step here. There is nothing to release, so there is
108
+ // no 503 archive_failed path any more — that failure mode is gone rather than
109
+ // relocated. The day-archive mirror already retains history.
110
+ const archived = 0
102
111
 
103
112
  const next: MessageEraState = createMessageEra(input.now ?? Date.now())
104
113
  return {
@@ -55,9 +55,38 @@ export function currentMessageEraState(): MessageEraState {
55
55
  cachedMtimeMs = mtimeMs
56
56
  return cached
57
57
  }
58
- cached = { v: 1, era: LEGACY_MESSAGE_ERA, startedAt: 0 }
59
- cachedMtimeMs = mtimeMs
60
- return cached
58
+ // Missing is not corrupt. A legacy install upgrading for the first time has
59
+ // no file AND no era stamps on its exchanges, and only LEGACY_MESSAGE_ERA
60
+ // classifies an unstamped exchange as current. Rotating here would file every
61
+ // one of them under a previous era and empty the chat on upgrade.
62
+ if (loaded.status === 'missing') {
63
+ cached = { v: 1, era: LEGACY_MESSAGE_ERA, startedAt: 0 }
64
+ cachedMtimeMs = mtimeMs
65
+ return cached
66
+ }
67
+
68
+ // Corrupt, or present-but-invalid: the file existed, so a real era almost
69
+ // certainly did too, and its value is now unrecoverable. Reverting to legacy
70
+ // would be the worst available answer -- it re-reads every era-stamped
71
+ // exchange as a PREVIOUS era (hiding it from /all-messages) while promoting
72
+ // unstamped ones to current, and it is indistinguishable from a reset nobody
73
+ // asked for. Rotate explicitly and say so, so the state is self-consistent
74
+ // and the event is diagnosable.
75
+ console.error(
76
+ `[message-era] ${MESSAGE_ERA_FILE} was unreadable (status=${loaded.status}); `
77
+ + 'rotating to a fresh era. Older messages stay in day archives and remain '
78
+ + 'reachable by session, but not by short number.',
79
+ )
80
+ try {
81
+ return createMessageEra()
82
+ } catch (err) {
83
+ // Read-only or full data dir: degrade to legacy rather than crash the
84
+ // server, but never cache it -- the next call retries the rotation.
85
+ console.error('[message-era] could not persist the replacement era:', err)
86
+ cached = null
87
+ cachedMtimeMs = Number.NaN
88
+ return { v: 1, era: LEGACY_MESSAGE_ERA, startedAt: 0 }
89
+ }
61
90
  }
62
91
 
63
92
  export function currentMessageEra(): string {
@@ -82,7 +82,7 @@ export async function preparePublicDurableQueryAdmission(raw: unknown): Promise<
82
82
  throw new QueryJobAdmissionPreparationError(
83
83
  409,
84
84
  'message_era_mismatch',
85
- 'Reopen or update COS Glasses to start the fresh message list.',
85
+ 'Reopen or update COS Glasses. Your cards stay; the next message is #1.',
86
86
  )
87
87
  }
88
88
  try {
@@ -60,7 +60,7 @@ queryRouter.post('/query', async (req, res) => {
60
60
  maintenanceLease.release()
61
61
  return res.status(409).json({
62
62
  error: 'message_era_mismatch',
63
- detail: 'Reopen or update COS Glasses to start the fresh message list.',
63
+ detail: 'Reopen or update COS Glasses. Your cards stay; the next message is #1.',
64
64
  era: activeMessageEra,
65
65
  })
66
66
  }
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env tsx
2
- // Archives live sessions, then creates a fresh short-number namespace.
3
- // History stays in day archives. Disk mtime is enough — no server restart.
2
+ // Creates a fresh short-number namespace. Live sessions are NOT ended and
3
+ // nothing is archived -- the companion keeps every card. Disk mtime is enough
4
+ // — no server restart.
4
5
  //
5
6
  // npx tsx server/scripts/reset-message-era.ts --confirm
6
7
 
@@ -22,7 +23,7 @@ console.log(JSON.stringify({
22
23
  restartRequired: false,
23
24
  verify: 'GET /api/message-counter should return { max: 0, era: "<era above>" }',
24
25
  nextSteps: [
25
- 'Phone: tap RESET # or reopen the companion so the live list clears',
26
+ 'Phone: reopen the companion cards stay, the next message is #1',
26
27
  'Send a test message — expect #1',
27
28
  ],
28
29
  }, null, 2))