@gotcos/glasses-server 6.99.20 → 6.99.29

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 (65) hide show
  1. package/CHANGELOG.md +257 -0
  2. package/docs/work-orchestrator.md +20 -0
  3. package/package.json +1 -1
  4. package/server/index.ts +48 -5
  5. package/server/lib/agent-session-output-images.ts +280 -0
  6. package/server/lib/agent-session-store.ts +3 -1
  7. package/server/lib/agent-session-turns.ts +18 -7
  8. package/server/lib/client-liveness.ts +38 -3
  9. package/server/lib/codex-subagent-activity.ts +36 -0
  10. package/server/lib/g2-turn-provenance.ts +679 -0
  11. package/server/lib/media-store.ts +41 -2
  12. package/server/lib/permission-broker.ts +94 -17
  13. package/server/lib/queue-turn-evidence.ts +19 -2
  14. package/server/lib/session-halt.ts +15 -1
  15. package/server/lib/session-stream-events.ts +4 -0
  16. package/server/lib/session-subagents.ts +155 -0
  17. package/server/lib/session-turn-attachments.ts +245 -0
  18. package/server/lib/thread-turn-queue-deliver.ts +17 -2
  19. package/server/lib/thread-turn-queue.ts +10 -0
  20. package/server/lib/today-brief.ts +731 -0
  21. package/server/lib/turn-file-store.ts +202 -0
  22. package/server/lib/turn-footer.ts +81 -0
  23. package/server/lib/web-control-static.ts +226 -0
  24. package/server/lib/web-memories-bridge.ts +9 -8
  25. package/server/lib/web-pet-choice.ts +225 -0
  26. package/server/lib/web-pet.ts +226 -0
  27. package/server/lib/work-lens-rounds.ts +29 -4
  28. package/server/routes/agent-session-bindings.ts +164 -16
  29. package/server/routes/agent-sessions.ts +77 -8
  30. package/server/routes/client-instance.ts +12 -1
  31. package/server/routes/cursor-stop-followup.ts +30 -1
  32. package/server/routes/g2-turns.ts +46 -0
  33. package/server/routes/health.ts +9 -0
  34. package/server/routes/media.ts +32 -8
  35. package/server/routes/permission-broker.ts +47 -4
  36. package/server/routes/thread-turn-queue.ts +148 -8
  37. package/server/routes/today-brief.ts +148 -0
  38. package/server/routes/web-control.ts +110 -7
  39. package/server/routes/work-lens-rounds.ts +19 -5
  40. package/server/web-control/app.css +26 -5
  41. package/server/web-control/app.js +1351 -177
  42. package/server/web-control/control.css +1 -1
  43. package/server/web-control/index.html +31 -13
  44. package/server/web-control/memories/memory-workspace.js +1 -1
  45. package/server/web-control/panes/cos-chat.css +22 -0
  46. package/server/web-control/panes/cos-chat.js +264 -48
  47. package/server/web-control/panes/meetings.css +15 -2
  48. package/server/web-control/panes/meetings.js +20 -9
  49. package/server/web-control/panes/memories.css +291 -0
  50. package/server/web-control/panes/memories.js +2208 -0
  51. package/server/web-control/panes/questions.css +39 -1
  52. package/server/web-control/panes/questions.js +419 -64
  53. package/server/web-control/panes/reader-media.js +154 -0
  54. package/server/web-control/panes/sessions.css +2 -0
  55. package/server/web-control/panes/settings.js +136 -6
  56. package/server/web-control/panes/today-actions.css +167 -0
  57. package/server/web-control/panes/today-actions.js +1303 -0
  58. package/server/web-control/panes/work.css +54 -0
  59. package/server/web-control/panes/work.js +1037 -18
  60. package/server/web-control/shell.js +6 -2
  61. package/server/web-control/theme.js +0 -2
  62. package/server/web-control/memories/memories-app.js +0 -1561
  63. package/server/web-control/memories/memories-theme.css +0 -73
  64. package/server/web-control/memories/memories.html +0 -72
  65. package/server/web-control/memories/memory-stewardship.js +0 -63
@@ -0,0 +1,202 @@
1
+ // Any file, for a session turn only (Miles, 2026-11-07: "We still don't have the ability to take picture, or add photo
2
+ // or files from the web").
3
+ //
4
+ // The media store takes what a MODEL can read (photos, PDF, TXT, MD, CSV, JSON, XLSX, MP4, MOV): it extracts text and
5
+ // frames, and an Ask, a Drop or Prep hands those to a model. A Claude or Codex session on this Mac is different: it gets
6
+ // the file's path (lib/session-turn-attachments.ts) and opens it with its own tools, so a .zip, a .docx, a log or a
7
+ // HEIC is useful there. This keeps those, and only those, beside the media store:
8
+ //
9
+ // - Same upload: POST /api/media/file. Only when the upload says `X-COS-Purpose: session-turn` AND the media store
10
+ // refuses the file as a format it cannot read does the file land here instead (routes/media.ts).
11
+ // - Same ids (m_ + 24 hex), but never in the media index: an Ask, a Drop, Prep or GET /api/media/:id cannot find one
12
+ // (media_not_found), so a file no model can read never reaches a model. Only the turn and fork routes resolve them.
13
+ // - Same cap as the media store's own for a file that is not a video: 64 MB (MAX_OTHER_MEDIA_BYTES).
14
+ // - Kept 4 hours until a turn takes it, then 30 days. Swept on each new file, at boot and every 5 minutes after
15
+ // (startSweep, beside the media store's own GC; QA 6.99.27).
16
+ // - Each file is `<root>/<id>/file/<its own name, made safe>`, so the agent sees a real name and extension, and the
17
+ // record is `<root>/<id>/meta.json`, one folder up. No name a person gives a file can reach the record: on the
18
+ // default case-insensitive macOS disk a file named META.JSON beside the record WAS the record, and the record write
19
+ // replaced its bytes (QA, 6.99.27). Nothing here ever serves the bytes back over HTTP.
20
+
21
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, copyFileSync, statSync } from 'node:fs'
22
+ import { join, resolve, sep } from 'node:path'
23
+ import { randomBytes } from 'node:crypto'
24
+ import { DATA_DIR } from './data-dir.js'
25
+ import { atomicWriteFileSync } from './atomic-fs.js'
26
+ import { MAX_OTHER_MEDIA_BYTES, RichMediaSafetyError } from './rich-media-safety.js'
27
+ import { isValidMediaId, type MediaAttachmentRef } from '../../shared/media-attachment.js'
28
+ import { MediaStoreError, MEDIA_GC_INTERVAL_MS } from './media-store.js'
29
+
30
+ export const TURN_FILE_MAX_BYTES = MAX_OTHER_MEDIA_BYTES
31
+ export const TURN_FILE_STAGED_TTL_MS = 4 * 60 * 60_000
32
+ export const TURN_FILE_KEPT_TTL_MS = 30 * 24 * 60 * 60_000
33
+ const PAYLOAD_DIR = 'file'
34
+
35
+ interface TurnFileMeta {
36
+ id: string
37
+ name: string
38
+ label: string
39
+ mime: string
40
+ bytes: number
41
+ createdAtMs: number
42
+ associatedAtMs?: number
43
+ runId?: string
44
+ }
45
+
46
+ /** What the upload answers: an id and what it is. Never a path. */
47
+ export interface TurnFileRef {
48
+ id: string
49
+ kind: 'session_file'
50
+ mime: string
51
+ bytes: number
52
+ label: string
53
+ createdAt: string
54
+ }
55
+
56
+ /** A filename the agent can read, that cannot climb out of its folder or carry control characters. */
57
+ export function safeTurnFileName(label: string | undefined): string {
58
+ // eslint-disable-next-line no-control-regex
59
+ // QA 6.99.27: bidi controls go too, so the name on disk (and in the path the agent reads) shows as what it is.
60
+ let name = String(label ?? '').replace(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, '')
61
+ .replace(/[\u0000-\u001f\u007f\u2028\u2029/\\:]+/g, ' ').replace(/\s+/g, ' ').trim()
62
+ name = name.replace(/^[.\s]+/, '').trimEnd()
63
+ if (!name) name = 'file'
64
+ if (name.length > 120) {
65
+ const dot = name.lastIndexOf('.')
66
+ const ext = dot > 0 && name.length - dot <= 12 ? name.slice(dot) : ''
67
+ name = name.slice(0, 120 - ext.length).trimEnd() + ext
68
+ }
69
+ return name
70
+ }
71
+
72
+ function safeMime(declared: string | undefined): string {
73
+ const m = String(declared ?? '').toLowerCase().split(';', 1)[0]!.trim()
74
+ return /^[a-z0-9][a-z0-9!#$&^_.+-]{0,63}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,63}$/.test(m) ? m : 'application/octet-stream'
75
+ }
76
+
77
+ export class TurnFileStore {
78
+ readonly root: string
79
+ private readonly now: () => number
80
+ constructor(root: string = join(DATA_DIR, 'turn-files'), now: () => number = Date.now) {
81
+ this.root = resolve(root)
82
+ this.now = now
83
+ }
84
+
85
+ private dirOf(id: string): string { return join(this.root, id) }
86
+ /** The file's own folder, apart from the record (see the header). */
87
+ private payloadDirOf(id: string): string { return join(this.root, id, PAYLOAD_DIR) }
88
+
89
+ private readMeta(id: string): TurnFileMeta | null {
90
+ if (!isValidMediaId(id)) return null
91
+ const path = join(this.dirOf(id), 'meta.json')
92
+ if (!existsSync(path)) return null
93
+ try {
94
+ const m = JSON.parse(readFileSync(path, 'utf8')) as TurnFileMeta
95
+ if (m.id !== id || typeof m.name !== 'string' || m.name !== safeTurnFileName(m.name) || typeof m.createdAtMs !== 'number') return null
96
+ return m
97
+ } catch { return null }
98
+ }
99
+
100
+ private expired(m: TurnFileMeta, now: number): boolean {
101
+ return typeof m.associatedAtMs === 'number' ? now - m.associatedAtMs > TURN_FILE_KEPT_TTL_MS : now - m.createdAtMs > TURN_FILE_STAGED_TTL_MS
102
+ }
103
+
104
+ /** Takes a staged upload (moved, else copied). Throws RichMediaSafetyError('attachment_too_large') past the cap. */
105
+ ingestFromFile(input: { sourcePath: string; byteLength: number; label?: string; declaredMime?: string }): TurnFileRef {
106
+ if (!Number.isSafeInteger(input.byteLength) || input.byteLength <= 0) throw new RichMediaSafetyError('corrupt_attachment', 'attachment is empty')
107
+ if (input.byteLength > TURN_FILE_MAX_BYTES) throw new RichMediaSafetyError('attachment_too_large', `attachment exceeds ${TURN_FILE_MAX_BYTES} byte limit`)
108
+ this.sweep()
109
+ const id = 'm_' + randomBytes(12).toString('hex')
110
+ const dir = this.dirOf(id)
111
+ const payloadDir = this.payloadDirOf(id)
112
+ mkdirSync(payloadDir, { recursive: true, mode: 0o700 })
113
+ const name = safeTurnFileName(input.label)
114
+ const target = join(payloadDir, name)
115
+ try {
116
+ renameSync(input.sourcePath, target)
117
+ } catch {
118
+ copyFileSync(input.sourcePath, target)
119
+ }
120
+ const now = this.now()
121
+ const meta: TurnFileMeta = { id, name, label: String(input.label ?? '').slice(0, 120) || name, mime: safeMime(input.declaredMime), bytes: input.byteLength, createdAtMs: now }
122
+ atomicWriteFileSync(join(dir, 'meta.json'), JSON.stringify(meta))
123
+ return { id, kind: 'session_file', mime: meta.mime, bytes: meta.bytes, label: meta.label, createdAt: new Date(now).toISOString() }
124
+ }
125
+
126
+ /** The file's ref (in the media ref's shape, for the turn's line) and path, or a media-store error. */
127
+ resolveUsable(id: string): { record: { ref: MediaAttachmentRef }; path: string } {
128
+ const m = this.readMeta(id)
129
+ if (!m) throw new MediaStoreError('media_not_found', `attachment ${id} not found`)
130
+ if (this.expired(m, this.now())) throw new MediaStoreError('media_expired', `attachment ${id} expired`)
131
+ const path = join(this.payloadDirOf(id), m.name)
132
+ if (!resolve(path).startsWith(this.payloadDirOf(id) + sep) || !existsSync(path)) throw new MediaStoreError('media_unavailable', `attachment ${id} content unavailable`)
133
+ // Shaped as a ref only for the line the agent reads (kind and mime are not in the media contract and never leave
134
+ // this server as one).
135
+ const ref = { id, kind: 'session_file', category: 'file', mime: m.mime, width: 0, height: 0, createdAt: new Date(m.createdAtMs).toISOString(), bytes: m.bytes, label: m.label } as unknown as MediaAttachmentRef
136
+ return { record: { ref }, path }
137
+ }
138
+
139
+ has(id: string): boolean { return this.readMeta(id) !== null }
140
+
141
+ /** Kept for 30 days from now: a turn took it. */
142
+ async associate(ids: string[], target: { runId?: string }): Promise<void> {
143
+ const now = this.now()
144
+ for (const id of ids) {
145
+ const m = this.readMeta(id)
146
+ if (!m) throw new MediaStoreError('media_not_found', `attachment ${id} not found`)
147
+ if (this.expired(m, now)) throw new MediaStoreError('media_expired', `attachment ${id} expired`)
148
+ m.associatedAtMs = now
149
+ if (target.runId) m.runId = target.runId.slice(0, 160)
150
+ atomicWriteFileSync(join(this.dirOf(id), 'meta.json'), JSON.stringify(m))
151
+ }
152
+ }
153
+
154
+ private sweepTimer: ReturnType<typeof setInterval> | null = null
155
+ /**
156
+ * QA 6.99.27 (Skeptic W2): a sweep at once and then every `intervalMs` (the media store's GC interval), so a file
157
+ * past its time goes even when no new file arrives. Before, only an upload swept. Idempotent; the timer never holds
158
+ * the process open.
159
+ */
160
+ startSweep(intervalMs: number = MEDIA_GC_INTERVAL_MS): void {
161
+ if (this.sweepTimer) return
162
+ this.sweep()
163
+ this.sweepTimer = setInterval(() => { this.sweep() }, intervalMs)
164
+ this.sweepTimer.unref?.()
165
+ }
166
+ stopSweep(): void {
167
+ if (this.sweepTimer) clearInterval(this.sweepTimer)
168
+ this.sweepTimer = null
169
+ }
170
+
171
+ /** Removes expired files and folders that are not ours. Never throws. */
172
+ sweep(): number {
173
+ let removed = 0
174
+ if (!existsSync(this.root)) return 0
175
+ const now = this.now()
176
+ let names: string[] = []
177
+ try { names = readdirSync(this.root) } catch { return 0 }
178
+ for (const name of names) {
179
+ const dir = join(this.root, name)
180
+ try {
181
+ if (!statSync(dir).isDirectory()) continue
182
+ const m = this.readMeta(name)
183
+ // A folder with no readable meta is a crashed upload once it is past the staged TTL.
184
+ const stale = m ? this.expired(m, now) : now - statSync(dir).mtimeMs > TURN_FILE_STAGED_TTL_MS
185
+ if (stale) { rmSync(dir, { recursive: true, force: true }); removed++ }
186
+ } catch { /* one bad folder never stops the sweep */ }
187
+ }
188
+ return removed
189
+ }
190
+ }
191
+
192
+ let defaultStore: TurnFileStore | null = null
193
+ export function getTurnFileStore(): TurnFileStore {
194
+ if (!defaultStore) defaultStore = new TurnFileStore()
195
+ return defaultStore
196
+ }
197
+ /** Test hook. */
198
+ export function _setTurnFileStoreForTests(store: TurnFileStore | null): TurnFileStore | null {
199
+ const prev = defaultStore
200
+ defaultStore = store
201
+ return prev
202
+ }
@@ -0,0 +1,81 @@
1
+ // The one trailing verify line a vouched glasses or phone turn carries into an agent session (G2
2
+ // authority Tier 1, 6.99.29). PURE: no I/O, no imports, so the stream grammar
3
+ // (session-stream-events.ts), the transcript readers and the queue evidence can take the line off
4
+ // without importing the ledger. lib/g2-turn-provenance.ts re-exports all of it.
5
+ //
6
+ // [COS-G2 turn <id> · verify: operations/scripts/cos_python operations/scripts/verify_g2_turn.py <id> --session <your session id> --provider claude]
7
+ //
8
+ // The provider is filled in (claude, codex or cursor): the server footing the turn knows which
9
+ // harness it delivers into (Codex re-audit of 910eb90). The session stays a placeholder ON PURPOSE:
10
+ // the receiving agent states its OWN session id and the verifier compares it with the record, so a
11
+ // message that landed in the wrong session does not verify there. An unknown provider keeps the
12
+ // `<claude|codex|cursor>` placeholder rather than naming a wrong one.
13
+ //
14
+ // The COS-WEB tag is reserved for a future web origin backed by a device key the API token cannot
15
+ // produce. In 6.99.29 no turn is footed COS-WEB: a web turn is recorded, never vouched. The readers
16
+ // still take either tag off.
17
+ //
18
+ // The verifier (MU-Chief-Staff operations/scripts/verify_g2_turn.py) mirrors G2_TURN_FOOTER_RE.
19
+ //
20
+ // FOOTER-SHAPED LINES IN TYPED TEXT ARE REFUSED (QA 6.99.29). Only the server writes a footer, so
21
+ // words that carry anything that looks like one are refused at every route that hands words to a
22
+ // session (turns, queue park and edit, steer, fork, Send again, native Codex queue edit).
23
+
24
+ /** The footer tag per origin: the glasses and the phone keep COS-G2, a web turn says COS-WEB. */
25
+ export function turnFooterTag(client: string): 'COS-G2' | 'COS-WEB' {
26
+ return client === 'web' ? 'COS-WEB' : 'COS-G2'
27
+ }
28
+
29
+ /** The harnesses the verifier accepts for --provider. */
30
+ export const FOOTER_PROVIDERS: ReadonlySet<string> = new Set(['claude', 'codex', 'cursor'])
31
+
32
+ /** The footer's --provider value: the receiving harness when it is one the verifier knows, else the placeholder. */
33
+ export function footerProvider(provider: string | null | undefined): string {
34
+ return typeof provider === 'string' && FOOTER_PROVIDERS.has(provider) ? provider : '<claude|codex|cursor>'
35
+ }
36
+
37
+ export function turnFooter(turnId: string, client: string = 'glasses', provider?: string | null): string {
38
+ return `[${turnFooterTag(client)} turn ${turnId} · verify: operations/scripts/cos_python operations/scripts/verify_g2_turn.py ${turnId} --session <your session id> --provider ${footerProvider(provider)}]`
39
+ }
40
+
41
+ /**
42
+ * Anything that looks like a footer, anywhere in the text: `[COS-G2 turn` or `[COS-WEB turn`, any
43
+ * case, with spaces or any dash between the parts. Lenient on purpose: refusing a lookalike costs a
44
+ * retyped line; accepting one could put a second footer in front of a reader.
45
+ */
46
+ export const TURN_FOOTER_SHAPED_RE = /\[\s*COS[-\u2010-\u2015\u2212\s]*(?:G2|WEB)\s+turn\b/i
47
+
48
+ /** Whether typed words carry a footer-shaped line (refused: only the server writes a footer). */
49
+ export function hasFooterShapedLine(text: string): boolean {
50
+ return typeof text === 'string' && TURN_FOOTER_SHAPED_RE.test(text)
51
+ }
52
+
53
+ /** The refusal copy for a footer-shaped line, shown where the words were typed. */
54
+ export const FOOTER_LINE_REFUSED_COPY = 'This message has a line that looks like a COS verify footer ("[COS-G2 turn" or "[COS-WEB turn"). Only COS writes those lines. Take that line out and send it again.'
55
+
56
+ /** The refusal copy for a turn id this server already used for a different turn. */
57
+ export const TURN_ID_IN_USE_COPY = 'That message id was already used for a different message. Send it again as a new message.'
58
+
59
+ /** The 6.66 name, kept: a glasses or phone footer. */
60
+ export function g2TurnFooter(turnId: string, provider?: string | null): string {
61
+ return turnFooter(turnId, 'glasses', provider)
62
+ }
63
+
64
+ /** The footer line as it ends a delivered prompt (either tag). Used by tests and mirrored by the verifier. */
65
+ export const G2_TURN_FOOTER_RE = /\n\[COS-(G2|WEB) turn ([A-Za-z0-9][A-Za-z0-9_-]{7,127}) · verify: [^\n\]]*\][\t\n\v\f\r ]*$/
66
+
67
+ /** The prompt with its footer stripped, and the turn id the footer named (null: no footer). */
68
+ export function stripG2TurnFooter(text: string): { text: string; turnId: string | null; tag: 'COS-G2' | 'COS-WEB' | null } {
69
+ const m = G2_TURN_FOOTER_RE.exec(text)
70
+ if (!m) return { text, turnId: null, tag: null }
71
+ return { text: text.slice(0, m.index), turnId: m[2], tag: m[1] === 'WEB' ? 'COS-WEB' : 'COS-G2' }
72
+ }
73
+
74
+ /**
75
+ * The words a transcript row shows with a trailing verify footer taken off, for the code that
76
+ * matches a transcript against the words it sent (queue-turn-evidence.ts). Only a footer at the
77
+ * very end, of either tag, is removed.
78
+ */
79
+ export function withoutTurnFooter(text: string): string {
80
+ return stripG2TurnFooter(text).text
81
+ }
@@ -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', {})