@gotcos/glasses-server 6.99.12 → 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/CHANGELOG.md +124 -0
- package/package.json +1 -1
- package/server/index.ts +44 -2
- package/server/lib/ask-work-plan.ts +116 -0
- package/server/lib/batch-transcript-quality.ts +2 -0
- package/server/lib/cos-thread.ts +582 -0
- package/server/lib/drop-attachments.ts +55 -0
- package/server/lib/drop-service.ts +104 -13
- package/server/lib/drop-settings.ts +13 -2
- package/server/lib/drop-store.ts +38 -2
- package/server/lib/media-store.ts +80 -0
- package/server/lib/meeting-ask-runtime.ts +1 -1
- package/server/lib/meeting-batch-transcribe.ts +54 -2
- package/server/lib/permission-broker.ts +6 -4
- package/server/lib/phone-recording.ts +1 -1
- package/server/lib/ping-sources.ts +6 -2
- package/server/lib/prep-runtime.ts +915 -0
- package/server/lib/prep-store.ts +226 -0
- package/server/lib/stt-resolver.ts +26 -2
- package/server/lib/transcript-cleanup.ts +11 -2
- package/server/lib/work-orchestrator/runtime.ts +8 -2
- package/server/routes/ask-work-plan.ts +114 -0
- package/server/routes/cos-thread.ts +79 -0
- package/server/routes/drops.ts +4 -2
- package/server/routes/health.ts +8 -0
- package/server/routes/permission-broker.ts +2 -2
- package/server/routes/prep.ts +81 -0
- package/server/routes/transcribe-stream.ts +9 -1
- package/server/routes/welcome-context.ts +29 -1
- package/server/routes/work-orchestrator.ts +57 -0
- package/server/scripts/web-live-cls/cdp.mjs +57 -0
- package/server/scripts/web-live-cls/measure.mjs +80 -0
- package/server/scripts/web-live-cls/mock.mjs +97 -0
- package/server/scripts/web-live-cls/render.mjs +26 -0
- package/server/web-control/control.css +13 -0
- package/server/web-control/panes/cos-chat.css +39 -0
- package/server/web-control/panes/cos-chat.js +799 -41
- package/server/web-control/panes/meetings.css +91 -4
- package/server/web-control/panes/meetings.js +585 -58
- package/server/web-control/panes/messages.js +25 -18
- package/shared/meeting-ask.ts +6 -2
- package/shared/prep.ts +317 -0
|
@@ -62,10 +62,16 @@
|
|
|
62
62
|
function headline(q) {
|
|
63
63
|
var s = String(q || '').replace(/\s+/g, ' ').trim();
|
|
64
64
|
if (!s) return '(empty query)';
|
|
65
|
-
return s.length >
|
|
65
|
+
return s.length > 240 ? s.slice(0, 239) + '…' : s;
|
|
66
66
|
}
|
|
67
67
|
function modelOf(t) { var m = String(t.modelPreference || ''); return /^[A-Za-z0-9._:-]{1,64}$/.test(m) ? m : ''; }
|
|
68
|
-
|
|
68
|
+
// 6.99.15: the row says "Opus", not "opus"; a model id it does not know stays as the server wrote it.
|
|
69
|
+
var MODEL_NAMES = { opus: 'Opus', sonnet: 'Sonnet', fable: 'Fable', haiku: 'Haiku' };
|
|
70
|
+
function modelName(m) { return MODEL_NAMES[m] || m; }
|
|
71
|
+
// A preview of an answer whose progress lines were joined with no space ("now.Reading the tax"): one space after a
|
|
72
|
+
// sentence's end when the next sentence starts at once. Previews only; the reader shows the answer as written.
|
|
73
|
+
function previewText(text) { return stripMd(text).replace(/([a-z0-9)][.!?])([A-Z][a-z])/g, '$1 $2'); }
|
|
74
|
+
function originTag(t) { return t.origin === 'routine' ? 'Routine' : t.origin === 'task' ? 'Task' : ''; }
|
|
69
75
|
function originTitle(t) {
|
|
70
76
|
var id = t.originId ? ' (' + t.originId + ')' : '';
|
|
71
77
|
return t.origin === 'routine' ? 'Produced by a scheduled routine on your Mac' + id : t.origin === 'task' ? 'Produced by an agent working a task on your Mac' + id : '';
|
|
@@ -285,8 +291,8 @@
|
|
|
285
291
|
var detail;
|
|
286
292
|
if (S.recentState === 'loading' && !S.recent) detail = 'Loading…';
|
|
287
293
|
else if (S.recentState === 'error') detail = S.recentError === 'signed-out' ? 'Signed out' : 'Could not load messages';
|
|
288
|
-
else if (S.recent && S.recent.length) detail =
|
|
289
|
-
else if (S.recent) detail = 'No
|
|
294
|
+
else if (S.recent && S.recent.length) detail = S.recent.length + ' ' + W.plural(S.recent.length, 'message', 'messages') + ', newest first';
|
|
295
|
+
else if (S.recent) detail = 'No messages today';
|
|
290
296
|
else detail = 'Refresh to load';
|
|
291
297
|
return '<header class="cx-hero" data-tint="messages">' +
|
|
292
298
|
'<span class="cx-glyph"><svg class="icon"><use href="#i-messages"/></svg></span>' +
|
|
@@ -298,7 +304,7 @@
|
|
|
298
304
|
}
|
|
299
305
|
function controlsHtml() {
|
|
300
306
|
return '<div class="cx-controls">' +
|
|
301
|
-
'<div class="cx-switch" role="group" aria-label="Message view"
|
|
307
|
+
'<div class="cx-switch" role="group" aria-label="Message view">' +
|
|
302
308
|
'<button type="button" data-msg="view" data-view="recent" aria-pressed="' + (S.view === 'recent') + '">Recent</button>' +
|
|
303
309
|
'<button type="button" data-msg="view" data-view="archive" aria-pressed="' + (S.view === 'archive') + '">Archive</button>' +
|
|
304
310
|
'</div>' +
|
|
@@ -318,24 +324,26 @@
|
|
|
318
324
|
? '<span>' + esc(recentSide) + '</span><span>·</span><button type="button" class="door" data-msg="view" data-view="archive">' + esc(archSide) + '<svg class="icon" aria-hidden="true"><use href="#i-chev"/></svg></button>'
|
|
319
325
|
: '<button type="button" class="door" data-msg="view" data-view="recent">' + esc(recentSide) + '<svg class="icon" aria-hidden="true"><use href="#i-chev"/></svg></button><span>·</span><span>' + esc(archSide) + '</span>') + '</p>';
|
|
320
326
|
}
|
|
327
|
+
// 6.99.15: one row is the ask as its title with the time beside it, the answer under it, and one quiet line of
|
|
328
|
+
// facts (number, Routine or Task, model, attachments). No glyph per row: every row was the same bubble.
|
|
321
329
|
function recentRowHtml(t, i) {
|
|
322
330
|
var a = atts(t);
|
|
323
331
|
var kinds = a.map(attKind);
|
|
324
332
|
var n = numOf(t);
|
|
325
333
|
var tag = originTag(t);
|
|
326
334
|
var model = modelOf(t);
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
335
|
+
var facts = [];
|
|
336
|
+
if (n != null) facts.push('<span class="cx-num">#' + esc(n) + '</span>');
|
|
337
|
+
if (tag) facts.push('<span class="cx-tag" title="' + esc(originTitle(t)) + '">' + tag + '</span>');
|
|
338
|
+
if (model) facts.push('<span class="cx-model">' + esc(modelName(model)) + '</span>');
|
|
339
|
+
if (a.length) facts.push('<span class="cx-att" title="' + esc(attTitle(a)) + '"><svg class="icon"><use href="#i-' + attIcon(kinds) + '"/></svg>' + esc(attTitle(a)) + '</span>');
|
|
340
|
+
var sub = previewText(t.text);
|
|
341
|
+
return '<button type="button" class="cx-row cx-msgrow" data-msg="open" data-key="' + esc(keyOf(t, i)) + '">' +
|
|
330
342
|
'<span class="cx-row-body">' +
|
|
331
|
-
'<span class="cx-
|
|
332
|
-
'<span class="cx-time">' + esc(timeLabel(t.timestamp)) + '</span>' +
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
(a.length ? '<span class="cx-att" title="' + esc(attTitle(a)) + '"><svg class="icon"><use href="#i-' + attIcon(kinds) + '"/></svg>' + a.length + '</span>' : '') +
|
|
336
|
-
'</span>' +
|
|
337
|
-
'<span class="cx-row-title">' + esc(headline(t.query)) + '</span>' +
|
|
338
|
-
'<span class="cx-row-sub">' + esc(stripMd(t.text)) + '</span>' +
|
|
343
|
+
'<span class="cx-msg-top"><span class="cx-row-title">' + esc(headline(t.query)) + '</span>' +
|
|
344
|
+
'<span class="cx-time">' + esc(timeLabel(t.timestamp)) + '</span></span>' +
|
|
345
|
+
(sub ? '<span class="cx-row-sub">' + esc(sub) + '</span>' : '') +
|
|
346
|
+
(facts.length ? '<span class="cx-meta cx-facts">' + facts.join('') + '</span>' : '') +
|
|
339
347
|
'</span><span class="chev"></span></button>';
|
|
340
348
|
}
|
|
341
349
|
// The pinned COS chat, first in Recent (Drop it on only, and not while a search narrows the list).
|
|
@@ -361,8 +369,7 @@
|
|
|
361
369
|
return '<div class="cx-list"><p class="cx-empty">No recent message contains “' + esc(q) + '”' +
|
|
362
370
|
(days ? ', but ' + days + ' archived ' + W.plural(days, 'day', 'days') + ' ' + (days === 1 ? 'does' : 'do') + '.' : '. The archive goes back further.') + '</p></div>';
|
|
363
371
|
}
|
|
364
|
-
return '<div class="cx-list">' + shown.map(function (t) { return recentRowHtml(t, list.indexOf(t)); }).join('') + '</div>'
|
|
365
|
-
'<p class="cx-note">ROUTINE and TASK mark a run your Mac started.</p>';
|
|
372
|
+
return '<div class="cx-list">' + shown.map(function (t) { return recentRowHtml(t, list.indexOf(t)); }).join('') + '</div>';
|
|
366
373
|
}
|
|
367
374
|
function archiveBody() {
|
|
368
375
|
var q = S.q.trim();
|
package/shared/meeting-ask.ts
CHANGED
|
@@ -33,8 +33,12 @@ export interface MeetingAskFile {
|
|
|
33
33
|
status: 'ready' | 'pending' | 'unsupported' | 'failed' | 'removed' | 'secret-held';
|
|
34
34
|
text: string;
|
|
35
35
|
notice?: string;
|
|
36
|
-
/**
|
|
37
|
-
|
|
36
|
+
/**
|
|
37
|
+
* "phone" for a file the phone attached through POST files/media (6.71.1). "prep" (6.99.19) for a copy a Prep bind
|
|
38
|
+
* adopted (id prep_<draftId>_<fileId>, or prep_<draftId>_steer for "Prep notes"). Control's own rows omit it, and a
|
|
39
|
+
* client that does not know a value shows the row as a plain file.
|
|
40
|
+
*/
|
|
41
|
+
origin?: 'phone' | 'prep';
|
|
38
42
|
}
|
|
39
43
|
export interface MeetingAskSource {
|
|
40
44
|
/** Server-owned accepted post-stop quotation; no live turn target or precise word clock. */
|
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
|
+
}
|