@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.99.16: the COS thread (lib/cos-thread.ts). Auth comes from the parent /api mount. Every answer is
|
|
3
|
+
* `Cache-Control: private, no-store`. Gated exactly like the Drop it routes (routes/drops.ts): while Drop it is off,
|
|
4
|
+
* every route answers 404 `{ error: { code: 'drop_it_off' } }` and no file is created (the store opens on the first
|
|
5
|
+
* request after the feature is turned on). Advertised as `capabilities.dropIt.thread: { protocol: 1 }` while on.
|
|
6
|
+
*
|
|
7
|
+
* GET /cos-thread CosThreadAnswer: the thread and what the next Ask should do
|
|
8
|
+
* POST /cos-thread/new {} CosThreadAnswer (next.reason 'manual'): closes this thread, starts another
|
|
9
|
+
* POST /cos-thread/turn { clientId, sessionId, query, answer?, messageNo?, provider? } CosThreadAnswer
|
|
10
|
+
* GET /cos-thread/search ?q&limit { hits: [{ threadId, sessionId, at, query, answerHead, messageNo }], scanned }
|
|
11
|
+
* GET /cos-thread/threads ?limit { threads: [{ id, startedAt, endedAt, asks, segments, title }] }
|
|
12
|
+
*/
|
|
13
|
+
import { Router, type Response } from 'express'
|
|
14
|
+
import { CosThread, CosThreadError, parseTurnInput } from '../lib/cos-thread.js'
|
|
15
|
+
|
|
16
|
+
export interface CosThreadRouterDependencies {
|
|
17
|
+
enabled: () => boolean
|
|
18
|
+
/** The store, opened on first use. Null when it cannot open. */
|
|
19
|
+
thread: () => CosThread | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const fail = (res: Response, error: unknown) => {
|
|
23
|
+
if (error instanceof CosThreadError) return res.status(error.status).json({ error: { code: error.code } })
|
|
24
|
+
console.error('[cos-thread] request failed:', error instanceof Error ? error.name : 'error')
|
|
25
|
+
return res.status(500).json({ error: { code: 'cos_thread_failed' } })
|
|
26
|
+
}
|
|
27
|
+
/** A whole number in [min, max], the default when absent, or null when present and out of range. */
|
|
28
|
+
function intParam(raw: unknown, min: number, max: number, fallback: number): number | null {
|
|
29
|
+
if (raw === undefined) return fallback
|
|
30
|
+
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) return null
|
|
31
|
+
const n = Number(raw)
|
|
32
|
+
return n >= min && n <= max ? n : null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createCosThreadRouter(deps: CosThreadRouterDependencies): Router {
|
|
36
|
+
const router = Router()
|
|
37
|
+
router.use('/cos-thread', (_req, res, next) => {
|
|
38
|
+
res.set('Cache-Control', 'private, no-store')
|
|
39
|
+
if (!deps.enabled()) return res.status(404).json({ error: { code: 'drop_it_off', message: 'Drop it is off on this server.' } })
|
|
40
|
+
next()
|
|
41
|
+
})
|
|
42
|
+
const withThread = (res: Response, fn: (t: CosThread) => unknown) => {
|
|
43
|
+
const thread = deps.thread()
|
|
44
|
+
if (!thread) return res.status(503).json({ error: { code: 'cos_thread_unavailable' } })
|
|
45
|
+
try { return fn(thread) } catch (e) { return fail(res, e) }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
router.get('/cos-thread', (_req, res) => withThread(res, t => res.json(t.view())))
|
|
49
|
+
|
|
50
|
+
router.post('/cos-thread/new', (req, res) => {
|
|
51
|
+
const b = req.body
|
|
52
|
+
if (b !== undefined && (b === null || typeof b !== 'object' || Array.isArray(b) || Object.keys(b).length)) {
|
|
53
|
+
return res.status(400).json({ error: { code: 'invalid_request', message: 'Send {}.' } })
|
|
54
|
+
}
|
|
55
|
+
return withThread(res, t => res.json(t.startNew()))
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
router.post('/cos-thread/turn', (req, res) => {
|
|
59
|
+
const input = parseTurnInput(req.body)
|
|
60
|
+
if (!input) return res.status(400).json({ error: { code: 'invalid_turn' } })
|
|
61
|
+
return withThread(res, t => res.json(t.recordTurn(input)))
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
router.get('/cos-thread/search', (req, res) => {
|
|
65
|
+
const q = typeof req.query.q === 'string' ? req.query.q.trim() : ''
|
|
66
|
+
if (q.length < 2 || q.length > 200) return res.status(400).json({ error: { code: 'invalid_query', message: 'Search with 2 to 200 characters.' } })
|
|
67
|
+
const limit = intParam(req.query.limit, 1, 50, 20)
|
|
68
|
+
if (limit === null) return res.status(400).json({ error: { code: 'invalid_limit' } })
|
|
69
|
+
return withThread(res, t => res.json(t.search(q, limit)))
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
router.get('/cos-thread/threads', (req, res) => {
|
|
73
|
+
const limit = intParam(req.query.limit, 1, 100, 20)
|
|
74
|
+
if (limit === null) return res.status(400).json({ error: { code: 'invalid_limit' } })
|
|
75
|
+
return withThread(res, t => res.json(t.list(limit)))
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
return router
|
|
79
|
+
}
|
package/server/routes/drops.ts
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
*
|
|
10
10
|
* GET /drops/settings { protocol, enabled, source, router }
|
|
11
11
|
* PUT /drops/settings { enabled } same answer
|
|
12
|
-
* POST /drops { dropId, text, source, hint?: { sessionId, label? }, file? } DropAnswer
|
|
12
|
+
* POST /drops { dropId, text, source, hint?: { sessionId, label? }, file?, attachmentIds? } DropAnswer
|
|
13
|
+
* 6.99.13: attachmentIds, up to 5 fresh uploads (POST /api/media, /api/media/file), kept with the
|
|
14
|
+
* drop (lib/drop-attachments.ts). Words are still required. Advertised as dropIt.attachments.
|
|
13
15
|
* GET /drops ?q&kind&status&limit&before History
|
|
14
16
|
* GET /drops/:id DropAnswer
|
|
15
17
|
* POST /drops/:id/undo DropAnswer
|
|
@@ -144,7 +146,7 @@ export function createDropsRouter(deps: DropsRouterDependencies): Router {
|
|
|
144
146
|
|
|
145
147
|
router.post('/drops', (req, res) => withService(res, async service => {
|
|
146
148
|
const b = req.body
|
|
147
|
-
if (!isRecord(b) || Object.keys(b).some(k => !['dropId', 'text', 'source', 'hint', 'file'].includes(k))) return bad(res, 'invalid_drop')
|
|
149
|
+
if (!isRecord(b) || Object.keys(b).some(k => !['dropId', 'text', 'source', 'hint', 'file', 'attachmentIds'].includes(k))) return bad(res, 'invalid_drop')
|
|
148
150
|
try { return res.json(await service.submit(b as unknown as DropInput)) } catch (e) { return send(res, e) }
|
|
149
151
|
}))
|
|
150
152
|
|
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,
|
|
@@ -197,8 +197,8 @@ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps):
|
|
|
197
197
|
// Only a client that says it can answer (and shows the cards) counts as live.
|
|
198
198
|
const client = typeof req.query.client === 'string' ? req.query.client : ''
|
|
199
199
|
if (byToken && ANSWERING_CLIENTS.has(client)) notePoll(now(), questionsPollFacts(req.query as Record<string, unknown>))
|
|
200
|
-
// 6.99.
|
|
201
|
-
|
|
200
|
+
// 6.99.13 (Miles, option B): a browser poll never counts as live, so an open web tab never holds a question
|
|
201
|
+
// back from the Mac's own pop-up. The web still lists and answers what the glasses or phone are holding.
|
|
202
202
|
const health = deps.broker.health()
|
|
203
203
|
// Listed first: a deadline the timer has not caught yet is settled by the listing.
|
|
204
204
|
const items = deps.broker.list()
|