@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/package.json +1 -1
  3. package/server/index.ts +44 -2
  4. package/server/lib/ask-work-plan.ts +116 -0
  5. package/server/lib/batch-transcript-quality.ts +2 -0
  6. package/server/lib/cos-thread.ts +582 -0
  7. package/server/lib/drop-attachments.ts +55 -0
  8. package/server/lib/drop-service.ts +104 -13
  9. package/server/lib/drop-settings.ts +13 -2
  10. package/server/lib/drop-store.ts +38 -2
  11. package/server/lib/media-store.ts +80 -0
  12. package/server/lib/meeting-ask-runtime.ts +1 -1
  13. package/server/lib/meeting-batch-transcribe.ts +54 -2
  14. package/server/lib/permission-broker.ts +6 -4
  15. package/server/lib/phone-recording.ts +1 -1
  16. package/server/lib/ping-sources.ts +6 -2
  17. package/server/lib/prep-runtime.ts +915 -0
  18. package/server/lib/prep-store.ts +226 -0
  19. package/server/lib/stt-resolver.ts +26 -2
  20. package/server/lib/transcript-cleanup.ts +11 -2
  21. package/server/lib/work-orchestrator/runtime.ts +8 -2
  22. package/server/routes/ask-work-plan.ts +114 -0
  23. package/server/routes/cos-thread.ts +79 -0
  24. package/server/routes/drops.ts +4 -2
  25. package/server/routes/health.ts +8 -0
  26. package/server/routes/permission-broker.ts +2 -2
  27. package/server/routes/prep.ts +81 -0
  28. package/server/routes/transcribe-stream.ts +9 -1
  29. package/server/routes/welcome-context.ts +29 -1
  30. package/server/routes/work-orchestrator.ts +57 -0
  31. package/server/scripts/web-live-cls/cdp.mjs +57 -0
  32. package/server/scripts/web-live-cls/measure.mjs +80 -0
  33. package/server/scripts/web-live-cls/mock.mjs +97 -0
  34. package/server/scripts/web-live-cls/render.mjs +26 -0
  35. package/server/web-control/control.css +13 -0
  36. package/server/web-control/panes/cos-chat.css +39 -0
  37. package/server/web-control/panes/cos-chat.js +799 -41
  38. package/server/web-control/panes/meetings.css +91 -4
  39. package/server/web-control/panes/meetings.js +585 -58
  40. package/server/web-control/panes/messages.js +25 -18
  41. package/shared/meeting-ask.ts +6 -2
  42. 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') { out.push(...q); wordsFromSecond += q.length }
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
- // Pasted text and read pages (paste_, web_) are article words, not the room's vocabulary: they must not rewrite the saved transcript.
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): Promise<OrchestratorInput> {
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
- const usageViews = await monitor.forPlan()
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
+ }
@@ -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
 
@@ -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.12: a browser counts only as `web`, and never with presence (no away hold from a tab).
201
- else if (byWebSession && client === WEB_ANSWERING_CLIENT) notePoll(now(), {})
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()