@gotcos/glasses-server 6.99.16 → 6.99.20

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/shared/prep.ts ADDED
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Prep v1.1 (server 6.99.19, protocol 1): notes and files added before a call, a prep run from them, and an explicit
3
+ * bind that carries them into the live meeting and its saved notes. Contract: PREP_CONTRACT_v1_2026-11-06.md, with its
4
+ * "v1.1 amendments" section (which wins where they differ).
5
+ *
6
+ * Identity rule: a draft is NEVER matched to a meeting by its title or time. `occurrence` is filled only when a client
7
+ * (or a future calendar bridge) supplies provider, calendarId, eventId or externalId, and startsAt; its key only FINDS a
8
+ * draft. Files reach a meeting only through POST /prep/drafts/:id/bind with that meeting's sessionId.
9
+ *
10
+ * Types, pure validators and the Web Crypto client job id only: no node imports, so a browser bundle can share it.
11
+ *
12
+ * Routes (all under /api, X-Cos-Token, Cache-Control: no-store; writes answer 503 {error:'maintenance'} while a
13
+ * maintenance drain is closing admissions). Every mutation requires the header X-COS-Expected-Server-Instance
14
+ * (v1.1 #19): missing is 428 {error:'server_instance_required'}, a different server is 409
15
+ * {error:'server_instance_mismatch'}. A body serverInstanceId is also checked when sent. Every JSON response, errors
16
+ * included, carries the top-level `serverInstanceId`; the original-bytes route sends it as X-COS-Server-Instance.
17
+ * - POST /prep/drafts {clientDraftId,title?,startsAt?,occurrence?} -> 201 {draft} (200 and the same draft on a repeat)
18
+ * - GET /prep/drafts?occurrence=&state=active&limit=20 -> {drafts}, newest first
19
+ * - GET /prep/drafts/:id -> {draft}
20
+ * - DELETE /prep/drafts/:id -> {draft}: archives it (state 'archived'); files and snapshots stay, by-session still works
21
+ * - PATCH /prep/drafts/:id {revision,title?,startsAt?,steer?,occurrence?} -> {draft}; stale -> 409 {error:'revision_conflict',draft}
22
+ * - POST /prep/drafts/:id/files {operationId,revision,media:[{id,name?,sourceRef?,sha256}]} -> {draft}; over 10 -> 413
23
+ * {error:'prep_file_limit'}; a copy whose sha256 differs -> 409 {error:'media_hash_mismatch'} with nothing written
24
+ * - DELETE /prep/drafts/:id/files/:fileId?revision=N -> {draft}
25
+ * - GET /prep/drafts/:id/files/:fileId -> original bytes; GET .../text -> {text} ('' for a held or unread file)
26
+ * - POST /prep/drafts/:id/run {clientRequestId,revision,model?,effort?,continue?} -> 202 {job,run,draft}. While
27
+ * another run is in the way (a 'submitting' receipt, or an accepted job with no session yet), a NEW id is
28
+ * 409 {error:'run_in_progress', run} with no side effect; only a retry with that run's own clientRequestId
29
+ * re-drives it. Replaying a failed run is 409 {error:'run_failed', run}.
30
+ * - GET /prep/drafts/:id/runs/:clientRequestId -> 200 {run,job} once admitted; 202 {run} while its admission is in
31
+ * flight; 404 {error:'run_not_accepted', run?} when no job exists for it and none is being admitted (a
32
+ * 'submitting' run left by a restart: send the same run again); 503 {error:'run_status_unknown'} when the
33
+ * job lookup failed
34
+ * - POST /prep/drafts/:id/bind {revision,sessionId,surface,serverInstanceId?} -> {draft,files}
35
+ * - DELETE /prep/drafts/:id/bind/:sessionId -> {draft}
36
+ * - GET /prep/by-session/:sessionId -> {snapshots: PrepSessionSnapshot[]} (frozen per bind, never the latest draft)
37
+ * - GET /prep/suggest?sessionId=&startedAt=<ms> -> {drafts}: at most 5 active drafts, nearest startsAt first (a draft
38
+ * with no start after, newest first); suggestions only, never a bind
39
+ *
40
+ * Replay before CAS (v1.1 #3): a repeat of a create (clientDraftId), files call (operationId), run (clientRequestId) or
41
+ * bind (sessionId + revision) returns the original result without checking revision. A repeat with a different body is
42
+ * 409 {error:'request_reused'}.
43
+ */
44
+
45
+ export const PREP_PROTOCOL = 1 as const
46
+
47
+ export const PREP_LIMITS = Object.freeze({
48
+ /** Files per draft, counting every file not removed. */
49
+ maxFiles: 10,
50
+ steerChars: 4_000,
51
+ titleChars: 300,
52
+ /** Extracted text kept per file, in UTF-8 bytes (the phone Meeting Ask file cap). Also the answer kept on a run. */
53
+ fileTextBytes: 100_000,
54
+ activeDrafts: 300,
55
+ /** The whole Prep store: drafts, originals, extracted text and run prompts. */
56
+ storeBytes: 1024 * 1024 * 1024,
57
+ /** The durable query job prompt limit (QUERY_JOB_LIMITS.promptChars). */
58
+ promptChars: 48_000,
59
+ listLimit: 50,
60
+ /** Suggestions returned by GET /prep/suggest. */
61
+ suggestLimit: 5,
62
+ /** The answer kept on one run, and on all of a draft's runs together (the draft file must reload under 5 MB). */
63
+ runAnswerBytes: 20_000,
64
+ draftAnswerBytes: 1_000_000,
65
+ /** Snapshots kept per (draft, meeting). */
66
+ snapshotsPerSession: 20,
67
+ /** An unbound draft whose start is this far past may be archived to make room at the 300-draft limit. */
68
+ autoArchiveAfterMs: 7 * 24 * 60 * 60_000,
69
+ /** Meeting Ask's limits for one meeting's files (MeetingAskStore.putFiles), checked before a bind writes. */
70
+ meetingFiles: 100,
71
+ meetingTextBytes: 500_000,
72
+ })
73
+
74
+ /** GET /api/health -> capabilities.prep (health adds `serverInstanceId`). Absent on an older server: "Prep me" stays a plain Ask. */
75
+ export const PREP_CAPABILITY = Object.freeze({ protocol: PREP_PROTOCOL, maxFiles: PREP_LIMITS.maxFiles, steerChars: PREP_LIMITS.steerChars, bind: true, suggest: true })
76
+
77
+ export type PrepProvider = 'eventkit' | 'm365' | 'google'
78
+ export type PrepSurface = 'web' | 'control' | 'phone' | 'g2'
79
+ export type PrepFileKind = 'document' | 'image' | 'text' | 'other'
80
+ export type PrepFileStatus = 'ready' | 'unsupported' | 'failed' | 'secret-held' | 'removed'
81
+
82
+ export interface PrepOccurrence {
83
+ /**
84
+ * "occ:v2:" + hex sha256("occ:v2:" + provider + "|" + account + "|" + calendarId + "|" + (eventId or "") + "|" +
85
+ * (externalId or "") + "|" + startsAt as exact UTC ISO, e.g. 2026-11-06T22:00:00.000Z). No quantization: distinct
86
+ * occurrences never share a key. Lookup only; it never binds.
87
+ */
88
+ key: string
89
+ provider: PrepProvider
90
+ /** The calendar account (an email or provider account id), or null when the provider has none. */
91
+ account: string | null
92
+ calendarId: string
93
+ eventId: string | null
94
+ externalId: string | null
95
+ startsAt: string
96
+ endsAt: string | null
97
+ }
98
+
99
+ export interface PrepFile {
100
+ id: string
101
+ /** The /api/media id it was copied from: one draft never holds the same media twice. */
102
+ mediaId: string
103
+ name: string
104
+ mime: string
105
+ bytes: number
106
+ /**
107
+ * sha256 (lowercase hex) of the bytes the Prep store keeps for this file, which are the bytes /api/media stored.
108
+ * For documents and plain text (and a video /api/media has not compressed) those are exactly the bytes the client
109
+ * uploaded, so this equals uploadSha256 and the add was refused unless they matched. For images /api/media keeps a
110
+ * re-encoded JPEG (original-normalized.jpg), so this is the hash of that JPEG and differs from uploadSha256.
111
+ */
112
+ sha256: string
113
+ /**
114
+ * The sha256 the client sent in POST files: the hash of the bytes it sent to /api/media. Checked against the copy
115
+ * where /api/media keeps bytes verbatim (409 media_hash_mismatch otherwise). Where /api/media re-encodes (images, a
116
+ * compressed video) the upload's bytes no longer exist on the server, so the copy is checked against the media
117
+ * store's own hash of what it stored instead, and this value is kept as the client's statement.
118
+ */
119
+ uploadSha256: string
120
+ addedAt: string
121
+ kind: PrepFileKind
122
+ status: PrepFileStatus
123
+ notice?: string
124
+ textChars: number
125
+ source: 'upload' | 'control-local'
126
+ /** Control's opaque local source id, echoed back, never interpreted. */
127
+ sourceRef?: string
128
+ }
129
+
130
+ /** One files call, kept so a repeat of its operationId replays (v1.1 #14). */
131
+ export interface PrepFileOperation {
132
+ operationId: string
133
+ fingerprint: string
134
+ fileIds: string[]
135
+ at: string
136
+ }
137
+
138
+ /**
139
+ * One run, frozen when it was asked (v1.1 #7). `state` is 'submitting' from the moment the prompt is saved until the
140
+ * job is admitted ('accepted'), or 'failed' when the admission refused it (a replay is then 409 run_failed). Once the
141
+ * job ends, its status and answer (up to 20 KB; all of a draft's answers up to 1 MB, oldest dropped first) are copied
142
+ * here when the draft or run is read, without moving the draft's revision. A new run is refused (409 run_in_progress)
143
+ * only until the previous one has a session; after that a second run may go into the same conversation while the
144
+ * first is still answering.
145
+ */
146
+ export interface PrepRun {
147
+ clientRequestId: string
148
+ clientJobId: string
149
+ jobId: string | null
150
+ sessionId: string | null
151
+ draftRevision: number
152
+ createdAt: string
153
+ state: 'submitting' | 'accepted' | 'failed'
154
+ /** sha256 of the canonical request minus volatile fields (revision, serverInstanceId). */
155
+ fingerprint: string
156
+ promptSha256: string
157
+ steerSha256: string
158
+ files: Array<{ id: string; sha256: string }>
159
+ model?: string
160
+ effort?: string
161
+ continue?: boolean
162
+ error?: string
163
+ /** Copied from the job once it is terminal (optional per v1.1 #22; absent means read the job, or it is gone). */
164
+ status?: string
165
+ /** The answer as copied, or null when the job was gone before any answer was copied (outcome 'unavailable'). */
166
+ answer?: string | null
167
+ /**
168
+ * 'unavailable': the run was accepted but its job is no longer kept (retention), so the server recorded it as ended
169
+ * with no answer. Terminal: it never blocks a new run, and GET .../runs answers 200 {run, job: null}.
170
+ */
171
+ outcome?: 'unavailable'
172
+ answerTruncated?: boolean
173
+ settledAt?: string
174
+ }
175
+
176
+ /**
177
+ * What one bind adopted, frozen (v1.1 #21), exactly {draftRevision, steer, files}. `revision` is the Meeting Ask copy
178
+ * revision (prep_<draftId>_<fileId> in the meeting); `sha256` is PrepFile.sha256. A re-bind is a new snapshot.
179
+ */
180
+ export interface PrepBindingSnapshot {
181
+ draftRevision: number
182
+ /** '' with held: true when the notes looked like a password or key (the meeting got them as secret-held, no text). */
183
+ steer: string
184
+ files: Array<{ id: string; name: string; revision: string; sha256: string }>
185
+ held?: true
186
+ }
187
+
188
+ /**
189
+ * One bind of a draft to a meeting, kept append-only (newest last, at most 20 per meeting) so a rebind never erases
190
+ * what the meeting used before. `state` is the snapshot's own: 'applying' until its rows reached Meeting Ask (and
191
+ * forever, if it was interrupted and then superseded), 'applied' once they did, 'removed' once the bind was taken back.
192
+ */
193
+ export interface PrepSnapshotRecord {
194
+ sessionId: string
195
+ state: 'applying' | 'applied' | 'removed'
196
+ boundAt: string
197
+ surface: PrepSurface
198
+ title: string
199
+ startsAt: string | null
200
+ snapshot: PrepBindingSnapshot
201
+ }
202
+
203
+ /**
204
+ * One row of GET /prep/by-session/:sessionId -> {serverInstanceId, snapshots}: a binding's frozen snapshot plus the
205
+ * title and start frozen with it. Never the latest draft, so an edit after a bind cannot rewrite what the meeting used.
206
+ */
207
+ export interface PrepSessionSnapshot extends PrepBindingSnapshot {
208
+ draftId: string
209
+ sessionId: string
210
+ title: string
211
+ startsAt: string | null
212
+ surface: PrepSurface
213
+ /** This snapshot's own state (PrepSnapshotRecord.state). An 'applying' snapshot never reached the meeting. */
214
+ state: 'applying' | 'applied' | 'removed'
215
+ boundAt: string
216
+ /** True for the snapshot the meeting's copies hold now; false for an earlier bind of the same draft. */
217
+ current: boolean
218
+ /** Set on every snapshot of a draft whose binding to this meeting was taken back. */
219
+ removedAt?: string
220
+ }
221
+
222
+ /**
223
+ * 413 {error:'file_context_limit', needBytes, haveBytes, needFiles, haveFiles} from a bind, with nothing written.
224
+ * needBytes: UTF-8 text bytes this bind would put in the meeting (the notes and each adopted file).
225
+ * haveBytes: room left, 500,000 minus the text bytes of the meeting's current files that are not this draft's own
226
+ * copies (a bind replaces its own copies). needFiles: the meeting's file count after the bind. haveFiles: 100.
227
+ */
228
+ export interface PrepFileContextLimit { error: 'file_context_limit'; needBytes: number; haveBytes: number; needFiles: number; haveFiles: number }
229
+
230
+ /** A bind is written as 'applying' before the meeting files change and 'applied' after (v1.1 #11). */
231
+ export interface PrepBinding {
232
+ sessionId: string
233
+ serverInstanceId: string
234
+ /** The draft revision this bind froze and attached. */
235
+ revision: number
236
+ state: 'applying' | 'applied'
237
+ boundAt: string
238
+ surface: PrepSurface
239
+ /** The Meeting Ask file ids this bind adopted (prep_<draftId>_steer, prep_<draftId>_<fileId>). */
240
+ fileIds: string[]
241
+ snapshot: PrepBindingSnapshot
242
+ /** The draft's title and start when it was bound, frozen with the snapshot. */
243
+ title: string
244
+ startsAt: string | null
245
+ removedAt?: string
246
+ }
247
+
248
+ export interface PrepDraft {
249
+ id: string
250
+ clientDraftId: string
251
+ /** The server that holds this draft (getServerInstanceId()), or null before it has one. */
252
+ serverInstanceId: string | null
253
+ /** CAS revision: +1 on every write. */
254
+ revision: number
255
+ createdAt: string
256
+ updatedAt: string
257
+ title: string
258
+ /** Display only; never used to match a meeting. */
259
+ startsAt: string | null
260
+ occurrence: PrepOccurrence | null
261
+ identity: 'provider' | 'none'
262
+ steer: string
263
+ files: PrepFile[]
264
+ fileOperations: PrepFileOperation[]
265
+ runs: PrepRun[]
266
+ conversation: { sessionId: string | null }
267
+ bindings: PrepBinding[]
268
+ /** Every bind's frozen snapshot, append-only (GET /prep/by-session reads these). */
269
+ snapshots: PrepSnapshotRecord[]
270
+ state: 'active' | 'archived'
271
+ }
272
+
273
+ /** "prep_" + 26 lowercase base32 (RFC 4648 alphabet). */
274
+ export const PREP_DRAFT_ID = /^prep_[a-z2-7]{26}$/
275
+ /** "pf_" + 16 hex. */
276
+ export const PREP_FILE_ID = /^pf_[0-9a-f]{16}$/
277
+ /** A client-made identity (clientDraftId, clientRequestId, operationId): a lowercase UUID v4. */
278
+ export const PREP_UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
279
+ export const PREP_PROVIDERS: readonly PrepProvider[] = ['eventkit', 'm365', 'google']
280
+ export const PREP_SURFACES: readonly PrepSurface[] = ['web', 'control', 'phone', 'g2']
281
+
282
+ /** The Meeting Ask file id the steer text takes in a bound meeting. */
283
+ export function prepSteerFileId(draftId: string): string { return `prep_${draftId}_steer` }
284
+ /** The Meeting Ask file id a Prep file takes in a bound meeting. */
285
+ export function prepAdoptedFileId(draftId: string, fileId: string): string { return `prep_${draftId}_${fileId}` }
286
+ /** A Meeting Ask file a Prep bind adopted (notes or a file). Saved-transcript cleanup does not take vocabulary from these. */
287
+ export function isPrepCopyId(id: string): boolean { return /^prep_prep_[a-z2-7]{26}_/.test(id) }
288
+ /** The Meeting Ask file name of the steer text. */
289
+ export const PREP_STEER_FILE_NAME = 'Prep notes'
290
+
291
+ /** The text prefix hashed into a Prep run's clientJobId. */
292
+ export const PREP_RUN_JOB_PREFIX = 'prep-run:v1:'
293
+
294
+ /**
295
+ * The durable query job identity of one Prep run (generation 1), v1.1 #5:
296
+ *
297
+ * hex = sha256(UTF-8 of "prep-run:v1:" + draftId + ":" + clientRequestId)
298
+ * hex[12] = '4'; hex[16] = '89ab'[parseInt(hex[16], 16) & 3] (UUID v4 version and variant)
299
+ * id = hex[0:8]-hex[8:12]-hex[12:16]-hex[16:20]-hex[20:32]
300
+ *
301
+ * A client that lost the 202 can find the job with GET /api/query-jobs/by-client/<id>?generation=1 (or
302
+ * GET /api/prep/drafts/:id/runs/:clientRequestId), and the server finds the same job on a retry instead of submitting
303
+ * a second one. Web Crypto (browsers and Node 20+), so the server, the web client and tests share this function.
304
+ */
305
+ export async function prepClientJobId(draftId: string, clientRequestId: string): Promise<string> {
306
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(`${PREP_RUN_JOB_PREFIX}${draftId}:${clientRequestId}`))
307
+ return uuidV4FromHex([...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join(''))
308
+ }
309
+
310
+ /** The first 32 hex characters with the UUID v4 version and variant nibbles forced, dashed 8-4-4-4-12. */
311
+ export function uuidV4FromHex(hex: string): string {
312
+ const h = hex.slice(0, 32).toLowerCase().split('')
313
+ h[12] = '4'
314
+ h[16] = '89ab'[parseInt(h[16], 16) & 3]
315
+ const s = h.join('')
316
+ return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20, 32)}`
317
+ }