@gotcos/glasses-server 6.79.2 → 6.81.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.
Files changed (70) hide show
  1. package/.env.example +9 -0
  2. package/CHANGELOG.md +31 -0
  3. package/README.md +10 -0
  4. package/managed-runtime-contract.json +1 -0
  5. package/package.json +10 -1
  6. package/server/index.ts +14 -1
  7. package/server/lib/agent-session-search.ts +8 -5
  8. package/server/lib/agent-session-store.ts +45 -4
  9. package/server/lib/archive.ts +2 -1
  10. package/server/lib/attached-provider-adapter.ts +11 -0
  11. package/server/lib/claude-bridge.ts +112 -8
  12. package/server/lib/claude-settings-guard.ts +94 -0
  13. package/server/lib/claude-steer.ts +27 -0
  14. package/server/lib/codex-bridge.ts +205 -32
  15. package/server/lib/codex-engine-sessions.ts +27 -6
  16. package/server/lib/codex-homes.ts +134 -0
  17. package/server/lib/codex-live-queue.ts +14 -0
  18. package/server/lib/codex-profile-home.ts +452 -0
  19. package/server/lib/codex-queue-control.ts +17 -1
  20. package/server/lib/codex-subagent-activity.ts +22 -0
  21. package/server/lib/conversation.ts +92 -15
  22. package/server/lib/direct-bridge.ts +853 -0
  23. package/server/lib/direct-sse.ts +262 -0
  24. package/server/lib/fork-thread.ts +11 -0
  25. package/server/lib/key-answer-fields.ts +49 -0
  26. package/server/lib/key-catalog.ts +301 -0
  27. package/server/lib/key-lanes-pricing.ts +12 -0
  28. package/server/lib/key-lanes-wiring.ts +346 -0
  29. package/server/lib/key-lanes.ts +145 -0
  30. package/server/lib/key-ledger.ts +123 -0
  31. package/server/lib/key-notices.ts +486 -0
  32. package/server/lib/key-profile-rejects.ts +25 -0
  33. package/server/lib/key-profile-routing.ts +173 -0
  34. package/server/lib/key-profiles.ts +182 -0
  35. package/server/lib/key-relay-contract.ts +262 -0
  36. package/server/lib/key-relay-errors.ts +211 -0
  37. package/server/lib/key-relay-meter.ts +303 -0
  38. package/server/lib/key-relay.ts +1018 -0
  39. package/server/lib/key-secrets-guard.ts +52 -0
  40. package/server/lib/key-store.ts +228 -0
  41. package/server/lib/keyed-sessions.ts +154 -0
  42. package/server/lib/lane-b-env.ts +169 -0
  43. package/server/lib/meeting-autonomous-finalize.ts +267 -0
  44. package/server/lib/meeting-stop-intents.ts +277 -0
  45. package/server/lib/model-router.ts +96 -7
  46. package/server/lib/morning-brief-config.ts +3 -0
  47. package/server/lib/native-head.ts +13 -1
  48. package/server/lib/occupancy-probes.ts +4 -0
  49. package/server/lib/ollama-run-ledger.ts +96 -13
  50. package/server/lib/prompt-edit.ts +2 -0
  51. package/server/lib/query-job-coordinator.ts +2 -1
  52. package/server/lib/query-job-runtime.ts +67 -15
  53. package/server/lib/query-job-store.ts +7 -1
  54. package/server/lib/query-job-types.ts +15 -1
  55. package/server/lib/session-hook-events.ts +28 -0
  56. package/server/lib/session-hook-ledger.ts +10 -0
  57. package/server/lib/thread-occupancy.ts +22 -1
  58. package/server/lib/token-audit.ts +18 -4
  59. package/server/lib/work-handoff-requests.ts +3 -0
  60. package/server/routes/agent-session-bindings.ts +15 -0
  61. package/server/routes/agent-sessions.ts +17 -5
  62. package/server/routes/handoffs.ts +22 -1
  63. package/server/routes/health.ts +5 -0
  64. package/server/routes/key-lanes.ts +442 -0
  65. package/server/routes/meeting.ts +326 -47
  66. package/server/routes/openai-compat.ts +28 -3
  67. package/server/routes/query.ts +20 -6
  68. package/server/routes/transcribe-stream.ts +48 -1
  69. package/shared/key-preference.ts +57 -0
  70. package/shared/model-preference.ts +20 -2
@@ -0,0 +1,267 @@
1
+ // The server finishes a stopped G2 meeting on its own (6.81.0).
2
+ //
3
+ // Miles, 2026-10-20: "if the application isn't in the foreground, it can fail to save the
4
+ // meetings". Once the phone has told the server its meeting stopped (lib/meeting-stop-
5
+ // intents.ts) and the server holds every chunk it captured, nothing is left that needs the
6
+ // phone, so the server runs the same save the phone would have run. The phone, when it
7
+ // comes back on screen, finds a saved meeting and adopts the receipt.
8
+ //
9
+ // THE ONE RULE: NEVER SAVE A CAPTURE THAT IS NOT COMPLETE. The server saves on its own only
10
+ // when its ledger holds every index 0..N (N = the phone's last captured index) and every one
11
+ // of them has finished ASR. A gap means audio exists only on the phone, so the server waits
12
+ // and the session stays live: the phone's late upload lands in the SAME session (the merge)
13
+ // and the next check saves. There is no shorter timeout that saves with the gap, because a
14
+ // save closes the session, and a chunk that arrives after the close is refused (410) and the
15
+ // phone reads "server closed, local audio pending" as a collision it cannot resolve: the
16
+ // meeting splits or the tail is lost. A gap that never fills is handed to the existing
17
+ // stranded sweeper at its existing 4 h horizon, exactly as before this release. Waiting
18
+ // costs only time; saving early costs audio.
19
+ //
20
+ // Why a settle delay as well: a phone that is on screen saves within a few seconds of Stop.
21
+ // The server stands back for AUTONOMOUS_FINALIZE_SETTLE_MS after the Stop and after the last
22
+ // chunk arrived, so a healthy phone's own save is the normal path and the server's is the
23
+ // fallback. Both racing is safe anyway (one save lock, an idempotent save route), but not
24
+ // racing is quieter.
25
+
26
+ import type { StopIntent } from './meeting-stop-intents.js'
27
+
28
+ /** Quiet time after the Stop and after the last chunk before the server saves on its own. */
29
+ export const AUTONOMOUS_FINALIZE_SETTLE_MS = 45_000
30
+ /** How often a waiting Stop is checked again. Cheap: an in-memory ledger read. */
31
+ export const AUTONOMOUS_FINALIZE_POLL_MS = 15_000
32
+ /** Missing indices kept in a log line. */
33
+ const MISSING_SAMPLE = 8
34
+
35
+ export interface SessionLedger {
36
+ state: 'active' | 'closed' | 'missing'
37
+ received: readonly number[]
38
+ asrCompleted: readonly number[]
39
+ maxChunkIndex: number
40
+ /** ms since epoch of the last chunk arrival, or null. */
41
+ lastActivityAt: number | null
42
+ }
43
+
44
+ export type AutonomousWaitReason = 'settling' | 'missing_chunks' | 'asr_pending' | 'session_unknown' | 'saving' | 'not_ready'
45
+ export type AutonomousStandDownReason = 'already_saved' | 'closed_unsaved' | 'nothing_captured' | 'server_instance_mismatch'
46
+
47
+ export type AutonomousDecision =
48
+ | { action: 'finalize'; chunks: number }
49
+ | { action: 'wait'; reason: AutonomousWaitReason; missing: number[]; missingCount: number; retryInMs: number }
50
+ | { action: 'revoke'; reason: 'chunk_beyond_final'; maxChunkIndex: number }
51
+ | { action: 'stand_down'; reason: AutonomousStandDownReason }
52
+
53
+ function missingFrom(finalIndex: number, have: readonly number[]): { sample: number[]; count: number } {
54
+ const present = new Set(have)
55
+ const sample: number[] = []
56
+ let count = 0
57
+ for (let index = 0; index <= finalIndex; index++) {
58
+ if (present.has(index)) continue
59
+ count++
60
+ if (sample.length < MISSING_SAMPLE) sample.push(index)
61
+ }
62
+ return { sample, count }
63
+ }
64
+
65
+ /**
66
+ * What to do with a waiting Stop right now. Pure: every input is passed in.
67
+ *
68
+ * Order matters and each step is pinned by a test: a saved meeting is never saved again, a
69
+ * chunk past the Stop means the meeting is still recording (revoke, never save), and only a
70
+ * complete, fully transcribed, quiet capture is saved.
71
+ */
72
+ export function decideAutonomousFinalize(input: {
73
+ intent: Pick<StopIntent, 'finalChunkIndex' | 'receivedAt'>
74
+ ledger: SessionLedger
75
+ now: number
76
+ alreadySaved: boolean
77
+ saving: boolean
78
+ }): AutonomousDecision {
79
+ const { intent, ledger, now } = input
80
+ if (input.alreadySaved) return { action: 'stand_down', reason: 'already_saved' }
81
+ if (input.saving) return { action: 'wait', reason: 'saving', missing: [], missingCount: 0, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
82
+ // A session closed WITHOUT a save receipt (expired, quarantined) is the stranded and
83
+ // quarantine paths' to finish. Saving here would race them.
84
+ if (ledger.state === 'closed') return { action: 'stand_down', reason: 'closed_unsaved' }
85
+ if (intent.finalChunkIndex < 0) return { action: 'stand_down', reason: 'nothing_captured' }
86
+ // The server holds audio the phone says it never captured: the phone resumed after this
87
+ // Stop, or the Stop is stale. Either way the meeting may still be recording.
88
+ if (ledger.maxChunkIndex > intent.finalChunkIndex) {
89
+ return { action: 'revoke', reason: 'chunk_beyond_final', maxChunkIndex: ledger.maxChunkIndex }
90
+ }
91
+ // An offline capture: the Stop arrived before any chunk did. Wait for the upload.
92
+ if (ledger.state === 'missing') {
93
+ return { action: 'wait', reason: 'session_unknown', missing: [], missingCount: intent.finalChunkIndex + 1, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
94
+ }
95
+ const absent = missingFrom(intent.finalChunkIndex, ledger.received)
96
+ if (absent.count > 0) {
97
+ return { action: 'wait', reason: 'missing_chunks', missing: absent.sample, missingCount: absent.count, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
98
+ }
99
+ const pending = missingFrom(intent.finalChunkIndex, ledger.asrCompleted)
100
+ if (pending.count > 0) {
101
+ return { action: 'wait', reason: 'asr_pending', missing: pending.sample, missingCount: pending.count, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
102
+ }
103
+ const quietSince = Math.max(intent.receivedAt, ledger.lastActivityAt ?? 0)
104
+ const settleLeft = quietSince + AUTONOMOUS_FINALIZE_SETTLE_MS - now
105
+ if (settleLeft > 0) {
106
+ return { action: 'wait', reason: 'settling', missing: [], missingCount: 0, retryInMs: Math.min(settleLeft, AUTONOMOUS_FINALIZE_POLL_MS) }
107
+ }
108
+ return { action: 'finalize', chunks: intent.finalChunkIndex + 1 }
109
+ }
110
+
111
+ /** Expand `[start, end]` ranges (the status route's shape) into indices. */
112
+ export function indicesFromRanges(ranges: ReadonlyArray<readonly [number, number]>): number[] {
113
+ const out: number[] = []
114
+ for (const [start, end] of ranges) for (let index = start; index <= end; index++) out.push(index)
115
+ return out
116
+ }
117
+
118
+ export type AutonomousSaveOutcome =
119
+ | { ok: true; filename: string; sidecarPath: string; chunkCount: number; canonicalCount: number; missingIndices: number[] }
120
+ | { ok: false; status: number; reason: string }
121
+
122
+ export interface AutonomousFinalizerDeps {
123
+ waiting(): StopIntent[]
124
+ get(sessionId: string): StopIntent | null
125
+ update(sessionId: string, patch: Partial<StopIntent>): void
126
+ ledger(sessionId: string): SessionLedger
127
+ alreadySaved(sessionId: string): boolean
128
+ saving(sessionId: string): boolean
129
+ save(intent: StopIntent): Promise<AutonomousSaveOutcome>
130
+ /** Maintenance admissions open AND durable session recovery has run. */
131
+ ready(): boolean
132
+ now(): number
133
+ /** Returns a cancel function. Production timers are unref'd so they never hold the process. */
134
+ schedule(fn: () => void, ms: number): () => void
135
+ log(event: string, fields: Record<string, unknown>): void
136
+ }
137
+
138
+ export interface AutonomousFinalizer {
139
+ /** Check one session now (a Stop arrived, a boot). Never throws. */
140
+ check(sessionId: string, trigger: string): Promise<AutonomousDecision | null>
141
+ /** Check every waiting Stop, then re-arm while any remain. */
142
+ tick(trigger?: string): Promise<void>
143
+ /** Make sure a tick is scheduled. */
144
+ arm(ms?: number): void
145
+ }
146
+
147
+ export function createAutonomousFinalizer(deps: AutonomousFinalizerDeps): AutonomousFinalizer {
148
+ const inFlight = new Set<string>()
149
+ let cancel: (() => void) | null = null
150
+ let armedFor = Infinity
151
+
152
+ const arm = (ms = AUTONOMOUS_FINALIZE_POLL_MS) => {
153
+ const due = deps.now() + Math.max(0, ms)
154
+ if (cancel && armedFor <= due) return
155
+ cancel?.()
156
+ armedFor = due
157
+ cancel = deps.schedule(() => {
158
+ cancel = null
159
+ armedFor = Infinity
160
+ void finalizer.tick('poll')
161
+ }, Math.max(0, ms))
162
+ }
163
+
164
+ const check = async (sessionId: string, trigger: string): Promise<AutonomousDecision | null> => {
165
+ if (inFlight.has(sessionId)) return null
166
+ const intent = deps.get(sessionId)
167
+ if (!intent || intent.state !== 'waiting') return null
168
+ // A boot behind a closed maintenance gate has not adopted the persisted sessions yet,
169
+ // so "the server holds no chunks" is not evidence of anything. Wait, never decide.
170
+ if (!deps.ready()) {
171
+ return { action: 'wait', reason: 'not_ready', missing: [], missingCount: 0, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
172
+ }
173
+ inFlight.add(sessionId)
174
+ try {
175
+ const now = deps.now()
176
+ const decision = decideAutonomousFinalize({
177
+ intent,
178
+ ledger: deps.ledger(sessionId),
179
+ now,
180
+ alreadySaved: deps.alreadySaved(sessionId),
181
+ saving: deps.saving(sessionId),
182
+ })
183
+ if (decision.action === 'stand_down') {
184
+ // A save the phone (or anyone) already made is the phone's save, not ours.
185
+ deps.update(sessionId, decision.reason === 'already_saved'
186
+ ? { state: 'saved', savedBy: intent.savedBy ?? 'phone', savedAt: intent.savedAt ?? now }
187
+ : { state: 'stood_down', reason: decision.reason })
188
+ deps.log('stood_down', { sessionId, reason: decision.reason, trigger, waitedMs: now - intent.receivedAt })
189
+ return decision
190
+ }
191
+ if (decision.action === 'revoke') {
192
+ deps.update(sessionId, { state: 'revoked', reason: decision.reason })
193
+ deps.log('revoked', { sessionId, reason: decision.reason, finalChunkIndex: intent.finalChunkIndex, maxChunkIndex: decision.maxChunkIndex, trigger })
194
+ return decision
195
+ }
196
+ if (decision.action === 'wait') {
197
+ const last = intent.lastWait
198
+ // One line per change of state, not one per poll.
199
+ if (!last || last.reason !== decision.reason || last.missingCount !== decision.missingCount) {
200
+ deps.update(sessionId, { lastWait: { reason: decision.reason, missingCount: decision.missingCount, missingSample: decision.missing, at: now } })
201
+ deps.log('waiting', { sessionId, reason: decision.reason, missingCount: decision.missingCount, missingSample: decision.missing, finalChunkIndex: intent.finalChunkIndex, trigger })
202
+ }
203
+ return decision
204
+ }
205
+ const outcome = await deps.save(intent)
206
+ const at = deps.now()
207
+ if (outcome.ok) {
208
+ deps.update(sessionId, {
209
+ state: 'saved', savedBy: 'server', savedAt: at,
210
+ filename: outcome.filename, sidecarPath: outcome.sidecarPath,
211
+ chunkCount: outcome.chunkCount, canonicalCount: outcome.canonicalCount, lastWait: null,
212
+ })
213
+ deps.log('finalized', {
214
+ sessionId, reason: 'stop_received_complete', trigger,
215
+ finalChunkIndex: intent.finalChunkIndex, chunks: outcome.chunkCount, canonical: outcome.canonicalCount,
216
+ gaps: outcome.missingIndices.length, filename: outcome.filename,
217
+ waitedMs: at - intent.receivedAt, phoneConfirmed: false,
218
+ })
219
+ return decision
220
+ }
221
+ // The phone saved between the decision and the lock: its save, not ours.
222
+ if (outcome.reason === 'already_saved') {
223
+ deps.update(sessionId, { state: 'saved', savedBy: 'phone', savedAt: at, lastWait: null })
224
+ deps.log('stood_down', { sessionId, reason: 'already_saved', trigger, waitedMs: at - intent.receivedAt })
225
+ return { action: 'stand_down', reason: 'already_saved' }
226
+ }
227
+ // The phone pinned another server instance: this Mac is not where it meant to save.
228
+ if (outcome.reason === 'server_instance_mismatch') {
229
+ deps.update(sessionId, { state: 'stood_down', reason: outcome.reason })
230
+ deps.log('stood_down', { sessionId, reason: outcome.reason, trigger })
231
+ return { action: 'stand_down', reason: 'server_instance_mismatch' }
232
+ }
233
+ // The phone's own save holds the lock: it wins, and the next check reads its receipt.
234
+ if (outcome.status === 409 && outcome.reason === 'save_in_progress') {
235
+ return { action: 'wait', reason: 'saving', missing: [], missingCount: 0, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
236
+ }
237
+ // Nothing to save (every chunk was silence): the phone's own Stop says so on the lens.
238
+ if (outcome.status === 404) {
239
+ deps.update(sessionId, { state: 'stood_down', reason: outcome.reason })
240
+ deps.log('stood_down', { sessionId, reason: outcome.reason, trigger })
241
+ return { action: 'stand_down', reason: 'nothing_captured' }
242
+ }
243
+ deps.log('finalize_failed', { sessionId, status: outcome.status, reason: outcome.reason, trigger })
244
+ return { action: 'wait', reason: 'saving', missing: [], missingCount: 0, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
245
+ } catch (error) {
246
+ deps.log('finalize_failed', { sessionId, status: 500, reason: error instanceof Error ? error.message.slice(0, 200) : 'error', trigger })
247
+ return null
248
+ } finally {
249
+ inFlight.delete(sessionId)
250
+ }
251
+ }
252
+
253
+ const finalizer: AutonomousFinalizer = {
254
+ check,
255
+ async tick(trigger = 'poll') {
256
+ let soonest = Infinity
257
+ for (const intent of deps.waiting()) {
258
+ const decision = await check(intent.sessionId, trigger)
259
+ if (decision?.action === 'wait') soonest = Math.min(soonest, decision.retryInMs)
260
+ else if (decision === null && deps.get(intent.sessionId)?.state === 'waiting') soonest = Math.min(soonest, AUTONOMOUS_FINALIZE_POLL_MS)
261
+ }
262
+ if (Number.isFinite(soonest)) arm(soonest)
263
+ },
264
+ arm,
265
+ }
266
+ return finalizer
267
+ }
@@ -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
+ }