@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.
- package/CHANGELOG.md +24 -0
- package/package.json +4 -1
- package/server/index.ts +10 -1
- package/server/lib/claude-bridge.ts +6 -1
- package/server/lib/codex-bridge.ts +1 -1
- package/server/lib/cursor-bridge.ts +1 -1
- package/server/lib/direct-bridge.ts +1 -1
- package/server/lib/meeting-autonomous-finalize.ts +267 -0
- package/server/lib/meeting-stop-intents.ts +277 -0
- package/server/lib/morning-brief-schedule.ts +13 -7
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/lib/routines-config.ts +421 -0
- package/server/lib/routines-registry.ts +151 -0
- package/server/lib/routines-runtime.ts +64 -0
- package/server/lib/routines-scheduler.ts +509 -0
- package/server/routes/meeting.ts +326 -47
- package/server/routes/routines.ts +101 -0
- package/server/routes/transcribe-stream.ts +48 -1
|
@@ -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:
|
|
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?:
|
|
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:
|
|
145
|
-
runs: readonly
|
|
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 } : {}),
|