@gotcos/glasses-server 6.80.0 → 6.82.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.
@@ -0,0 +1,277 @@
1
+ // The phone's Stop, as the server hears it (6.81.0).
2
+ //
3
+ // WHY THIS EXISTS. Until 6.81.0 the server learned that a G2 meeting had ended only from
4
+ // POST /api/meeting/save, and the phone sends that save only after its own finalize has
5
+ // read IndexedDB, reconciled the server's ledger and drained every chunk. iOS suspends
6
+ // IndexedDB while the companion is off screen, so a Stop pressed with the phone in a pocket
7
+ // sat unsaved until the app came back on screen (2026-10-20: 11:19 meeting qbbgyq, Stop at
8
+ // 12:43, `stop_blocked` and `foreground_reconcile_failed` "Load failed", saved only by a new
9
+ // WebView at 12:47; 14:07 meeting mip9qb, Stop at 15:13:50, saved at 15:14:37 only once
10
+ // the phone was visible). A fetch works while hidden; IndexedDB does not.
11
+ //
12
+ // WHAT A STOP INTENT IS. One small POST the phone can send the moment Stop is pressed,
13
+ // before any IndexedDB work: "this meeting has ended, and the last chunk I captured is
14
+ // index N". The server records it durably and from then on can finish the save itself
15
+ // once it holds every chunk 0..N (lib/meeting-autonomous-finalize.ts decides when).
16
+ //
17
+ // WHAT IT IS NOT. It never closes the session, never refuses a chunk, and never saves by
18
+ // itself. Chunks keep arriving into the same live session after it (that is the merge),
19
+ // and a chunk past N cancels it, because a meeting that is still recording must never be
20
+ // finalized.
21
+
22
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from 'node:fs'
23
+ import { dirname } from 'node:path'
24
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
25
+ import { dataPath } from './data-dir.js'
26
+ import { LOCAL_FIRST_MEETING_IDLE_RETENTION_MS } from './local-first-meetings-contract.js'
27
+
28
+ export const STOP_INTENTS_FILE_NAME = 'meeting-stop-intents.json'
29
+ export const AUTONOMOUS_SAVE_LOG_NAME = 'meeting-autonomous-save.jsonl'
30
+
31
+ /** A finished intent stays readable this long, so a late phone can still be answered and logged. */
32
+ export const STOP_INTENT_TERMINAL_RETAIN_MS = 24 * 60 * 60_000
33
+ /**
34
+ * A waiting intent outlives the session it describes by one retention horizon. By then the
35
+ * stranded sweeper (4 h idle) has promoted or quarantined the session and owns it.
36
+ */
37
+ export const STOP_INTENT_WAITING_RETAIN_MS = 2 * LOCAL_FIRST_MEETING_IDLE_RETENTION_MS
38
+
39
+ export type StopIntentState = 'waiting' | 'saved' | 'revoked' | 'stood_down'
40
+ /** `other`: an unpinned save (the stranded sweep's own POST, a manual curl). */
41
+ export type StopIntentSavedBy = 'server' | 'phone' | 'other'
42
+
43
+ export interface StopIntentWait {
44
+ reason: string
45
+ missingCount: number
46
+ /** The first few missing indices, for the log. Never the whole list. */
47
+ missingSample: number[]
48
+ at: number
49
+ }
50
+
51
+ export interface StopIntent {
52
+ sessionId: string
53
+ /** The highest chunk index the phone captured. -1 = it captured nothing. */
54
+ finalChunkIndex: number
55
+ source: string
56
+ title?: string
57
+ domain?: string
58
+ serverInstanceId: string
59
+ /** Server clock, first receipt. The phone's clock is never read. */
60
+ receivedAt: number
61
+ updatedAt: number
62
+ /** How many times the phone sent it (a hidden phone retries). */
63
+ posts: number
64
+ state: StopIntentState
65
+ /** Why it is revoked or stood down. */
66
+ reason?: string
67
+ savedBy?: StopIntentSavedBy
68
+ savedAt?: number
69
+ filename?: string
70
+ sidecarPath?: string
71
+ chunkCount?: number
72
+ canonicalCount?: number
73
+ lastWait?: StopIntentWait | null
74
+ /** Set when the phone first reports back after the server saved on its own. */
75
+ phoneConfirmedAt?: number
76
+ phoneConfirmedBy?: string
77
+ /** Chunks the phone re-sent after the save that the saved meeting already held. */
78
+ lateReplays?: number
79
+ /** Chunks the phone sent after the save that the saved meeting did NOT hold. Should stay 0. */
80
+ lateRefused?: number
81
+ /** The state of an earlier Stop for this session that this one replaced. */
82
+ replaced?: StopIntentState
83
+ }
84
+
85
+ export type RecordStopIntentResult =
86
+ | { ok: true; intent: StopIntent; created: boolean; raised: boolean }
87
+ | { ok: false; reason: 'terminal'; intent: StopIntent }
88
+
89
+ export interface StopIntentStore {
90
+ readonly path: string
91
+ get(sessionId: string): StopIntent | null
92
+ /** Waiting intents, oldest first. */
93
+ waiting(): StopIntent[]
94
+ /**
95
+ * Record or refresh the phone's Stop. Re-posting is idempotent; a higher final index
96
+ * raises it (never lowers it: the server must never finalize before the highest index the
97
+ * phone ever claimed has arrived).
98
+ */
99
+ record(input: { sessionId: string; finalChunkIndex: number; source: string; title?: string; domain?: string; serverInstanceId: string }): RecordStopIntentResult
100
+ update(sessionId: string, patch: Partial<StopIntent>): StopIntent | null
101
+ }
102
+
103
+ function isRecord(value: unknown): value is Record<string, unknown> {
104
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
105
+ }
106
+
107
+ function readIntents(path: string): Record<string, StopIntent> {
108
+ if (!existsSync(path)) return {}
109
+ try {
110
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown
111
+ if (!isRecord(parsed) || !isRecord(parsed.intents)) return {}
112
+ const out: Record<string, StopIntent> = {}
113
+ for (const [id, raw] of Object.entries(parsed.intents)) {
114
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(id) || !isRecord(raw)) continue
115
+ const intent = raw as unknown as StopIntent
116
+ if (intent.sessionId !== id) continue
117
+ if (!Number.isInteger(intent.finalChunkIndex) || intent.finalChunkIndex < -1) continue
118
+ if (!Number.isFinite(intent.receivedAt) || !Number.isFinite(intent.updatedAt)) continue
119
+ if (!['waiting', 'saved', 'revoked', 'stood_down'].includes(intent.state)) continue
120
+ out[id] = intent
121
+ }
122
+ return out
123
+ } catch {
124
+ // A corrupt ledger is set aside, never silently trusted or deleted.
125
+ try { renameSync(path, `${path}.corrupt.${Date.now()}`) } catch {}
126
+ return {}
127
+ }
128
+ }
129
+
130
+ export function createStopIntentStore(options: { path?: string; now?: () => number } = {}): StopIntentStore {
131
+ const path = options.path ?? dataPath(STOP_INTENTS_FILE_NAME)
132
+ const now = options.now ?? Date.now
133
+ let cache: Record<string, StopIntent> | null = null
134
+
135
+ const load = (): Record<string, StopIntent> => {
136
+ if (!cache) cache = readIntents(path)
137
+ return cache
138
+ }
139
+ const prune = (intents: Record<string, StopIntent>, at: number) => {
140
+ for (const [id, intent] of Object.entries(intents)) {
141
+ const retain = intent.state === 'waiting' ? STOP_INTENT_WAITING_RETAIN_MS : STOP_INTENT_TERMINAL_RETAIN_MS
142
+ if (at - intent.updatedAt > retain) delete intents[id]
143
+ }
144
+ }
145
+ // Persisted before the caller answers, so a 2xx means a restart still knows the Stop.
146
+ const persist = (intents: Record<string, StopIntent>) => {
147
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
148
+ durableAtomicWriteFileSync(path, JSON.stringify({ schemaVersion: 1, intents }, null, 2), { mode: 0o600 })
149
+ }
150
+
151
+ return {
152
+ path,
153
+ get(sessionId) {
154
+ const intent = load()[sessionId]
155
+ return intent ? { ...intent } : null
156
+ },
157
+ waiting() {
158
+ return Object.values(load())
159
+ .filter(intent => intent.state === 'waiting')
160
+ .sort((a, b) => a.receivedAt - b.receivedAt)
161
+ .map(intent => ({ ...intent }))
162
+ },
163
+ record(input) {
164
+ const at = now()
165
+ const intents = load()
166
+ prune(intents, at)
167
+ const prior = intents[input.sessionId]
168
+ // A saved Stop is final. A revoked or stood-down one is history: the phone resumed and
169
+ // stopped again, so this is a new Stop and replaces it.
170
+ if (prior && prior.state === 'saved') return { ok: false, reason: 'terminal', intent: { ...prior } }
171
+ const existing = prior?.state === 'waiting' ? prior : undefined
172
+ if (existing) {
173
+ const raised = input.finalChunkIndex > existing.finalChunkIndex
174
+ const next: StopIntent = {
175
+ ...existing,
176
+ finalChunkIndex: Math.max(existing.finalChunkIndex, input.finalChunkIndex),
177
+ title: input.title ?? existing.title,
178
+ domain: input.domain ?? existing.domain,
179
+ updatedAt: at,
180
+ posts: existing.posts + 1,
181
+ }
182
+ intents[input.sessionId] = next
183
+ persist(intents)
184
+ return { ok: true, intent: { ...next }, created: false, raised }
185
+ }
186
+ const intent: StopIntent = {
187
+ sessionId: input.sessionId,
188
+ finalChunkIndex: input.finalChunkIndex,
189
+ source: input.source,
190
+ ...(input.title ? { title: input.title } : {}),
191
+ ...(input.domain ? { domain: input.domain } : {}),
192
+ serverInstanceId: input.serverInstanceId,
193
+ receivedAt: at,
194
+ updatedAt: at,
195
+ posts: 1,
196
+ state: 'waiting',
197
+ lastWait: null,
198
+ ...(prior ? { replaced: prior.state } : {}),
199
+ }
200
+ intents[input.sessionId] = intent
201
+ persist(intents)
202
+ return { ok: true, intent: { ...intent }, created: true, raised: false }
203
+ },
204
+ update(sessionId, patch) {
205
+ const intents = load()
206
+ const existing = intents[sessionId]
207
+ if (!existing) return null
208
+ const next: StopIntent = { ...existing, ...patch, sessionId, updatedAt: now() }
209
+ intents[sessionId] = next
210
+ try { persist(intents) } catch (error) {
211
+ console.error(`[meeting/auto-save] Could not persist the stop intent for ${sessionId}: ${error instanceof Error ? error.message : error}`)
212
+ }
213
+ return { ...next }
214
+ },
215
+ }
216
+ }
217
+
218
+ let defaultStore: StopIntentStore | null = null
219
+ /** The process-wide store, created on first use (never at import: tests import this module). */
220
+ export function getStopIntentStore(): StopIntentStore {
221
+ if (!defaultStore) defaultStore = createStopIntentStore()
222
+ return defaultStore
223
+ }
224
+
225
+ /** One JSON line per autonomous-save event, beside the server log line that says the same. */
226
+ export function appendAutonomousSaveLog(event: string, fields: Record<string, unknown>, path = dataPath(AUTONOMOUS_SAVE_LOG_NAME)): void {
227
+ try {
228
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
229
+ appendFileSync(path, `${JSON.stringify({ at: new Date().toISOString(), event, ...fields })}\n`, { mode: 0o600 })
230
+ } catch { /* the console line is the fallback record */ }
231
+ }
232
+
233
+ export interface SavedChunkText {
234
+ text: string
235
+ speaker: string
236
+ elapsed: number
237
+ [key: string]: unknown
238
+ }
239
+ export type SavedChunkReplay = { kind: 'canonical'; chunk: SavedChunkText } | { kind: 'empty' } | null
240
+
241
+ let sidecarCache: { path: string; mtimeMs: number; entries: Map<number, SavedChunkText> } | null = null
242
+
243
+ /**
244
+ * What a meeting the server saved on its own holds at one chunk index, for answering a
245
+ * phone that re-sends that chunk after the save (its upload response was lost while it was
246
+ * hidden). `canonical`: the saved text. `empty`: the chunk arrived and was silence. `null`:
247
+ * the saved meeting never held this index, so the replay is refused as before (410).
248
+ *
249
+ * Read from the saved sidecar, not from memory, so it survives a restart. One sidecar is
250
+ * cached at a time: replays arrive in a burst for one meeting.
251
+ */
252
+ export function savedChunkReplay(sidecarPath: string, chunkIndex: number, asrCompleted: readonly number[]): SavedChunkReplay {
253
+ let entries: Map<number, SavedChunkText>
254
+ try {
255
+ const mtimeMs = statSync(sidecarPath).mtimeMs
256
+ if (sidecarCache && sidecarCache.path === sidecarPath && sidecarCache.mtimeMs === mtimeMs) {
257
+ entries = sidecarCache.entries
258
+ } else {
259
+ const sidecar = JSON.parse(readFileSync(sidecarPath, 'utf-8')) as { chunkEntries?: unknown }
260
+ entries = new Map()
261
+ for (const entry of Array.isArray(sidecar.chunkEntries) ? sidecar.chunkEntries : []) {
262
+ const candidate = entry as { chunkIndex?: unknown; chunk?: unknown }
263
+ const chunk = candidate?.chunk as Partial<SavedChunkText> | undefined
264
+ if (!Number.isInteger(candidate?.chunkIndex) || !chunk) continue
265
+ if (typeof chunk.text !== 'string' || !chunk.text || typeof chunk.speaker !== 'string') continue
266
+ if (typeof chunk.elapsed !== 'number' || !Number.isFinite(chunk.elapsed)) continue
267
+ entries.set(candidate.chunkIndex as number, chunk as SavedChunkText)
268
+ }
269
+ sidecarCache = { path: sidecarPath, mtimeMs, entries }
270
+ }
271
+ } catch {
272
+ return null
273
+ }
274
+ const chunk = entries.get(chunkIndex)
275
+ if (chunk) return { kind: 'canonical', chunk }
276
+ return asrCompleted.includes(chunkIndex) ? { kind: 'empty' } : null
277
+ }
@@ -113,9 +113,15 @@ function daysBetween(fromDay: string, toDay: string): number {
113
113
  return Math.round((Date.UTC(ty, tm - 1, td) - Date.UTC(fy, fm - 1, fd)) / 86_400_000)
114
114
  }
115
115
 
116
+ /** The schedule fields the arithmetic reads. The morning brief config and a
117
+ * 6.82.0 skill routine both carry them, so both share one decision table. */
118
+ export type ScheduleShape = Pick<MorningBriefConfig, 'enabled' | 'time' | 'timezone' | 'days' | 'catchUpMinutes'>
119
+ /** The ledger fields the fire decision reads (a brief run and a routine run both have them). */
120
+ export type ScheduledRunShape = Pick<MorningBriefRun, 'day' | 'trigger' | 'attempt' | 'firedAt' | 'jobId' | 'submitError'>
121
+
116
122
  /** ISO instant of the next scheduled fire at or after `nowMs`, or null when
117
123
  * the brief is disabled or no weekday is selected. */
118
- export function nextScheduledFire(config: MorningBriefConfig, nowMs: number): string | null {
124
+ export function nextScheduledFire(config: Pick<ScheduleShape, 'enabled' | 'time' | 'timezone' | 'days'>, nowMs: number): string | null {
119
125
  if (!config.enabled || config.days.length === 0) return null
120
126
  const target = parseTime(config.time)
121
127
  const clock = localClock(nowMs, config.timezone)
@@ -129,8 +135,8 @@ export function nextScheduledFire(config: MorningBriefConfig, nowMs: number): st
129
135
  return null
130
136
  }
131
137
 
132
- export type FireDecision =
133
- | { fire: true; day: string; attempt: number; resume?: MorningBriefRun }
138
+ export type FireDecision<R extends ScheduledRunShape = MorningBriefRun> =
139
+ | { fire: true; day: string; attempt: number; resume?: R }
134
140
  | { fire: false; reason: 'disabled' | 'not_a_brief_day' | 'before_slot' | 'window_closed' | 'already_fired' | 'in_flight' | 'attempts_exhausted' | 'backoff' }
135
141
 
136
142
  /**
@@ -140,11 +146,11 @@ export type FireDecision =
140
146
  * job id and no error is a crash between ledger write and admission — resumed
141
147
  * by client identity rather than re-run.
142
148
  */
143
- export function decideScheduledFire(
144
- config: MorningBriefConfig,
145
- runs: readonly MorningBriefRun[],
149
+ export function decideScheduledFire<R extends ScheduledRunShape = MorningBriefRun>(
150
+ config: ScheduleShape,
151
+ runs: readonly R[],
146
152
  nowMs: number,
147
- ): FireDecision {
153
+ ): FireDecision<R> {
148
154
  if (!config.enabled) return { fire: false, reason: 'disabled' }
149
155
  const clock = localClock(nowMs, config.timezone)
150
156
  if (!config.days.includes(clock.weekday)) return { fire: false, reason: 'not_a_brief_day' }
@@ -1,3 +1,4 @@
1
+ import { routineAuditCaller } from './routines-config.js'
1
2
  import { carriesBoundTo } from './agent-session-binding-store.js'
2
3
  import { randomUUID } from 'node:crypto'
3
4
  import { resolve } from 'node:path'
@@ -420,6 +421,8 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
420
421
  sessionLockHeld: true,
421
422
  ...(request.dispatch ? { dispatch: request.dispatch } : {}),
422
423
  ...(request.sessionName ? { sessionName: request.sessionName } : {}),
424
+ // 6.82.0: a routine's run is counted in the token audit under the routine that started it.
425
+ ...(routineAuditCaller(request) ? { auditCaller: routineAuditCaller(request) } : {}),
423
426
  ...(request.keyTurnOnly ? { keyTurnOnly: true } : {}),
424
427
  // 6.78.0: only on a run the coordinator decided is steerable.
425
428
  ...(steer ? { steer } : {}),