@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.
- package/.env.example +9 -0
- package/CHANGELOG.md +31 -0
- package/README.md +10 -0
- package/managed-runtime-contract.json +1 -0
- package/package.json +10 -1
- package/server/index.ts +14 -1
- package/server/lib/agent-session-search.ts +8 -5
- package/server/lib/agent-session-store.ts +45 -4
- package/server/lib/archive.ts +2 -1
- package/server/lib/attached-provider-adapter.ts +11 -0
- package/server/lib/claude-bridge.ts +112 -8
- package/server/lib/claude-settings-guard.ts +94 -0
- package/server/lib/claude-steer.ts +27 -0
- package/server/lib/codex-bridge.ts +205 -32
- package/server/lib/codex-engine-sessions.ts +27 -6
- package/server/lib/codex-homes.ts +134 -0
- package/server/lib/codex-live-queue.ts +14 -0
- package/server/lib/codex-profile-home.ts +452 -0
- package/server/lib/codex-queue-control.ts +17 -1
- package/server/lib/codex-subagent-activity.ts +22 -0
- package/server/lib/conversation.ts +92 -15
- package/server/lib/direct-bridge.ts +853 -0
- package/server/lib/direct-sse.ts +262 -0
- package/server/lib/fork-thread.ts +11 -0
- package/server/lib/key-answer-fields.ts +49 -0
- package/server/lib/key-catalog.ts +301 -0
- package/server/lib/key-lanes-pricing.ts +12 -0
- package/server/lib/key-lanes-wiring.ts +346 -0
- package/server/lib/key-lanes.ts +145 -0
- package/server/lib/key-ledger.ts +123 -0
- package/server/lib/key-notices.ts +486 -0
- package/server/lib/key-profile-rejects.ts +25 -0
- package/server/lib/key-profile-routing.ts +173 -0
- package/server/lib/key-profiles.ts +182 -0
- package/server/lib/key-relay-contract.ts +262 -0
- package/server/lib/key-relay-errors.ts +211 -0
- package/server/lib/key-relay-meter.ts +303 -0
- package/server/lib/key-relay.ts +1018 -0
- package/server/lib/key-secrets-guard.ts +52 -0
- package/server/lib/key-store.ts +228 -0
- package/server/lib/keyed-sessions.ts +154 -0
- package/server/lib/lane-b-env.ts +169 -0
- package/server/lib/meeting-autonomous-finalize.ts +267 -0
- package/server/lib/meeting-stop-intents.ts +277 -0
- package/server/lib/model-router.ts +96 -7
- package/server/lib/morning-brief-config.ts +3 -0
- package/server/lib/native-head.ts +13 -1
- package/server/lib/occupancy-probes.ts +4 -0
- package/server/lib/ollama-run-ledger.ts +96 -13
- package/server/lib/prompt-edit.ts +2 -0
- package/server/lib/query-job-coordinator.ts +2 -1
- package/server/lib/query-job-runtime.ts +67 -15
- package/server/lib/query-job-store.ts +7 -1
- package/server/lib/query-job-types.ts +15 -1
- package/server/lib/session-hook-events.ts +28 -0
- package/server/lib/session-hook-ledger.ts +10 -0
- package/server/lib/thread-occupancy.ts +22 -1
- package/server/lib/token-audit.ts +18 -4
- package/server/lib/work-handoff-requests.ts +3 -0
- package/server/routes/agent-session-bindings.ts +15 -0
- package/server/routes/agent-sessions.ts +17 -5
- package/server/routes/handoffs.ts +22 -1
- package/server/routes/health.ts +5 -0
- package/server/routes/key-lanes.ts +442 -0
- package/server/routes/meeting.ts +326 -47
- package/server/routes/openai-compat.ts +28 -3
- package/server/routes/query.ts +20 -6
- package/server/routes/transcribe-stream.ts +48 -1
- package/shared/key-preference.ts +57 -0
- 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
|
+
}
|