@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/CHANGELOG.md +46 -0
- package/package.json +1 -1
- package/server/index.ts +21 -0
- package/server/lib/ask-work-plan.ts +116 -0
- package/server/lib/batch-transcript-quality.ts +2 -0
- package/server/lib/drop-store.ts +16 -2
- package/server/lib/meeting-ask-runtime.ts +1 -1
- package/server/lib/meeting-batch-transcribe.ts +40 -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/health.ts +8 -0
- package/server/routes/prep.ts +81 -0
- package/server/routes/welcome-context.ts +29 -1
- package/server/routes/work-orchestrator.ts +57 -0
- package/server/web-control/panes/cos-chat.css +22 -0
- package/server/web-control/panes/cos-chat.js +615 -2
- package/server/web-control/panes/meetings.css +30 -0
- package/server/web-control/panes/meetings.js +230 -7
- package/shared/meeting-ask.ts +6 -2
- package/shared/prep.ts +317 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prep v1.1 (6.99.19): the durable Prep store at dataPath('prep').
|
|
3
|
+
*
|
|
4
|
+
* drafts/<draftId>.json one PrepDraft, written whole (fsync + rename, mode 0600)
|
|
5
|
+
* files/<draftId>/<fileId>.bin the original bytes, copied out of /api/media at add time (the media stage expires in 4 h)
|
|
6
|
+
* files/<draftId>/<fileId>.txt the extracted text a run and a bind read (ready files only)
|
|
7
|
+
* runs/<draftId>/<clientRequestId>.txt a run's exact prompt, saved before the job is submitted
|
|
8
|
+
* binds/<draftId>/<sessionId>/<revision>.json the exact Meeting Ask rows a bind attaches, saved before its intent
|
|
9
|
+
*
|
|
10
|
+
* Drafts are read back from disk on first use, so a restarted server (a fresh instance) serves the same drafts. Writes
|
|
11
|
+
* are synchronous inside one server process; prep-runtime.ts serializes each draft's read-modify-write. Nothing is
|
|
12
|
+
* auto-deleted in v1. A draft file that cannot be read is skipped and logged, never deleted.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from 'node:crypto'
|
|
15
|
+
import { lstatSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'
|
|
16
|
+
import { join, resolve } from 'node:path'
|
|
17
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
18
|
+
import { dataPath } from './data-dir.js'
|
|
19
|
+
import { ASK_SESSION } from './ask-session-id.js'
|
|
20
|
+
import { PREP_DRAFT_ID, PREP_FILE_ID, PREP_PROVIDERS, PREP_UUID_V4, type PrepDraft, type PrepOccurrence } from '../../shared/prep.js'
|
|
21
|
+
|
|
22
|
+
export class PrepError extends Error {
|
|
23
|
+
constructor(readonly code: string, readonly status = 400, readonly draft?: PrepDraft, readonly extra: Record<string, unknown> = {}) {
|
|
24
|
+
super(code)
|
|
25
|
+
this.name = 'PrepError'
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* v1.1 #16: "occ:v2:" + sha256("occ:v2:" + provider|account|calendarId|eventId or ""|externalId or ""|startsAt exact
|
|
31
|
+
* UTC ISO). Every part, no quantization, so distinct occurrences never coalesce. Finds a draft; never binds one.
|
|
32
|
+
*/
|
|
33
|
+
export function prepOccurrenceKey(o: Pick<PrepOccurrence, 'provider' | 'account' | 'calendarId' | 'eventId' | 'externalId' | 'startsAt'>): string {
|
|
34
|
+
const text = 'occ:v2:' + [o.provider, o.account ?? '', o.calendarId, o.eventId ?? '', o.externalId ?? '', new Date(Date.parse(o.startsAt)).toISOString()].join('|')
|
|
35
|
+
return 'occ:v2:' + createHash('sha256').update(text).digest('hex')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The provider identity, or null. All of provider, calendarId, eventId or externalId, and startsAt must be present
|
|
40
|
+
* (account is optional and part of the key); anything less is no identity at all, never a guess from title or time.
|
|
41
|
+
* A client-sent `key` is ignored and recomputed. A non-object is a 400.
|
|
42
|
+
*/
|
|
43
|
+
export function parsePrepOccurrence(raw: unknown): PrepOccurrence | null {
|
|
44
|
+
if (raw === null) return null
|
|
45
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new PrepError('invalid_occurrence')
|
|
46
|
+
const o = raw as Record<string, unknown>
|
|
47
|
+
const id = (v: unknown) => typeof v === 'string' && v.trim() && v.length <= 512 ? v : null
|
|
48
|
+
const iso = (v: unknown) => typeof v === 'string' && v.length <= 64 && Number.isFinite(Date.parse(v)) ? new Date(Date.parse(v)).toISOString() : null
|
|
49
|
+
const provider = PREP_PROVIDERS.includes(o.provider as never) ? o.provider as PrepOccurrence['provider'] : null
|
|
50
|
+
const account = id(o.account), calendarId = id(o.calendarId), eventId = id(o.eventId), externalId = id(o.externalId)
|
|
51
|
+
const startsAt = iso(o.startsAt)
|
|
52
|
+
if (!provider || !calendarId || (!eventId && !externalId) || !startsAt) return null
|
|
53
|
+
const base = { provider, account, calendarId, eventId, externalId, startsAt }
|
|
54
|
+
return { key: prepOccurrenceKey(base), ...base, endsAt: iso(o.endsAt) }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The loader skips a draft file larger than this; writes stop well short of it. */
|
|
58
|
+
export const DRAFT_LOAD_BYTES = 5_000_000
|
|
59
|
+
export const DRAFT_WRITE_BYTES = 4_000_000
|
|
60
|
+
|
|
61
|
+
export class PrepStore {
|
|
62
|
+
readonly root: string
|
|
63
|
+
private cache: Map<string, PrepDraft> | null = null
|
|
64
|
+
|
|
65
|
+
constructor(root = dataPath('prep')) { this.root = resolve(root) }
|
|
66
|
+
|
|
67
|
+
private dir(...parts: string[]): string {
|
|
68
|
+
mkdirSync(this.root, { recursive: true, mode: 0o700 })
|
|
69
|
+
if (lstatSync(this.root).isSymbolicLink()) throw new PrepError('prep_store_unsafe', 503)
|
|
70
|
+
let p = this.root
|
|
71
|
+
for (const part of parts) {
|
|
72
|
+
p = join(p, part)
|
|
73
|
+
mkdirSync(p, { recursive: true, mode: 0o700 })
|
|
74
|
+
if (lstatSync(p).isSymbolicLink()) throw new PrepError('prep_store_unsafe', 503)
|
|
75
|
+
}
|
|
76
|
+
return p
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private load(): Map<string, PrepDraft> {
|
|
80
|
+
if (this.cache) return this.cache
|
|
81
|
+
const drafts = new Map<string, PrepDraft>()
|
|
82
|
+
const dir = this.dir('drafts')
|
|
83
|
+
for (const name of readdirSync(dir)) {
|
|
84
|
+
if (!name.endsWith('.json')) continue
|
|
85
|
+
const id = name.slice(0, -5)
|
|
86
|
+
try {
|
|
87
|
+
const path = join(dir, name), st = lstatSync(path)
|
|
88
|
+
if (!PREP_DRAFT_ID.test(id) || !st.isFile() || st.size > DRAFT_LOAD_BYTES) throw new Error('unsafe')
|
|
89
|
+
const draft = JSON.parse(readFileSync(path, 'utf8')) as PrepDraft
|
|
90
|
+
if (draft?.id !== id || !Number.isSafeInteger(draft.revision) || !Array.isArray(draft.files)) throw new Error('shape')
|
|
91
|
+
draft.snapshots ??= []
|
|
92
|
+
draft.fileOperations ??= []
|
|
93
|
+
drafts.set(id, draft)
|
|
94
|
+
} catch {
|
|
95
|
+
// Kept on disk for a person to look at; it simply is not served.
|
|
96
|
+
console.warn(`[prep] skipped an unreadable draft file (${id.slice(0, 12)})`)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
this.cache = drafts
|
|
100
|
+
return drafts
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
get(id: string): PrepDraft | undefined {
|
|
104
|
+
if (!PREP_DRAFT_ID.test(id)) return undefined
|
|
105
|
+
const d = this.load().get(id)
|
|
106
|
+
return d ? structuredClone(d) : undefined
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
list(): PrepDraft[] { return [...this.load().values()].map(d => structuredClone(d)) }
|
|
110
|
+
|
|
111
|
+
byClientDraftId(clientDraftId: string): PrepDraft | undefined {
|
|
112
|
+
for (const d of this.load().values()) if (d.clientDraftId === clientDraftId) return structuredClone(d)
|
|
113
|
+
return undefined
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
write(draft: PrepDraft): PrepDraft {
|
|
117
|
+
if (!PREP_DRAFT_ID.test(draft.id)) throw new PrepError('invalid_draft_id')
|
|
118
|
+
const encoded = JSON.stringify(draft)
|
|
119
|
+
// Never write a draft the loader would refuse: it would vanish on the next restart.
|
|
120
|
+
if (Buffer.byteLength(encoded, 'utf8') > DRAFT_WRITE_BYTES) throw new PrepError('prep_draft_too_large', 507)
|
|
121
|
+
try {
|
|
122
|
+
durableAtomicWriteFileSync(join(this.dir('drafts'), draft.id + '.json'), encoded, { mode: 0o600 })
|
|
123
|
+
} catch (e) {
|
|
124
|
+
if (e instanceof PrepError) throw e
|
|
125
|
+
throw new PrepError('prep_write_failed', 507)
|
|
126
|
+
}
|
|
127
|
+
this.load().set(draft.id, structuredClone(draft))
|
|
128
|
+
return structuredClone(draft)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private blobPath(draftId: string, fileId: string, ext: 'bin' | 'txt'): string {
|
|
132
|
+
if (!PREP_DRAFT_ID.test(draftId) || !PREP_FILE_ID.test(fileId)) throw new PrepError('invalid_file_id')
|
|
133
|
+
return join(this.dir('files', draftId), `${fileId}.${ext}`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
putOriginal(draftId: string, fileId: string, bytes: Buffer): void {
|
|
137
|
+
try { durableAtomicWriteFileSync(this.blobPath(draftId, fileId, 'bin'), bytes, { mode: 0o600 }) } catch (e) {
|
|
138
|
+
if (e instanceof PrepError) throw e
|
|
139
|
+
throw new PrepError('prep_write_failed', 507)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
putText(draftId: string, fileId: string, text: string): void {
|
|
144
|
+
try { durableAtomicWriteFileSync(this.blobPath(draftId, fileId, 'txt'), text, { mode: 0o600 }) } catch (e) {
|
|
145
|
+
if (e instanceof PrepError) throw e
|
|
146
|
+
throw new PrepError('prep_write_failed', 507)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
readOriginal(draftId: string, fileId: string): Buffer {
|
|
151
|
+
try { return readFileSync(this.blobPath(draftId, fileId, 'bin')) } catch (e) {
|
|
152
|
+
if (e instanceof PrepError) throw e
|
|
153
|
+
throw new PrepError('file_not_found', 404)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The stored text, or '' when the file has none (not ready, or never extracted). */
|
|
158
|
+
readText(draftId: string, fileId: string): string {
|
|
159
|
+
try { return readFileSync(this.blobPath(draftId, fileId, 'txt'), 'utf8') } catch (e) {
|
|
160
|
+
if (e instanceof PrepError) throw e
|
|
161
|
+
return ''
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The exact prompt of a run, saved before its job is submitted so a retry after a crash resubmits the same bytes. */
|
|
166
|
+
putPrompt(draftId: string, clientRequestId: string, prompt: string): void {
|
|
167
|
+
try { durableAtomicWriteFileSync(this.promptPath(draftId, clientRequestId), prompt, { mode: 0o600 }) } catch (e) {
|
|
168
|
+
if (e instanceof PrepError) throw e
|
|
169
|
+
throw new PrepError('prep_write_failed', 507)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
readPrompt(draftId: string, clientRequestId: string): string | null {
|
|
174
|
+
try { return readFileSync(this.promptPath(draftId, clientRequestId), 'utf8') } catch (e) {
|
|
175
|
+
if (e instanceof PrepError) throw e
|
|
176
|
+
return null
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private promptPath(draftId: string, clientRequestId: string): string {
|
|
181
|
+
if (!PREP_DRAFT_ID.test(draftId) || !PREP_UUID_V4.test(clientRequestId)) throw new PrepError('invalid_client_request_id')
|
|
182
|
+
return join(this.dir('runs', draftId), `${clientRequestId}.txt`)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The exact Meeting Ask rows one bind attaches, saved before the binding intent, so a retry after a crash re-applies
|
|
187
|
+
* the frozen revision rather than whatever the draft holds by then.
|
|
188
|
+
*/
|
|
189
|
+
putBindPayload(draftId: string, sessionId: string, revision: number, rows: unknown[]): void {
|
|
190
|
+
try { durableAtomicWriteFileSync(this.bindPath(draftId, sessionId, revision), JSON.stringify(rows), { mode: 0o600 }) } catch (e) {
|
|
191
|
+
if (e instanceof PrepError) throw e
|
|
192
|
+
throw new PrepError('prep_write_failed', 507)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
readBindPayload(draftId: string, sessionId: string, revision: number): unknown[] | null {
|
|
197
|
+
try {
|
|
198
|
+
const rows = JSON.parse(readFileSync(this.bindPath(draftId, sessionId, revision), 'utf8'))
|
|
199
|
+
return Array.isArray(rows) ? rows : null
|
|
200
|
+
} catch (e) {
|
|
201
|
+
if (e instanceof PrepError) throw e
|
|
202
|
+
return null
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Keyed by (draft, meeting, revision): a replay of one bind can never read another bind's rows. */
|
|
207
|
+
private bindPath(draftId: string, sessionId: string, revision: number): string {
|
|
208
|
+
if (!PREP_DRAFT_ID.test(draftId) || !ASK_SESSION.test(sessionId) || !Number.isSafeInteger(revision) || revision < 0) throw new PrepError('invalid_session_id')
|
|
209
|
+
return join(this.dir('binds', draftId, sessionId), `${revision}.json`)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Bytes on disk under the store root (drafts, originals, text). Walked on each add: at most 300 drafts x 10 files. */
|
|
213
|
+
usageBytes(): number {
|
|
214
|
+
let total = 0
|
|
215
|
+
const walk = (p: string) => {
|
|
216
|
+
for (const name of readdirSync(p)) {
|
|
217
|
+
const child = join(p, name), st = lstatSync(child)
|
|
218
|
+
if (st.isSymbolicLink()) continue
|
|
219
|
+
if (st.isDirectory()) walk(child)
|
|
220
|
+
else if (st.isFile()) total += st.size
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
walk(this.dir())
|
|
224
|
+
return total
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -17,6 +17,13 @@
|
|
|
17
17
|
// holds a profile vocabulary term the primary's side does not, the second
|
|
18
18
|
// opinion's words are taken.
|
|
19
19
|
//
|
|
20
|
+
// A third caller (6.99.18) is the saved iPhone transcript, where the second
|
|
21
|
+
// side is the live Whisper words the phone already heard with the vocabulary
|
|
22
|
+
// prompt. Those words have approximate clocks and a prompted Whisper can echo a
|
|
23
|
+
// vocabulary term into a pause, so that caller sets anchoredVocabularyOnly: a
|
|
24
|
+
// term is taken only where the primary heard other words in the same place,
|
|
25
|
+
// and the taken words are clocked across the primary words they replace.
|
|
26
|
+
//
|
|
20
27
|
// Everything else stays as the primary heard it. No region whose two sides
|
|
21
28
|
// differ in a negation, a number or a unit is ever changed, whatever its length
|
|
22
29
|
// (the 2026-10-13 diagnostic saw "born lacking" become "born having" and
|
|
@@ -37,7 +44,7 @@ export interface Hypothesis {
|
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
export type DisagreementKind = 'substitution' | 'primary_only' | 'second_only'
|
|
40
|
-
export type ResolutionReason = 'gap_fill' | 'vocabulary' | 'protected' | 'not_speech' | 'low_confidence' | 'too_wide' | null
|
|
47
|
+
export type ResolutionReason = 'gap_fill' | 'vocabulary' | 'protected' | 'not_speech' | 'low_confidence' | 'too_wide' | 'unanchored' | null
|
|
41
48
|
|
|
42
49
|
export interface Disagreement {
|
|
43
50
|
kind: DisagreementKind
|
|
@@ -66,6 +73,10 @@ export interface ResolveOptions {
|
|
|
66
73
|
* 10 s) made the resolver worse than plain text alignment, because Large-v3's
|
|
67
74
|
* remapped token clocks have a heavy tail (median skew 0.23 s, p90 1.0 s). */
|
|
68
75
|
maxSkewSec?: number | null
|
|
76
|
+
/** The second side's clocks are approximate (live words). A vocabulary term is
|
|
77
|
+
* taken only from a substitution, never an insertion the primary has nothing
|
|
78
|
+
* for, and the taken words are clocked across the primary words they replace. */
|
|
79
|
+
anchoredVocabularyOnly?: boolean
|
|
69
80
|
}
|
|
70
81
|
|
|
71
82
|
export interface ResolveResult {
|
|
@@ -241,6 +252,15 @@ function gapVerdict(q: Token[], options: ResolveOptions): ResolutionReason {
|
|
|
241
252
|
return 'gap_fill'
|
|
242
253
|
}
|
|
243
254
|
|
|
255
|
+
/** The second side's words spread evenly over the time the replaced primary words took. */
|
|
256
|
+
function reclock(q: Token[], p: Token[]): Token[] {
|
|
257
|
+
const { start, end } = span(p)
|
|
258
|
+
if (start === null) return q.map(t => ({ ...t, start: null, end: null }))
|
|
259
|
+
const stop = Math.max(start, end ?? start)
|
|
260
|
+
const step = (stop - start) / q.length
|
|
261
|
+
return q.map((t, i) => ({ ...t, start: start + i * step, end: start + (i + 1) * step }))
|
|
262
|
+
}
|
|
263
|
+
|
|
244
264
|
export function resolveHypotheses(primary: Hypothesis, second: Hypothesis, options: ResolveOptions): ResolveResult {
|
|
245
265
|
const P = tokens(primary), Q = tokens(second)
|
|
246
266
|
const toWords = (ts: Token[]): HypWord[] => ts.map(t => ({
|
|
@@ -290,6 +310,7 @@ export function resolveHypotheses(primary: Hypothesis, second: Hypothesis, optio
|
|
|
290
310
|
const wantVocab = newTerms.length > 0
|
|
291
311
|
if (wantGap || wantVocab) {
|
|
292
312
|
if (!sameProtected(p, q)) reason = 'protected'
|
|
313
|
+
else if (wantVocab && options.anchoredVocabularyOnly && !p.length) reason = 'unanchored'
|
|
293
314
|
else if (wantVocab && (q.length > MAX_VOCAB_REGION_WORDS || p.length > q.length + MAX_VOCAB_REGION_SLACK)) {
|
|
294
315
|
// A term inside a long disagreement is not a reason to swap the whole region.
|
|
295
316
|
reason = wantGap ? gapVerdict(q, options) : 'too_wide'
|
|
@@ -308,7 +329,10 @@ export function resolveHypotheses(primary: Hypothesis, second: Hypothesis, optio
|
|
|
308
329
|
taken: options.apply ? take : 'primary',
|
|
309
330
|
reason,
|
|
310
331
|
})
|
|
311
|
-
if (options.apply && take === 'second') {
|
|
332
|
+
if (options.apply && take === 'second') {
|
|
333
|
+
out.push(...(options.anchoredVocabularyOnly ? reclock(q, p) : q))
|
|
334
|
+
wordsFromSecond += q.length
|
|
335
|
+
}
|
|
312
336
|
else out.push(...p)
|
|
313
337
|
}
|
|
314
338
|
return {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
|
|
29
29
|
import { existsSync, mkdirSync, appendFileSync, readFileSync, renameSync, statSync } from 'node:fs'
|
|
30
30
|
import { dirname } from 'node:path'
|
|
31
|
+
import { isPrepCopyId } from '../../shared/prep.js'
|
|
31
32
|
import { LensGistLimitError, runLensGistEngine } from './lens-gist-engines.js'
|
|
32
33
|
import { resolveProviderBinary } from './provider-binary.js'
|
|
33
34
|
import { getVocabulary, getWhisperCorrections } from './profile.js'
|
|
@@ -167,11 +168,19 @@ const notesSearchDefault: CleanupDeps['searchNotes'] = async (queries, sessionId
|
|
|
167
168
|
})(queries, sessionId, signal)
|
|
168
169
|
}
|
|
169
170
|
|
|
171
|
+
/**
|
|
172
|
+
* The meeting files whose words may steer saved-transcript cleanup. Pasted text and read pages (paste_, web_) are
|
|
173
|
+
* article words, not the room's vocabulary, and so are Prep copies (prep_prep_..., 6.99.19): notes and documents
|
|
174
|
+
* written before the call. None of them may rewrite the saved transcript.
|
|
175
|
+
*/
|
|
176
|
+
export function cleanupVocabularyFiles(files: ReadonlyArray<{ id: string; name: string; status: string; text: string }>, isPastedFileId: (id: string) => boolean): CleanupFile[] {
|
|
177
|
+
return files.filter(f => !isPastedFileId(f.id) && !isPrepCopyId(f.id) && f.status === 'ready' && typeof f.text === 'string' && f.text.trim()).map(f => ({ name: f.name, text: f.text }))
|
|
178
|
+
}
|
|
179
|
+
|
|
170
180
|
const filesDefault: CleanupDeps['files'] = async sessionId => {
|
|
171
181
|
try {
|
|
172
182
|
const { MeetingAskStore, isPastedFileId } = await import('./meeting-ask-store.js')
|
|
173
|
-
|
|
174
|
-
return new MeetingAskStore().files(sessionId).filter(f => !isPastedFileId(f.id) && f.status === 'ready' && typeof f.text === 'string' && f.text.trim()).map(f => ({ name: f.name, text: f.text }))
|
|
183
|
+
return cleanupVocabularyFiles(new MeetingAskStore().files(sessionId), isPastedFileId)
|
|
175
184
|
} catch { return [] }
|
|
176
185
|
}
|
|
177
186
|
|
|
@@ -48,14 +48,20 @@ export const INPUT_SCAN_WAIT_MS = 20_000
|
|
|
48
48
|
|
|
49
49
|
/** What one plan input reads. The server passes the process-wide stores; a test passes its own. */
|
|
50
50
|
export interface OrchestratorParts { tags: TagIndex; usage: UsageMonitor; snapshot: () => ControlSnapshot | null }
|
|
51
|
+
export interface OrchestratorInputOptions {
|
|
52
|
+
/** Read the existing usage view only, without starting provider usage subprocesses. Default retains Work's refresh. */
|
|
53
|
+
usageMode?: 'cached'
|
|
54
|
+
}
|
|
51
55
|
|
|
52
|
-
export async function orchestratorInputFor(workID: string, meetingWorkIDs: string[], parts?: OrchestratorParts
|
|
56
|
+
export async function orchestratorInputFor(workID: string, meetingWorkIDs: string[], parts?: OrchestratorParts,
|
|
57
|
+
options: OrchestratorInputOptions = {}): Promise<OrchestratorInput> {
|
|
53
58
|
const tags = parts?.tags ?? orchestratorTagIndex()
|
|
54
59
|
const monitor = parts?.usage ?? orchestratorUsage()
|
|
55
60
|
const scan = tags.refresh().catch(() => null)
|
|
56
61
|
const scanned = await Promise.race([scan, new Promise<null>(r => setTimeout(() => r(null), INPUT_SCAN_WAIT_MS).unref?.())])
|
|
57
62
|
// On demand: the plan this input feeds may fork or start a session, so a reading older than 2 minutes is re-read.
|
|
58
|
-
|
|
63
|
+
// Ask's proposal-only adapter uses cache-only evidence; it does not own the usage probes' child lifetimes.
|
|
64
|
+
const usageViews = options.usageMode === 'cached' ? monitor.views() : await monitor.forPlan()
|
|
59
65
|
const roots = agentSessionRoots()
|
|
60
66
|
return buildOrchestratorInput(workID, meetingWorkIDs, {
|
|
61
67
|
snapshot: parts ? parts.snapshot() : orchestratorSnapshots().get(),
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** Mounted under authenticated /api. Plan-only: never records a round, sends a handoff or writes a task. */
|
|
2
|
+
import { Router } from 'express'
|
|
3
|
+
import { ASK_WORK_PLAN_PROTOCOL, ASK_WORK_PLAN_BODY_BYTES, ASK_WORK_PLAN_DEADLINE_MS, ASK_WORK_PLAN_RULES,
|
|
4
|
+
AskWorkPlanError, parseAskWorkPlanRequest, resolveAskWorkTask, askWorkMeetingIDs, prepareAskWorkPlan, askWorkProposalID } from '../lib/ask-work-plan.js'
|
|
5
|
+
import { listBoard, type TaskBoardRow } from '../lib/task-store.js'
|
|
6
|
+
import { createWorkBoardReader } from '../lib/work-board-reader.js'
|
|
7
|
+
import { orchestratorInputFor, type OrchestratorParts } from '../lib/work-orchestrator/runtime.js'
|
|
8
|
+
import { runMeetingAskModel, AskProcessError } from '../lib/meeting-ask-runner.js'
|
|
9
|
+
import { maintenanceAdmissionsOpen, acquireMaintenanceWork } from '../lib/maintenance-lifecycle.js'
|
|
10
|
+
import { parseWorkChatAnswer, WORK_CHAT_SYSTEM, WORK_CHAT_ORCHESTRATOR_RULES, WORK_CHAT_PLANNERS, WORK_CHAT_EFFORTS,
|
|
11
|
+
WORK_CHAT_DEFAULT_EFFORT, WORK_CHAT_ORCHESTRATOR_OUTPUT_TOKENS, WORK_CHAT_THINKING_TOKENS,
|
|
12
|
+
WORK_CHAT_DEADLINE_MS, WORK_CHAT_MAX_ASSIGNMENTS } from '../lib/work-chat.js'
|
|
13
|
+
|
|
14
|
+
export interface AskWorkPlanDeps {
|
|
15
|
+
board: () => Promise<TaskBoardRow[]>
|
|
16
|
+
input: typeof orchestratorInputFor
|
|
17
|
+
run: typeof runMeetingAskModel
|
|
18
|
+
admissions: () => boolean
|
|
19
|
+
lease: () => { release(): void }
|
|
20
|
+
now: () => number
|
|
21
|
+
}
|
|
22
|
+
/** Ask never launches usage probes; stale or unknown cached headroom disables targets in prepareAskWorkPlan. */
|
|
23
|
+
export function readAskWorkPlanInput(workID: string, meetingWorkIDs: string[], parts?: OrchestratorParts) {
|
|
24
|
+
return orchestratorInputFor(workID, meetingWorkIDs, parts, { usageMode: 'cached' })
|
|
25
|
+
}
|
|
26
|
+
const defaults: AskWorkPlanDeps = {
|
|
27
|
+
// A new reader guarantees the post-plan revision check starts a new bounded board read.
|
|
28
|
+
board: () => createWorkBoardReader(() => listBoard()).read(0), input: readAskWorkPlanInput, run: runMeetingAskModel,
|
|
29
|
+
admissions: maintenanceAdmissionsOpen, lease: () => acquireMaintenanceWork('work_chat'), now: Date.now,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createAskWorkPlanRouter(given: Partial<AskWorkPlanDeps> = {}) {
|
|
33
|
+
const deps = { ...defaults, ...given }, router = Router()
|
|
34
|
+
let running = false
|
|
35
|
+
router.use('/ask/work-plan', (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
|
|
36
|
+
router.get('/ask/work-plan', (_req, res) => res.json({ protocolVersion: ASK_WORK_PLAN_PROTOCOL, planOnly: true,
|
|
37
|
+
requiresExistingWork: true, dispatch: false, controlSnapshot: 1, planner: 'sonnet', planners: WORK_CHAT_PLANNERS,
|
|
38
|
+
effort: WORK_CHAT_DEFAULT_EFFORT, efforts: WORK_CHAT_EFFORTS, maxAssignments: WORK_CHAT_MAX_ASSIGNMENTS,
|
|
39
|
+
maxQuestionChars: 2000, maxBodyBytes: ASK_WORK_PLAN_BODY_BYTES }))
|
|
40
|
+
router.post('/ask/work-plan', async (req, res) => {
|
|
41
|
+
const fail = (error: unknown) => {
|
|
42
|
+
const known = error instanceof AskWorkPlanError
|
|
43
|
+
if (!res.destroyed) res.status(known ? error.status : 503).json({ error: known ? error.code : 'ask_work_plan_unavailable',
|
|
44
|
+
message: known && error.code === 'question_contains_sensitive_text'
|
|
45
|
+
? 'Remove credentials from the question and try again.'
|
|
46
|
+
: 'No assignment was dispatched. Refresh the task and retry the plan.' })
|
|
47
|
+
}
|
|
48
|
+
let request
|
|
49
|
+
try { request = parseAskWorkPlanRequest(req.body) } catch (error) { fail(error); return }
|
|
50
|
+
if (!deps.admissions()) { fail(new AskWorkPlanError('maintenance', 503)); return }
|
|
51
|
+
if (running) { fail(new AskWorkPlanError('ask_work_plan_busy', 409)); return }
|
|
52
|
+
running = true
|
|
53
|
+
const controller = new AbortController(), abort = () => controller.abort()
|
|
54
|
+
const timer = setTimeout(abort, ASK_WORK_PLAN_DEADLINE_MS)
|
|
55
|
+
res.on('close', abort)
|
|
56
|
+
let lease: ReturnType<AskWorkPlanDeps['lease']> | undefined
|
|
57
|
+
let release = true
|
|
58
|
+
try {
|
|
59
|
+
lease = deps.lease()
|
|
60
|
+
let rows: TaskBoardRow[]
|
|
61
|
+
try { rows = await deps.board() }
|
|
62
|
+
catch { throw new AskWorkPlanError('work_board_unavailable', 503) }
|
|
63
|
+
if (controller.signal.aborted) throw new AskWorkPlanError('ask_work_plan_canceled', 408)
|
|
64
|
+
const task = resolveAskWorkTask(request, rows)
|
|
65
|
+
let evidence
|
|
66
|
+
// The default reads cached usage only. Still await the bounded evidence dependency before releasing admission.
|
|
67
|
+
try { evidence = await deps.input(request.workID, askWorkMeetingIDs(task)) }
|
|
68
|
+
catch { throw new AskWorkPlanError('orchestrator_input_unavailable', 503) }
|
|
69
|
+
if (controller.signal.aborted) throw new AskWorkPlanError('ask_work_plan_canceled', 408)
|
|
70
|
+
const prepared = prepareAskWorkPlan(request, task, evidence, deps.now())
|
|
71
|
+
if (controller.signal.aborted) throw new AskWorkPlanError('ask_work_plan_canceled', 408)
|
|
72
|
+
if (!deps.admissions()) throw new AskWorkPlanError('maintenance', 503)
|
|
73
|
+
const { planner = 'sonnet', effort = WORK_CHAT_DEFAULT_EFFORT, ...prompt } = prepared.input
|
|
74
|
+
// The runner settles only after termination proof (or explicit unconfirmed exit). Racing its
|
|
75
|
+
// promise with abort would release the lease and admit another provider during the kill wait.
|
|
76
|
+
const output = await deps.run({ engine: 'claude', model: planner }, JSON.stringify(prompt),
|
|
77
|
+
WORK_CHAT_SYSTEM + '\n\n' + WORK_CHAT_ORCHESTRATOR_RULES + '\n\n' + ASK_WORK_PLAN_RULES,
|
|
78
|
+
controller.signal, undefined, { caller: 'ask_work_plan' }, { effort, maxOutputTokens: WORK_CHAT_ORCHESTRATOR_OUTPUT_TOKENS,
|
|
79
|
+
maxThinkingTokens: WORK_CHAT_THINKING_TOKENS[effort], deadlineMs: WORK_CHAT_DEADLINE_MS })
|
|
80
|
+
if (controller.signal.aborted) throw new AskWorkPlanError('ask_work_plan_canceled', 408)
|
|
81
|
+
let answer
|
|
82
|
+
try { answer = parseWorkChatAnswer(output.text, prepared.input) }
|
|
83
|
+
catch { throw new AskWorkPlanError('invalid_plan_answer', 503) }
|
|
84
|
+
// A model may take minutes; never return an apparently current proposal for a changed/finished task.
|
|
85
|
+
try { rows = await deps.board() }
|
|
86
|
+
catch { throw new AskWorkPlanError('work_board_unavailable', 503) }
|
|
87
|
+
if (controller.signal.aborted) throw new AskWorkPlanError('ask_work_plan_canceled', 408)
|
|
88
|
+
const currentTask = resolveAskWorkTask(request, rows)
|
|
89
|
+
try { evidence = await deps.input(request.workID, askWorkMeetingIDs(currentTask)) }
|
|
90
|
+
catch { throw new AskWorkPlanError('orchestrator_input_unavailable', 503) }
|
|
91
|
+
if (controller.signal.aborted) throw new AskWorkPlanError('ask_work_plan_canceled', 408)
|
|
92
|
+
const current = prepareAskWorkPlan(request, currentTask, evidence, deps.now())
|
|
93
|
+
// Revalidate every proposed target against current discovery, live state and plan headroom.
|
|
94
|
+
try { answer = parseWorkChatAnswer(output.text, current.input) }
|
|
95
|
+
catch { throw new AskWorkPlanError('plan_targets_changed', 409) }
|
|
96
|
+
if (!res.destroyed) res.json({ protocolVersion: ASK_WORK_PLAN_PROTOCOL, planOnly: true, workID: request.workID,
|
|
97
|
+
revision: request.revision, title: prepared.title, ...answer, evidence: prepared.evidence, revalidatedEvidence: current.evidence,
|
|
98
|
+
proposalID: askWorkProposalID(prepared.input, answer), question: prepared.input.question, createdAt: new Date(deps.now()).toISOString(),
|
|
99
|
+
model: output.model ?? `claude:${planner} (requested)`, effort })
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error instanceof AskProcessError && error.terminationUnconfirmed) {
|
|
102
|
+
// Match Meeting Ask's orphan policy: no second process or maintenance drain until recovery.
|
|
103
|
+
release = false
|
|
104
|
+
console.warn('[ask-work-plan] provider termination unconfirmed; admission and maintenance lease retained')
|
|
105
|
+
fail(new AskWorkPlanError('provider_termination_unconfirmed', 503))
|
|
106
|
+
} else fail(error)
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
clearTimeout(timer); res.off('close', abort)
|
|
110
|
+
if (release) { lease?.release(); running = false }
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
return router
|
|
114
|
+
}
|
package/server/routes/health.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { dropItCapability } from '../lib/drop-settings.js'
|
|
2
|
+
import { PREP_CAPABILITY } from '../../shared/prep.js'
|
|
2
3
|
import { phoneMicrophoneMeetingsCapability } from '../lib/phone-recording.js'
|
|
3
4
|
import { macLiveVoicesSnapshot } from '../lib/mac-window-replay.js'
|
|
4
5
|
import { DICTATION_CLEAN_MODELS } from '../lib/dictation-clean-models.js'
|
|
@@ -465,6 +466,9 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
465
466
|
providerObserveFields({ state: cachedHookStatus().state, ready: hookHaltReady(cachedHookStatus()) }),
|
|
466
467
|
),
|
|
467
468
|
server_instance_id: getServerInstanceId(),
|
|
469
|
+
// 6.99.20: the same id in camelCase, the name COS Control 0.5.376's Prep discovery reads from /api/health
|
|
470
|
+
// (it refused Prep with "did not answer Prep discovery" when only server_instance_id was here). Additive.
|
|
471
|
+
serverInstanceId: getServerInstanceId(),
|
|
468
472
|
boot_id: serverMetrics.bootId,
|
|
469
473
|
generation_id: getServerGenerationId(),
|
|
470
474
|
mediaLimits,
|
|
@@ -538,6 +542,8 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
538
542
|
titleSteer: TITLE_STEER_CAPABILITY,
|
|
539
543
|
// Drop it P1: the pinned COS chat (routes/drops.ts). Codes only; `enabled` is false unless turned on. Absent on an older server.
|
|
540
544
|
dropIt: dropItCapability(),
|
|
545
|
+
// 6.99.19 Prep: notes and files before a call, kept in the meeting by an explicit bind (routes/prep.ts). Absent on an older server.
|
|
546
|
+
prep: { ...PREP_CAPABILITY, serverInstanceId: getServerInstanceId() },
|
|
541
547
|
// 6.77.0: codes only (this route is public): the protocol and whether the switch is on.
|
|
542
548
|
transcriptCleanup: transcriptCleanupAdvertised(),
|
|
543
549
|
richMedia: {
|
|
@@ -752,6 +758,8 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
752
758
|
titleSteer: TITLE_STEER_CAPABILITY,
|
|
753
759
|
// Drop it P1: the pinned COS chat (routes/drops.ts). Codes only; `enabled` is false unless turned on. Absent on an older server.
|
|
754
760
|
dropIt: dropItCapability(),
|
|
761
|
+
// 6.99.19 Prep: notes and files before a call, kept in the meeting by an explicit bind (routes/prep.ts). Absent on an older server.
|
|
762
|
+
prep: { ...PREP_CAPABILITY, serverInstanceId: getServerInstanceId() },
|
|
755
763
|
richMedia: {
|
|
756
764
|
video: richMedia.video,
|
|
757
765
|
maxPromptVisuals: MAX_MODEL_IMAGE_INPUTS,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prep v1.1 routes (6.99.19). Mounted under /api, so the X-Cos-Token check in index.ts runs first. Every answer is
|
|
3
|
+
* Cache-Control: no-store and carries the top-level `serverInstanceId` (errors included; the original-bytes route
|
|
4
|
+
* sends it as the X-COS-Server-Instance header). Errors are `{ error: '<code>' }` like Meeting Ask; a revision conflict
|
|
5
|
+
* also carries `draft`, run_in_progress carries `run`, and file_context_limit carries needBytes and haveBytes.
|
|
6
|
+
* Every mutation requires X-COS-Expected-Server-Instance (v1.1 #19), checked before the runtime is called. The
|
|
7
|
+
* behaviour lives in lib/prep-runtime.ts; shared/prep.ts lists the routes and DTOs.
|
|
8
|
+
*/
|
|
9
|
+
import { Router, type Request, type Response } from 'express'
|
|
10
|
+
import { PrepError } from '../lib/prep-store.js'
|
|
11
|
+
import { wellFormed, type PrepRuntime } from '../lib/prep-runtime.js'
|
|
12
|
+
|
|
13
|
+
export const EXPECTED_SERVER_HEADER = 'x-cos-expected-server-instance'
|
|
14
|
+
|
|
15
|
+
/** An ASCII fallback name plus the RFC 5987 UTF-8 name: no quotes, slashes or control characters reach the header. */
|
|
16
|
+
export function attachmentDisposition(name: string): string {
|
|
17
|
+
// By code point, without lone surrogates: encodeURIComponent throws URIError on half an emoji.
|
|
18
|
+
const whole = Array.from(wellFormed(name)).slice(0, 240).join('')
|
|
19
|
+
const ascii = whole.replace(/[^A-Za-z0-9._ -]/g, '_').replace(/^[ .]+/, '').slice(0, 120) || 'file'
|
|
20
|
+
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(whole)}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createPrepRouter(runtime: PrepRuntime): Router {
|
|
24
|
+
const router = Router()
|
|
25
|
+
router.use('/prep', (_q, res, next) => { res.set('Cache-Control', 'no-store'); next() })
|
|
26
|
+
|
|
27
|
+
const serverInstanceId = () => { try { return runtime.serverInstanceId() } catch { return null } }
|
|
28
|
+
const send = (res: Response, status: number, body: Record<string, unknown>) => res.status(status).json({ ...body, serverInstanceId: serverInstanceId() })
|
|
29
|
+
const failure = (res: Response, error: unknown) => {
|
|
30
|
+
if (error instanceof PrepError) {
|
|
31
|
+
send(res, error.status, { error: error.code, ...error.extra, ...(error.draft ? { draft: error.draft } : {}) })
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
console.warn('[prep] request failed:', error instanceof Error ? error.message : 'unknown')
|
|
35
|
+
send(res, 503, { error: 'prep_unavailable' })
|
|
36
|
+
}
|
|
37
|
+
/** A mutation: the expected server is checked before the runtime runs anything. */
|
|
38
|
+
const write = (status: number, fn: (req: Request) => Promise<Record<string, unknown>>) => async (req: Request, res: Response) => {
|
|
39
|
+
try {
|
|
40
|
+
runtime.authorize(req.get(EXPECTED_SERVER_HEADER))
|
|
41
|
+
send(res, status, await fn(req))
|
|
42
|
+
} catch (e) { failure(res, e) }
|
|
43
|
+
}
|
|
44
|
+
const read = (fn: (req: Request) => Promise<Record<string, unknown>> | Record<string, unknown>) => async (req: Request, res: Response) => {
|
|
45
|
+
try { send(res, 200, await fn(req)) } catch (e) { failure(res, e) }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
router.post('/prep/drafts', async (req, res) => {
|
|
49
|
+
try {
|
|
50
|
+
runtime.authorize(req.get(EXPECTED_SERVER_HEADER))
|
|
51
|
+
const { draft, created } = await runtime.create(req.body)
|
|
52
|
+
send(res, created ? 201 : 200, { draft })
|
|
53
|
+
} catch (e) { failure(res, e) }
|
|
54
|
+
})
|
|
55
|
+
router.get('/prep/drafts', read(req => ({ drafts: runtime.list({ occurrence: req.query.occurrence, state: req.query.state, limit: req.query.limit }) })))
|
|
56
|
+
router.get('/prep/drafts/:id', read(async req => ({ draft: await runtime.get(String(req.params.id)) })))
|
|
57
|
+
router.delete('/prep/drafts/:id', write(200, async req => ({ draft: await runtime.archive(String(req.params.id)) })))
|
|
58
|
+
router.patch('/prep/drafts/:id', write(200, async req => ({ draft: await runtime.patch(String(req.params.id), req.body) })))
|
|
59
|
+
router.post('/prep/drafts/:id/files', write(200, async req => ({ draft: await runtime.addFiles(String(req.params.id), req.body) })))
|
|
60
|
+
router.delete('/prep/drafts/:id/files/:fileId', write(200, async req => ({ draft: await runtime.removeFile(String(req.params.id), String(req.params.fileId), req.query.revision, req.query.serverInstanceId) })))
|
|
61
|
+
router.get('/prep/drafts/:id/files/:fileId', (req, res) => {
|
|
62
|
+
try {
|
|
63
|
+
const file = runtime.readOriginal(String(req.params.id), String(req.params.fileId))
|
|
64
|
+
res.set({ 'Content-Type': file.mime, 'Content-Disposition': attachmentDisposition(file.name), 'X-Content-Type-Options': 'nosniff', 'X-COS-Server-Instance': serverInstanceId() ?? '' })
|
|
65
|
+
res.send(file.bytes)
|
|
66
|
+
} catch (e) { failure(res, e) }
|
|
67
|
+
})
|
|
68
|
+
router.get('/prep/drafts/:id/files/:fileId/text', read(req => ({ text: runtime.readText(String(req.params.id), String(req.params.fileId)) })))
|
|
69
|
+
router.post('/prep/drafts/:id/run', write(202, async req => ({ ...await runtime.run(String(req.params.id), req.body) })))
|
|
70
|
+
router.get('/prep/drafts/:id/runs/:clientRequestId', async (req, res) => {
|
|
71
|
+
try {
|
|
72
|
+
const { status, run, job } = await runtime.getRun(String(req.params.id), String(req.params.clientRequestId))
|
|
73
|
+
send(res, status, status === 202 ? { run } : { run, job })
|
|
74
|
+
} catch (e) { failure(res, e) }
|
|
75
|
+
})
|
|
76
|
+
router.post('/prep/drafts/:id/bind', write(200, async req => ({ ...await runtime.bind(String(req.params.id), req.body) })))
|
|
77
|
+
router.delete('/prep/drafts/:id/bind/:sessionId', write(200, async req => ({ draft: await runtime.unbind(String(req.params.id), String(req.params.sessionId), req.query.serverInstanceId) })))
|
|
78
|
+
router.get('/prep/by-session/:sessionId', read(req => ({ snapshots: runtime.bySession(String(req.params.sessionId)) })))
|
|
79
|
+
router.get('/prep/suggest', read(req => ({ drafts: runtime.suggest({ sessionId: req.query.sessionId, startedAt: req.query.startedAt }) })))
|
|
80
|
+
return router
|
|
81
|
+
}
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
import { Router } from 'express'
|
|
6
6
|
import { callPython, pythonBridgeAvailable } from '../lib/python-bridge.js'
|
|
7
|
+
import { parsePrepOccurrence } from '../lib/prep-store.js'
|
|
8
|
+
import type { PrepOccurrence } from '../../shared/prep.js'
|
|
7
9
|
|
|
8
10
|
export const welcomeContextRouter = Router()
|
|
9
11
|
|
|
@@ -14,7 +16,29 @@ const CALENDAR_TTL_MS = 2 * 60_000
|
|
|
14
16
|
const COORD_MOVE_DEG = 0.05 // ~5km
|
|
15
17
|
|
|
16
18
|
type WeatherPayload = { temp: string; desc: string; location: string }
|
|
17
|
-
|
|
19
|
+
/**
|
|
20
|
+
* 6.99.19: `occurrence` (shared/prep.ts PrepOccurrence) is present ONLY when the bridge answers the event with a
|
|
21
|
+
* provider, calendar id, event id or external id, and start. The bridge sends no event ids today, so it is absent.
|
|
22
|
+
* It lets a client find a Prep draft for this exact event; it never binds one, and nothing is inferred from the title.
|
|
23
|
+
*/
|
|
24
|
+
type NextEventPayload = { title: string; time: string; inMinutes?: number; occurrence?: PrepOccurrence }
|
|
25
|
+
|
|
26
|
+
/** The bridge event's provider identity, or undefined. snake_case (calendar_context.py) or camelCase is accepted. */
|
|
27
|
+
export function bridgeOccurrence(evt: Record<string, unknown> | null | undefined): PrepOccurrence | undefined {
|
|
28
|
+
if (!evt || typeof evt !== 'object') return undefined
|
|
29
|
+
const pick = (...keys: string[]) => keys.map(k => evt[k]).find(v => v !== undefined && v !== null)
|
|
30
|
+
try {
|
|
31
|
+
return parsePrepOccurrence({
|
|
32
|
+
provider: pick('provider'),
|
|
33
|
+
account: pick('account', 'account_id', 'accountId') ?? null,
|
|
34
|
+
calendarId: pick('calendar_id', 'calendarId'),
|
|
35
|
+
eventId: pick('event_id', 'eventId') ?? null,
|
|
36
|
+
externalId: pick('external_id', 'externalId') ?? null,
|
|
37
|
+
startsAt: pick('start_time', 'start', 'startsAt'),
|
|
38
|
+
endsAt: pick('end_time', 'end', 'endsAt') ?? null,
|
|
39
|
+
}) ?? undefined
|
|
40
|
+
} catch { return undefined }
|
|
41
|
+
}
|
|
18
42
|
|
|
19
43
|
let cachedWeather: WeatherPayload | null = null
|
|
20
44
|
let weatherFetchedAt = 0
|
|
@@ -327,16 +351,20 @@ export async function fetchNextEvent(): Promise<{ event: NextEventPayload | null
|
|
|
327
351
|
const minsUntil = Number.isFinite(eventTime)
|
|
328
352
|
? Math.max(0, Math.round((eventTime - now) / 60_000))
|
|
329
353
|
: undefined
|
|
354
|
+
const occurrence = bridgeOccurrence(meaningful as Record<string, unknown>)
|
|
330
355
|
cachedNextEvent = {
|
|
331
356
|
title: meaningful.title || 'Meeting',
|
|
332
357
|
time: meaningful.start_time || meaningful.start || '',
|
|
333
358
|
...(minsUntil != null ? { inMinutes: minsUntil } : {}),
|
|
359
|
+
...(occurrence ? { occurrence } : {}),
|
|
334
360
|
}
|
|
335
361
|
} else if (cal?.next_event && !isGenericNoiseEvent(cal.next_event.title || '')) {
|
|
362
|
+
const occurrence = bridgeOccurrence(cal.next_event as Record<string, unknown>)
|
|
336
363
|
cachedNextEvent = {
|
|
337
364
|
title: cal.next_event.title || 'Meeting',
|
|
338
365
|
time: cal.next_event.start_time || '',
|
|
339
366
|
...(cal.minutes_until_next != null ? { inMinutes: cal.minutes_until_next } : {}),
|
|
367
|
+
...(occurrence ? { occurrence } : {}),
|
|
340
368
|
}
|
|
341
369
|
} else {
|
|
342
370
|
cachedNextEvent = null
|