@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.
- package/CHANGELOG.md +257 -0
- package/docs/work-orchestrator.md +20 -0
- package/package.json +1 -1
- package/server/index.ts +48 -5
- package/server/lib/agent-session-output-images.ts +280 -0
- package/server/lib/agent-session-store.ts +3 -1
- package/server/lib/agent-session-turns.ts +18 -7
- package/server/lib/client-liveness.ts +38 -3
- package/server/lib/codex-subagent-activity.ts +36 -0
- package/server/lib/g2-turn-provenance.ts +679 -0
- package/server/lib/media-store.ts +41 -2
- package/server/lib/permission-broker.ts +94 -17
- package/server/lib/queue-turn-evidence.ts +19 -2
- package/server/lib/session-halt.ts +15 -1
- package/server/lib/session-stream-events.ts +4 -0
- package/server/lib/session-subagents.ts +155 -0
- package/server/lib/session-turn-attachments.ts +245 -0
- package/server/lib/thread-turn-queue-deliver.ts +17 -2
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +731 -0
- package/server/lib/turn-file-store.ts +202 -0
- package/server/lib/turn-footer.ts +81 -0
- package/server/lib/web-control-static.ts +226 -0
- package/server/lib/web-memories-bridge.ts +9 -8
- package/server/lib/web-pet-choice.ts +225 -0
- package/server/lib/web-pet.ts +226 -0
- package/server/lib/work-lens-rounds.ts +29 -4
- package/server/routes/agent-session-bindings.ts +164 -16
- package/server/routes/agent-sessions.ts +77 -8
- package/server/routes/client-instance.ts +12 -1
- package/server/routes/cursor-stop-followup.ts +30 -1
- package/server/routes/g2-turns.ts +46 -0
- package/server/routes/health.ts +9 -0
- package/server/routes/media.ts +32 -8
- package/server/routes/permission-broker.ts +47 -4
- package/server/routes/thread-turn-queue.ts +148 -8
- package/server/routes/today-brief.ts +148 -0
- package/server/routes/web-control.ts +110 -7
- package/server/routes/work-lens-rounds.ts +19 -5
- package/server/web-control/app.css +26 -5
- package/server/web-control/app.js +1351 -177
- package/server/web-control/control.css +1 -1
- package/server/web-control/index.html +31 -13
- package/server/web-control/memories/memory-workspace.js +1 -1
- package/server/web-control/panes/cos-chat.css +22 -0
- package/server/web-control/panes/cos-chat.js +264 -48
- package/server/web-control/panes/meetings.css +15 -2
- package/server/web-control/panes/meetings.js +20 -9
- package/server/web-control/panes/memories.css +291 -0
- package/server/web-control/panes/memories.js +2208 -0
- package/server/web-control/panes/questions.css +39 -1
- package/server/web-control/panes/questions.js +419 -64
- package/server/web-control/panes/reader-media.js +154 -0
- package/server/web-control/panes/sessions.css +2 -0
- package/server/web-control/panes/settings.js +136 -6
- package/server/web-control/panes/today-actions.css +167 -0
- package/server/web-control/panes/today-actions.js +1303 -0
- package/server/web-control/panes/work.css +54 -0
- package/server/web-control/panes/work.js +1037 -18
- package/server/web-control/shell.js +6 -2
- package/server/web-control/theme.js +0 -2
- package/server/web-control/memories/memories-app.js +0 -1561
- package/server/web-control/memories/memories-theme.css +0 -73
- package/server/web-control/memories/memories.html +0 -72
- 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
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
const r = await loopbackFetch('/api/live-cues/history?days=7&limit=
|
|
313
|
-
const counts = (r.status === 200 ? r.body.counts ??
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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', {})
|