@gotcos/glasses-server 6.45.4 → 6.46.0

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,25 @@
1
+ ## 6.46.0
2
+
3
+ Name held voice samples and preview the meeting labels they will change. Paired Control build: 0.5.223.
4
+
5
+ - Individual held samples can suggest an existing voice using the same second-best-sample scoring as groups, with a clear margin and owner-proximity caution. Suggestions remain proposals.
6
+ - Naming returns a server-stored preview first. Apply checks the preview, voice store, raw chunk map and each meeting copy again, holds the COS sync lock where available, and writes chunk-scoped confirmations. Wider matches must agree with the named samples and the updated voice profile in their source meeting.
7
+ - Corrected labels reach HQ word speakers, live chunks and operations copies. Transcript words remain unchanged; only fully aligned turn labels change. Newer speaker corrections survive re-import. Search catches up through the existing indexing queue; graph staleness is explicit.
8
+ - Each naming has durable copy receipts and Undo. Interrupted work requires review. Undo restores eligible labels, retains enrolled voice samples, and never resurrects deleted audio. Held audio is deleted after successful meeting receipts.
9
+ - Existing-name matching resolves stored spelling before preview. Owner naming requires an explicit acknowledgement and stronger acoustic match. Older clients cannot apply the new naming contract without a preview.
10
+ - Voice-profile merges retain stronger source evidence before applying oldest-first eviction within a source tier. Training audio has its own retention capacity, independent of profile size, and freed slots are usable without a restart.
11
+ - Compatibility: 6.45.5 safely ignores chunk-scoped confirmations. It does not understand reverted batch history; keep 6.46.0 for naming recovery and Undo history after rollback.
12
+
13
+ ## 6.45.5
14
+
15
+ Sessions carry their last real activity and last tool (2026-09-13), for the phone Sessions list in COS Glasses 6.9.470.
16
+
17
+ - `GET /api/agent-sessions` rows and `GET /api/agent-sessions/:provider/:sessionId` add `last_activity_at` (ISO time of the newest Claude user or assistant record, or the newest Codex `response_item`) and `last_tool` (the most recent tool call in the latest turn: Claude `tool_use`, Codex function, custom tool, local shell and web search calls) when the transcript has them. Both are omitted otherwise, and Cursor rows are unchanged, so an older client sees the payload it always did.
18
+ - Why: a Claude Desktop relaunch appends bookkeeping records (`queue-operation` and similar) to every open transcript, so the file time moved with nobody working. On 2026-09-13, 18 of 60 listed rows shared one relaunch timestamp and sorted as the newest sessions on the Mac.
19
+ - `running_active` ("working now") is judged by the last real record when it is known and older than the file write (`activityClockMs`), so a relaunch no longer reads as 30 seconds of work on every held session.
20
+ - Cost: `lib/agent-session-activity.ts` reads the last 256 KiB of each listed transcript and widens x4 up to 16 MiB only when that tail holds no conversation record (measured record sizes reach 1.7 MB for Claude and 11.9 MB for Codex). Reads are memoized on mtime and size, least recently used evicted first, and at most 8 transcripts are read at once. Rows carry their resolved transcript path server-side only; `toEntry` projects named fields, so the path never reaches the wire.
21
+ - Tests: `agent-session-activity.test.ts` (relaunch bookkeeping ignored, newest tool of the latest turn only, a tool result keeps the turn open whichever marker it carries, a parsing half line dropped, every Codex tool kind, widening and its cap, re-reads on mtime or size change and not otherwise, LRU eviction, the working-now clock, bounded concurrency) and route tests for a Codex thread and a Claude thread after a relaunch, with no path on the list or the detail, plus a Claude thread this process holds: a relaunch write does not read as working on the list or the detail, and a record inside the 30 s window still does.
22
+
1
23
  ## 6.45.4
2
24
 
3
25
  Two things from 2026-09-12: a dictated prompt that ended in a sentence nobody said, and held voices that could only be named one meeting at a time. Both went through a four-validator QA pass against a copy of Miles's live store before they shipped; what that pass corrected is recorded inline.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.45.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",
3
+ "version": "6.46.0",
4
+ "description": "COS Glasses \u2014 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": {
7
7
  "glasses-server": "bin/cli.cjs",
package/server/index.ts CHANGED
@@ -51,6 +51,7 @@ import { promptDraftsRouter } from './routes/prompt-drafts.js'
51
51
  import { cliDebugRouter } from './routes/cli-debug.js'
52
52
  import { maintenanceRouter } from './routes/maintenance.js'
53
53
  import { ttsRouter } from './routes/tts.js'
54
+ import { recoverInterruptedNaming } from './lib/held-naming-batches.js'
54
55
  import { voiceRouter } from './routes/voice.js'
55
56
  import { glossaryRouter } from './routes/glossary.js'
56
57
  import { handoffsRouter } from './routes/handoffs.js'
@@ -684,6 +685,7 @@ app.use('/api', promptDraftsRouter)
684
685
  app.use('/api', cliDebugRouter)
685
686
  app.use('/api', maintenanceRouter)
686
687
  app.use('/api', ttsRouter)
688
+ recoverInterruptedNaming()
687
689
  app.use('/api', voiceRouter)
688
690
  app.use('/api', glossaryRouter)
689
691
  app.use('/api', handoffsRouter)
@@ -0,0 +1,200 @@
1
+ // When a Claude or Codex session last DID something, and the last tool it called.
2
+ //
3
+ // 6.45.5. Miles, 2026-09-13, on the phone's Sessions list: the status and the order should
4
+ // be "reflective". They were not, because both came from the transcript FILE's write time.
5
+ // A Claude Desktop relaunch appends bookkeeping records (`queue-operation` and friends) to
6
+ // every open transcript, so 18 of 60 rows shared one relaunch timestamp and sorted as the
7
+ // newest work on the Mac while their last real turn was days old.
8
+ //
9
+ // The answer is in the records themselves: Claude writes a `timestamp` on every user and
10
+ // assistant record, and Codex on every `response_item`. The newest record is at the end,
11
+ // so the reader starts from the tail and widens only when the tail holds no conversation
12
+ // record. Records can be very large: measured on this Mac over seven days, the biggest
13
+ // single records were 1.7 MB (Claude) and 11.9 MB (Codex). Reads are memoized on the
14
+ // file's mtime and size, so a steady list poll re-reads nothing that did not change.
15
+ //
16
+ // Cursor agent transcripts carry no per-record clock, so they answer null and the client
17
+ // keeps using the file time, exactly as before.
18
+
19
+ import { open, stat } from 'node:fs/promises'
20
+ import { AGENT_SESSION_LIST_MAX, type AgentProvider } from './agent-session-store.js'
21
+
22
+ /** First window read from the end of a transcript. */
23
+ export const ACTIVITY_TAIL_BYTES = 256 * 1024
24
+ /** Largest window the reader widens to (x4 each step) when the tail holds no conversation record. */
25
+ export const ACTIVITY_TAIL_MAX_BYTES = 16 * 1024 * 1024
26
+ /** Memo entries kept, least recently used evicted first: several full lists' worth. */
27
+ export const ACTIVITY_MEMO_MAX = AGENT_SESSION_LIST_MAX * 6
28
+ /** Transcripts read at once on a cold list, so 80 rows never hold 80 descriptors. */
29
+ export const ACTIVITY_READ_CONCURRENCY = 8
30
+
31
+ export interface SessionActivity {
32
+ /** ISO time of the newest user or assistant record (Claude) or response item (Codex). */
33
+ lastActivityAt: string | null
34
+ /** Name of the most recent tool call in the latest turn. */
35
+ lastTool: string | null
36
+ }
37
+
38
+ const NONE: SessionActivity = Object.freeze({ lastActivityAt: null, lastTool: null })
39
+ const memo = new Map<string, { mtimeMs: number; size: number; value: SessionActivity }>()
40
+ let fileReads = 0
41
+
42
+ const CODEX_TOOL_KINDS: Record<string, (payload: Record<string, unknown>) => string | null> = {
43
+ function_call: payload => (typeof payload.name === 'string' && payload.name ? payload.name : null),
44
+ custom_tool_call: payload => (typeof payload.name === 'string' && payload.name ? payload.name : null),
45
+ local_shell_call: () => 'local_shell',
46
+ web_search_call: () => 'web_search',
47
+ }
48
+
49
+ function isoOrNull(value: unknown): string | null {
50
+ if (typeof value !== 'string' || !value) return null
51
+ const ms = Date.parse(value)
52
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null
53
+ }
54
+
55
+ function claudeToolName(message: unknown): string | null {
56
+ if (!message || typeof message !== 'object') return null
57
+ const content = (message as { content?: unknown }).content
58
+ if (!Array.isArray(content)) return null
59
+ for (let i = content.length - 1; i >= 0; i--) {
60
+ const block = content[i] as { type?: unknown; name?: unknown } | null
61
+ if (block && block.type === 'tool_use' && typeof block.name === 'string' && block.name) return block.name
62
+ }
63
+ return null
64
+ }
65
+
66
+ /** A user record that starts a turn, as opposed to one that returns a tool result. */
67
+ function claudeTurnStart(obj: Record<string, unknown>): boolean {
68
+ if (obj.type !== 'user' || obj.toolUseResult) return false
69
+ const content = (obj.message as { content?: unknown } | undefined)?.content
70
+ return !(Array.isArray(content) && content.some(block => (block as { type?: unknown } | null)?.type === 'tool_result'))
71
+ }
72
+
73
+ /**
74
+ * Scan transcript text from the end. `startsMidFile` drops the first line, which a tail
75
+ * read almost always cuts in half.
76
+ *
77
+ * The last tool is looked for only inside the latest turn: once the scan passes the user
78
+ * message that started it, an older tool would describe work that is already over.
79
+ */
80
+ export function activityFromTail(provider: AgentProvider, text: string, startsMidFile: boolean): SessionActivity {
81
+ if (provider === 'cursor') return NONE
82
+ const lines = text.split('\n')
83
+ if (startsMidFile) lines.shift()
84
+ let lastActivityAt: string | null = null
85
+ let lastTool: string | null = null
86
+ let turnOpen = true
87
+ for (let i = lines.length - 1; i >= 0; i--) {
88
+ if (lastActivityAt !== null && (lastTool !== null || !turnOpen)) break
89
+ const line = lines[i].trim()
90
+ if (!line) continue
91
+ let obj: Record<string, unknown>
92
+ try {
93
+ const parsed: unknown = JSON.parse(line)
94
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue
95
+ obj = parsed as Record<string, unknown>
96
+ } catch {
97
+ continue
98
+ }
99
+ if (provider === 'claude') {
100
+ // Only conversation records count. Bookkeeping (`queue-operation`, `summary`,
101
+ // `custom-title`, `mode`...) is exactly what a relaunch writes.
102
+ if (obj.type !== 'user' && obj.type !== 'assistant') continue
103
+ if (lastActivityAt === null) lastActivityAt = isoOrNull(obj.timestamp)
104
+ if (turnOpen && lastTool === null && obj.type === 'assistant') lastTool = claudeToolName(obj.message)
105
+ if (claudeTurnStart(obj)) turnOpen = false
106
+ } else {
107
+ if (obj.type !== 'response_item' || !obj.payload || typeof obj.payload !== 'object') continue
108
+ const payload = obj.payload as Record<string, unknown>
109
+ if (lastActivityAt === null) lastActivityAt = isoOrNull(obj.timestamp)
110
+ const toolName = CODEX_TOOL_KINDS[String(payload.type ?? '')]
111
+ if (turnOpen && lastTool === null && toolName) lastTool = toolName(payload)
112
+ if (payload.type === 'message' && payload.role === 'user') turnOpen = false
113
+ }
114
+ }
115
+ return { lastActivityAt, lastTool }
116
+ }
117
+
118
+ /**
119
+ * Read one transcript's activity, memoized on mtime and size. Starts with the last
120
+ * ACTIVITY_TAIL_BYTES and widens x4 up to ACTIVITY_TAIL_MAX_BYTES only while no
121
+ * conversation record has been found. Any failure answers null fields.
122
+ */
123
+ export async function readSessionActivity(
124
+ provider: AgentProvider,
125
+ file: string,
126
+ opts: { memoMax?: number } = {},
127
+ ): Promise<SessionActivity> {
128
+ if (provider === 'cursor' || !file) return NONE
129
+ try {
130
+ const st = await stat(file)
131
+ const hit = memo.get(file)
132
+ if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) {
133
+ memo.delete(file)
134
+ memo.set(file, hit)
135
+ return hit.value
136
+ }
137
+ let value: SessionActivity = NONE
138
+ const handle = await open(file, 'r')
139
+ fileReads += 1
140
+ try {
141
+ for (let window = ACTIVITY_TAIL_BYTES; ; window *= 4) {
142
+ const length = Math.min(st.size, window)
143
+ const start = st.size - length
144
+ const buffer = Buffer.alloc(length)
145
+ const { bytesRead } = await handle.read(buffer, 0, length, start)
146
+ value = activityFromTail(provider, buffer.subarray(0, bytesRead).toString('utf8'), start > 0)
147
+ if (value.lastActivityAt !== null || start === 0 || window >= ACTIVITY_TAIL_MAX_BYTES) break
148
+ }
149
+ } finally {
150
+ await handle.close()
151
+ }
152
+ const max = Math.max(1, opts.memoMax ?? ACTIVITY_MEMO_MAX)
153
+ memo.delete(file)
154
+ while (memo.size >= max) {
155
+ const oldest = memo.keys().next().value
156
+ if (oldest === undefined) break
157
+ memo.delete(oldest)
158
+ }
159
+ memo.set(file, { mtimeMs: st.mtimeMs, size: st.size, value })
160
+ return value
161
+ } catch {
162
+ return NONE
163
+ }
164
+ }
165
+
166
+ /**
167
+ * The clock "working now" is judged by: the last real record when it is known and older
168
+ * than the file write, else the write time. A relaunch's bookkeeping write moves the file
169
+ * time with nobody working, and used to read as 30 seconds of work on every held session.
170
+ */
171
+ export function activityClockMs(mtimeMs: number, activity: SessionActivity | null | undefined): number {
172
+ const at = activity?.lastActivityAt ? Date.parse(activity.lastActivityAt) : Number.NaN
173
+ return Number.isFinite(at) && at < mtimeMs ? at : mtimeMs
174
+ }
175
+
176
+ /** Map with at most `limit` promises in flight, preserving order. */
177
+ export async function mapWithConcurrency<T, R>(
178
+ items: readonly T[],
179
+ limit: number,
180
+ fn: (item: T, index: number) => Promise<R>,
181
+ ): Promise<R[]> {
182
+ const out = new Array<R>(items.length)
183
+ let next = 0
184
+ const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
185
+ while (next < items.length) {
186
+ const index = next++
187
+ out[index] = await fn(items[index], index)
188
+ }
189
+ })
190
+ await Promise.all(workers)
191
+ return out
192
+ }
193
+
194
+ /** Test seams. */
195
+ export function clearSessionActivityMemo(): void {
196
+ memo.clear()
197
+ }
198
+ export function sessionActivityFileReads(): number {
199
+ return fileReads
200
+ }
@@ -50,6 +50,10 @@ export interface AgentSessionRow {
50
50
  pinned: boolean
51
51
  first_prompt?: string
52
52
  discussion_summary?: string
53
+ /** 6.45.5: the transcript this row was read from, when the walk resolved one. Server
54
+ * only: `toEntry` projects named fields, so this never reaches the wire. It lets the
55
+ * list read each row's last activity without resolving the file a second time. */
56
+ file?: string
53
57
  }
54
58
 
55
59
  export interface AgentSessionRoots {
@@ -1076,6 +1080,7 @@ export async function listClaudeSessions(
1076
1080
  provider: 'claude',
1077
1081
  display_label: title,
1078
1082
  project,
1083
+ file: candidate.file,
1079
1084
  modified: isoFromMtime(candidate.mtimeMs),
1080
1085
  created: isoFromMtime(candidate.birthtimeMs),
1081
1086
  alive: false,
@@ -1159,6 +1164,7 @@ export async function listCodexSessions(
1159
1164
  provider: 'codex',
1160
1165
  display_label: title,
1161
1166
  project: workspaceLabel(meta.cwd),
1167
+ file: candidate.file,
1162
1168
  modified: isoFromMtime(candidate.mtimeMs),
1163
1169
  created,
1164
1170
  alive: false,
@@ -1288,6 +1294,7 @@ async function enrichLiveClaude(row: AgentSessionRow, roots: AgentSessionRoots,
1288
1294
  ...row,
1289
1295
  session_id: fullId,
1290
1296
  modified,
1297
+ file: found,
1291
1298
  display_label: title || row.display_label || 'Claude session',
1292
1299
  ...discussionFields(title || row.display_label, firstPrompt || '', peek.latestAssistant),
1293
1300
  }
@@ -462,7 +462,7 @@ export function findCosOperationsMeetingBySessionId(sessionId: string): {
462
462
  const monthDir = join(meetingsBase, month)
463
463
  let sidecars: string[]
464
464
  try {
465
- sidecars = readdirSync(monthDir).filter(f => f.endsWith('.g2-chunks.json')).sort().reverse()
465
+ sidecars = readdirSync(monthDir).filter(f => f.endsWith('.g2-chunks.json') && !/ \d+(\.[A-Za-z0-9-]+)*\.json$/.test(f)).sort().reverse()
466
466
  } catch { continue }
467
467
 
468
468
  for (const sidecarName of sidecars) {
@@ -23,6 +23,9 @@ const DOMAIN_REVIEW_MARKER = '<!-- g2-needs-domain-review -->'
23
23
  const HQ_PENDING_MARKER = '<!-- g2-hq-state: pending -->'
24
24
  const OPERATIONS_OWNED_SIDECAR_FIELDS = new Set([
25
25
  'blended_into',
26
+ 'claimed_parent',
27
+ 'correctionRevision',
28
+ 'labelsNewerThanGraph',
26
29
  'dedupEvidence',
27
30
  'enrichmentState',
28
31
  'finalPath',
@@ -124,6 +127,12 @@ function parseSidecar(path: string): Record<string, unknown> | null {
124
127
  }
125
128
  }
126
129
 
130
+ export function g2Revision(value: unknown): number {
131
+ if (value === undefined || value === null) return 0
132
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) throw new Error('Invalid G2 revision')
133
+ return value
134
+ }
135
+
127
136
  /** Merge server-owned capture truth while preserving operations-owned match state. */
128
137
  export function mergeG2OperationsSidecar(
129
138
  sourcePath: string,
@@ -139,8 +148,8 @@ export function mergeG2OperationsSidecar(
139
148
  throw new Error(`Refusing G2 sidecar merge across sessions (${existingSession} != ${sourceSession})`)
140
149
  }
141
150
 
142
- const priorRevision = Number(existing.lifecycleRevision ?? 0)
143
- const nextRevision = Math.max(Number(options.revision ?? 0), Number(source.lifecycleRevision ?? 0))
151
+ const priorRevision = g2Revision(existing.lifecycleRevision)
152
+ const nextRevision = Math.max(g2Revision(options.revision), g2Revision(source.lifecycleRevision))
144
153
  if (Number.isFinite(priorRevision) && Number.isFinite(nextRevision) && nextRevision < priorRevision) {
145
154
  throw new Error(`Refusing regressing G2 sidecar revision ${nextRevision} < ${priorRevision}`)
146
155
  }
@@ -149,10 +158,19 @@ export function mergeG2OperationsSidecar(
149
158
  for (const field of OPERATIONS_OWNED_SIDECAR_FIELDS) {
150
159
  if (Object.prototype.hasOwnProperty.call(existing, field)) merged[field] = existing[field]
151
160
  }
161
+ const correctionFields = ['chunks', 'chunkEntries', 'speakers', 'batchSegments', 'batchApplied', 'correctionRevision', 'labelsNewerThanGraph']
162
+ if (g2Revision(existing.correctionRevision) > g2Revision(source.correctionRevision)) {
163
+ for (const field of correctionFields) {
164
+ if (Object.prototype.hasOwnProperty.call(existing, field)) merged[field] = existing[field]
165
+ else delete merged[field]
166
+ }
167
+ }
168
+ merged.correctionRevision = Math.max(g2Revision(existing.correctionRevision), g2Revision(source.correctionRevision))
169
+ merged.labelsNewerThanGraph = existing.labelsNewerThanGraph === true || source.labelsNewerThanGraph === true
152
170
  merged.lifecycleRevision = Math.max(priorRevision || 0, nextRevision || 0)
153
- const inferredFinalHqState = source.batchApplied === true
171
+ const inferredFinalHqState = merged.batchApplied === true
154
172
  ? 'accepted'
155
- : source.batchQualityReport
173
+ : merged.batchQualityReport
156
174
  ? 'rejected'
157
175
  : 'unavailable'
158
176
  merged.hqState = options.hqState
@@ -184,7 +202,11 @@ export function stageRecordingIntoOperations(
184
202
  const destDir = join(operationsDir, 'personal', 'meetings', month)
185
203
  mkdirSync(destDir, { recursive: true })
186
204
  const destPath = join(destDir, basename(localMeetingPath))
187
- const patched = patchRecordingForG2Pipeline(readFileSync(localMeetingPath, 'utf8'), options)
205
+ const sourceSidecar = parseSidecar(localMeetingPath.replace(/\.md$/, '.g2-chunks.json'))
206
+ const destinationSidecar = parseSidecar(destPath.replace(/\.md$/, '.g2-chunks.json'))
207
+ const keepMarkdown = g2Revision(destinationSidecar?.correctionRevision) > g2Revision(sourceSidecar?.correctionRevision)
208
+ if (keepMarkdown && !existsSync(destPath)) throw new Error('Corrected operations markdown missing; refusing stale import')
209
+ const patched = keepMarkdown ? readFileSync(destPath, 'utf8') : patchRecordingForG2Pipeline(readFileSync(localMeetingPath, 'utf8'), options)
188
210
 
189
211
  const stem = basename(localMeetingPath, '.md')
190
212
  const localDir = dirname(localMeetingPath)