@gotcos/glasses-server 6.99.16 → 6.99.25

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 (52) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/docs/work-orchestrator.md +20 -0
  3. package/package.json +1 -1
  4. package/server/index.ts +23 -1
  5. package/server/lib/ask-work-plan.ts +116 -0
  6. package/server/lib/batch-transcript-quality.ts +2 -0
  7. package/server/lib/drop-store.ts +16 -2
  8. package/server/lib/meeting-ask-runtime.ts +1 -1
  9. package/server/lib/meeting-batch-transcribe.ts +40 -1
  10. package/server/lib/ping-sources.ts +6 -2
  11. package/server/lib/prep-runtime.ts +915 -0
  12. package/server/lib/prep-store.ts +226 -0
  13. package/server/lib/stt-resolver.ts +26 -2
  14. package/server/lib/transcript-cleanup.ts +11 -2
  15. package/server/lib/web-control-static.ts +226 -0
  16. package/server/lib/web-memories-bridge.ts +9 -8
  17. package/server/lib/web-pet-choice.ts +225 -0
  18. package/server/lib/web-pet.ts +226 -0
  19. package/server/lib/work-lens-rounds.ts +29 -4
  20. package/server/lib/work-orchestrator/runtime.ts +8 -2
  21. package/server/routes/ask-work-plan.ts +114 -0
  22. package/server/routes/health.ts +8 -0
  23. package/server/routes/prep.ts +81 -0
  24. package/server/routes/web-control.ts +110 -7
  25. package/server/routes/welcome-context.ts +29 -1
  26. package/server/routes/work-lens-rounds.ts +19 -5
  27. package/server/routes/work-orchestrator.ts +57 -0
  28. package/server/web-control/app.css +24 -5
  29. package/server/web-control/app.js +646 -108
  30. package/server/web-control/control.css +1 -1
  31. package/server/web-control/index.html +27 -13
  32. package/server/web-control/memories/memory-workspace.js +1 -1
  33. package/server/web-control/panes/cos-chat.css +26 -0
  34. package/server/web-control/panes/cos-chat.js +641 -6
  35. package/server/web-control/panes/meetings.css +45 -2
  36. package/server/web-control/panes/meetings.js +230 -7
  37. package/server/web-control/panes/memories.css +291 -0
  38. package/server/web-control/panes/memories.js +2208 -0
  39. package/server/web-control/panes/questions.js +59 -21
  40. package/server/web-control/panes/settings.js +102 -4
  41. package/server/web-control/panes/today-actions.css +109 -0
  42. package/server/web-control/panes/today-actions.js +972 -0
  43. package/server/web-control/panes/work.css +54 -0
  44. package/server/web-control/panes/work.js +1037 -18
  45. package/server/web-control/shell.js +6 -2
  46. package/server/web-control/theme.js +0 -2
  47. package/shared/meeting-ask.ts +6 -2
  48. package/shared/prep.ts +317 -0
  49. package/server/web-control/memories/memories-app.js +0 -1561
  50. package/server/web-control/memories/memories-theme.css +0 -73
  51. package/server/web-control/memories/memories.html +0 -72
  52. package/server/web-control/memories/memory-stewardship.js +0 -63
@@ -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
 
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Fast static serving for COS Control Web (`/control/*`).
3
+ *
4
+ * Before this, `/control` went straight to `express.static`: no compression and
5
+ * `Cache-Control: public, max-age=0`, so every open on the phone re-asked the Mac about
6
+ * all 22 boot files over Tailscale and downloaded about 1.2 MB of uncompressed JS and CSS
7
+ * plus 770 KB of fonts whenever anything changed.
8
+ *
9
+ * What this does:
10
+ * - Every asset gets a content hash. `index.html` and the CSS files have their
11
+ * `/control/...` references rewritten to `/control/...?v=<hash>`.
12
+ * - A request whose `?v=` matches the current hash is cached for a year as `immutable`.
13
+ * Anything else (index.html, a stale or missing `?v=`) is `no-cache`, so it revalidates
14
+ * with its ETag and a new release is picked up on the next open.
15
+ * - Text assets are served brotli or gzip compressed when the browser accepts it. The
16
+ * compressed bytes are built on first request and kept in memory.
17
+ * - Files on disk are re-checked at most once per `recheckMs`, so a test copy served from
18
+ * a worktree picks up edits without a restart.
19
+ *
20
+ * Anything not in the table (a path this walker skips) falls through to `next()`, and the
21
+ * caller keeps `express.static` behind it, so no URL that worked before stops working.
22
+ */
23
+ import crypto from 'crypto'
24
+ import fs from 'fs'
25
+ import path from 'path'
26
+ import zlib from 'zlib'
27
+ import type { NextFunction, Request, RequestHandler, Response } from 'express'
28
+
29
+ /** A versioned URL's lifetime. Safe because the hash changes whenever the bytes do. */
30
+ export const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable'
31
+ /** Unversioned and HTML responses: always revalidate, so a new release shows on the next open. */
32
+ export const REVALIDATE_CACHE = 'no-cache'
33
+
34
+ const TYPES: Record<string, string> = {
35
+ '.html': 'text/html; charset=utf-8',
36
+ '.js': 'text/javascript; charset=utf-8',
37
+ '.css': 'text/css; charset=utf-8',
38
+ '.svg': 'image/svg+xml',
39
+ '.json': 'application/json; charset=utf-8',
40
+ '.ttf': 'font/ttf',
41
+ '.woff2': 'font/woff2',
42
+ '.png': 'image/png',
43
+ '.webp': 'image/webp',
44
+ '.jpg': 'image/jpeg',
45
+ }
46
+ /** Worth compressing. WOFF2 and images are already compressed. */
47
+ const COMPRESSIBLE = new Set(['.html', '.js', '.css', '.svg', '.json', '.ttf'])
48
+ /** Files whose `/control/...` references are rewritten to versioned URLs. */
49
+ const REWRITTEN = new Set(['.html', '.css'])
50
+ /** Only assets of these types get a versioned URL; HTML pages are always revalidated. */
51
+ const VERSIONABLE = new Set(['.js', '.css', '.svg', '.json', '.ttf', '.woff2', '.png', '.webp', '.jpg'])
52
+
53
+ export interface WebAsset {
54
+ /** Path under the mount, with a leading slash: `/panes/work.js`. */
55
+ urlPath: string
56
+ ext: string
57
+ type: string
58
+ body: Buffer
59
+ /** First 16 hex characters of the SHA-256 of `body` (after any rewrite). */
60
+ hash: string
61
+ gzip?: Promise<Buffer>
62
+ br?: Promise<Buffer>
63
+ }
64
+
65
+ export type WebAssetTable = Map<string, WebAsset>
66
+
67
+ /** Dotfiles are never served. Sources (`.ts`) and maps stay out because TYPES does not list them. */
68
+ function skip(name: string): boolean {
69
+ return name.startsWith('.')
70
+ }
71
+
72
+ function walk(dir: string, base = ''): Array<{ urlPath: string; file: string; stat: fs.Stats }> {
73
+ const out: Array<{ urlPath: string; file: string; stat: fs.Stats }> = []
74
+ let names: string[]
75
+ try { names = fs.readdirSync(dir) } catch { return out }
76
+ for (const name of names.sort()) {
77
+ if (skip(name)) continue
78
+ const file = path.join(dir, name)
79
+ let stat: fs.Stats
80
+ try { stat = fs.statSync(file) } catch { continue }
81
+ const urlPath = `${base}/${name}`
82
+ if (stat.isDirectory()) out.push(...walk(file, urlPath))
83
+ else if (stat.isFile() && TYPES[path.extname(name).toLowerCase()]) out.push({ urlPath, file, stat })
84
+ }
85
+ return out
86
+ }
87
+
88
+ /** A cheap fingerprint of the tree: every path, size and mtime. */
89
+ export function treeSignature(dir: string): string {
90
+ return walk(dir).map((f) => `${f.urlPath}:${f.stat.size}:${f.stat.mtimeMs}`).join('|')
91
+ }
92
+
93
+ function sha(body: Buffer): string {
94
+ return crypto.createHash('sha256').update(body).digest('hex').slice(0, 16)
95
+ }
96
+
97
+ /**
98
+ * Rewrite `/control/<path>` references (quoted, or inside `url(...)`) to carry `?v=<hash>`.
99
+ * Only references to a known, versionable asset are touched; a reference that already has a
100
+ * query string, or names a file that does not exist, is left exactly as written.
101
+ */
102
+ export function rewriteReferences(text: string, mount: string, hashes: Map<string, string>): string {
103
+ const prefix = mount.replace(/\/$/, '')
104
+ const re = new RegExp(`(["'(])${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(/[A-Za-z0-9_./-]+)(["')])`, 'g')
105
+ return text.replace(re, (whole, open: string, rel: string, close: string) => {
106
+ const hash = hashes.get(rel)
107
+ if (!hash || !VERSIONABLE.has(path.extname(rel).toLowerCase())) return whole
108
+ return `${open}${prefix}${rel}?v=${hash}${close}`
109
+ })
110
+ }
111
+
112
+ /**
113
+ * Build the table. Leaves (JS, fonts, images) are hashed first, then CSS is rewritten against
114
+ * them and hashed, then HTML is rewritten against everything. So a font change gives its CSS a
115
+ * new hash, and that gives index.html new references.
116
+ */
117
+ export function buildWebAssetTable(dir: string, mount = '/control'): WebAssetTable {
118
+ const files = walk(dir)
119
+ const hashes = new Map<string, string>()
120
+ const table: WebAssetTable = new Map()
121
+ const read = (file: string): Buffer | null => { try { return fs.readFileSync(file) } catch { return null } }
122
+ const add = (urlPath: string, ext: string, body: Buffer): void => {
123
+ const hash = sha(body)
124
+ hashes.set(urlPath, hash)
125
+ table.set(urlPath, { urlPath, ext, type: TYPES[ext], body, hash })
126
+ }
127
+ const pass = (pick: (ext: string) => boolean): void => {
128
+ for (const f of files) {
129
+ const ext = path.extname(f.urlPath).toLowerCase()
130
+ if (!pick(ext)) continue
131
+ const raw = read(f.file)
132
+ if (!raw) continue
133
+ const body = REWRITTEN.has(ext) ? Buffer.from(rewriteReferences(raw.toString('utf8'), mount, hashes), 'utf8') : raw
134
+ add(f.urlPath, ext, body)
135
+ }
136
+ }
137
+ pass((ext) => !REWRITTEN.has(ext))
138
+ pass((ext) => ext === '.css')
139
+ pass((ext) => ext === '.html')
140
+ return table
141
+ }
142
+
143
+ /** Which encoding to send: brotli, then gzip, then none. `q=0` refuses an encoding. */
144
+ export function pickEncoding(acceptEncoding: string | undefined): 'br' | 'gzip' | null {
145
+ const accepted = new Map<string, number>()
146
+ for (const part of String(acceptEncoding || '').toLowerCase().split(',')) {
147
+ const [name, ...params] = part.trim().split(';')
148
+ if (!name) continue
149
+ const q = params.map((p) => p.trim()).find((p) => p.startsWith('q='))
150
+ accepted.set(name.trim(), q ? Number(q.slice(2)) || 0 : 1)
151
+ }
152
+ const ok = (name: string): boolean => (accepted.get(name) ?? accepted.get('*') ?? 0) > 0
153
+ if (ok('br')) return 'br'
154
+ if (ok('gzip')) return 'gzip'
155
+ return null
156
+ }
157
+
158
+ function compressed(asset: WebAsset, encoding: 'br' | 'gzip'): Promise<Buffer> {
159
+ if (encoding === 'br') {
160
+ asset.br ??= new Promise((resolve, reject) => zlib.brotliCompress(asset.body, {
161
+ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 9, [zlib.constants.BROTLI_PARAM_SIZE_HINT]: asset.body.length },
162
+ }, (err, out) => (err ? reject(err) : resolve(out))))
163
+ return asset.br
164
+ }
165
+ asset.gzip ??= new Promise((resolve, reject) => zlib.gzip(asset.body, { level: 9 }, (err, out) => (err ? reject(err) : resolve(out))))
166
+ return asset.gzip
167
+ }
168
+
169
+ export interface WebControlStaticOptions {
170
+ mount?: string
171
+ /** How often the tree is re-checked for edits. 0 checks on every request. */
172
+ recheckMs?: number
173
+ now?: () => number
174
+ }
175
+
176
+ export function createWebControlStatic(dir: string, opts: WebControlStaticOptions = {}): RequestHandler & { table: () => WebAssetTable } {
177
+ const mount = opts.mount ?? '/control'
178
+ const recheckMs = opts.recheckMs ?? 1000
179
+ const now = opts.now ?? Date.now
180
+ let signature = treeSignature(dir)
181
+ let table = buildWebAssetTable(dir, mount)
182
+ let checkedAt = now()
183
+
184
+ const current = (): WebAssetTable => {
185
+ if (now() - checkedAt >= recheckMs) {
186
+ checkedAt = now()
187
+ const next = treeSignature(dir)
188
+ if (next !== signature) { signature = next; table = buildWebAssetTable(dir, mount) }
189
+ }
190
+ return table
191
+ }
192
+
193
+ const handler = (req: Request, res: Response, next: NextFunction): void => {
194
+ if (req.method !== 'GET' && req.method !== 'HEAD') { next(); return }
195
+ let urlPath = req.path || '/'
196
+ if (urlPath.endsWith('/')) urlPath += 'index.html'
197
+ const asset = current().get(urlPath)
198
+ if (!asset) { next(); return }
199
+
200
+ const versioned = VERSIONABLE.has(asset.ext) && req.query.v === asset.hash
201
+ const encoding = COMPRESSIBLE.has(asset.ext) ? pickEncoding(req.headers['accept-encoding'] as string | undefined) : null
202
+ const etag = `"${asset.hash}${encoding ? `-${encoding}` : ''}"`
203
+ res.setHeader('Content-Type', asset.type)
204
+ res.setHeader('Cache-Control', versioned ? IMMUTABLE_CACHE : REVALIDATE_CACHE)
205
+ res.setHeader('ETag', etag)
206
+ if (COMPRESSIBLE.has(asset.ext)) res.setHeader('Vary', 'Accept-Encoding')
207
+
208
+ const inm = String(req.headers['if-none-match'] || '')
209
+ if (inm && inm.split(',').map((t) => t.trim().replace(/^W\//, '')).includes(etag)) { res.status(304).end(); return }
210
+
211
+ const send = (body: Buffer): void => {
212
+ if (encoding) res.setHeader('Content-Encoding', encoding)
213
+ res.setHeader('Content-Length', String(body.length))
214
+ if (req.method === 'HEAD') res.end(); else res.end(body)
215
+ }
216
+ if (!encoding) { send(asset.body); return }
217
+ compressed(asset, encoding).then(send, () => {
218
+ // Compression failed: send the plain bytes rather than nothing.
219
+ res.removeHeader('Content-Encoding')
220
+ res.setHeader('ETag', `"${asset.hash}"`)
221
+ res.setHeader('Content-Length', String(asset.body.length))
222
+ if (req.method === 'HEAD') res.end(); else res.end(asset.body)
223
+ })
224
+ }
225
+ return Object.assign(handler, { table: current })
226
+ }
@@ -306,14 +306,15 @@ export async function dispatchMemoriesWebOp(
306
306
  return r.status === 200 ? reply(true, 'Duplicates ready', r.body) : reply(false, r.body.message || r.body.error || 'Failed')
307
307
  }
308
308
  case 'cues.summary': {
309
- // Rating cues opens Control's native cue pane, which the browser does not have, so the
310
- // page is told cues are unavailable and hides "Cues to rate" rather than show a button
311
- // that does nothing. The counts use Control's keys for when a web cue pane exists.
312
- const r = await loopbackFetch('/api/live-cues/history?days=7&limit=200', { timeoutMs: 10_000 })
313
- const counts = (r.status === 200 ? r.body.counts ?? {} : {}) as Record<string, any>
314
- const unrated = typeof counts.unrated === 'number' ? counts.unrated : 0
315
- const rated = typeof counts.rated === 'number' ? counts.rated : 0
316
- return reply(true, 'Cues', { available: false, unrated, rated, total: unrated + rated })
309
+ // Control's CueVerdicts.summary: the cue history read is the capability check. A 200 in the v1 contract means the
310
+ // server keeps the cue log and verdicts, and COS Control Web rates them in Memories (Cues to rate, Rated cues), so
311
+ // the answer is available with the week's counts; anything else is unavailable and the entries hide.
312
+ const r = await loopbackFetch('/api/live-cues/history?days=7&unrated=1&limit=1', { timeoutMs: 10_000 })
313
+ const counts = (r.status === 200 && r.body.v === 1 ? r.body.counts ?? null : null) as Record<string, any> | null
314
+ if (!counts || typeof counts.unrated !== 'number' || typeof counts.rated !== 'number' || typeof counts.total !== 'number') {
315
+ return reply(true, 'Cues', { available: false })
316
+ }
317
+ return reply(true, 'Cues', { available: true, unrated: counts.unrated, rated: counts.rated, total: counts.total })
317
318
  }
318
319
  case 'copy':
319
320
  return reply(true, 'Copied', {})