@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.
- package/CHANGELOG.md +137 -0
- package/docs/work-orchestrator.md +20 -0
- package/package.json +1 -1
- package/server/index.ts +23 -1
- package/server/lib/ask-work-plan.ts +116 -0
- package/server/lib/batch-transcript-quality.ts +2 -0
- package/server/lib/drop-store.ts +16 -2
- package/server/lib/meeting-ask-runtime.ts +1 -1
- package/server/lib/meeting-batch-transcribe.ts +40 -1
- package/server/lib/ping-sources.ts +6 -2
- package/server/lib/prep-runtime.ts +915 -0
- package/server/lib/prep-store.ts +226 -0
- package/server/lib/stt-resolver.ts +26 -2
- package/server/lib/transcript-cleanup.ts +11 -2
- package/server/lib/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/lib/work-orchestrator/runtime.ts +8 -2
- package/server/routes/ask-work-plan.ts +114 -0
- package/server/routes/health.ts +8 -0
- package/server/routes/prep.ts +81 -0
- package/server/routes/web-control.ts +110 -7
- package/server/routes/welcome-context.ts +29 -1
- package/server/routes/work-lens-rounds.ts +19 -5
- package/server/routes/work-orchestrator.ts +57 -0
- package/server/web-control/app.css +24 -5
- package/server/web-control/app.js +646 -108
- package/server/web-control/control.css +1 -1
- package/server/web-control/index.html +27 -13
- package/server/web-control/memories/memory-workspace.js +1 -1
- package/server/web-control/panes/cos-chat.css +26 -0
- package/server/web-control/panes/cos-chat.js +641 -6
- package/server/web-control/panes/meetings.css +45 -2
- package/server/web-control/panes/meetings.js +230 -7
- package/server/web-control/panes/memories.css +291 -0
- package/server/web-control/panes/memories.js +2208 -0
- package/server/web-control/panes/questions.js +59 -21
- package/server/web-control/panes/settings.js +102 -4
- package/server/web-control/panes/today-actions.css +109 -0
- package/server/web-control/panes/today-actions.js +972 -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/shared/meeting-ask.ts +6 -2
- package/shared/prep.ts +317 -0
- 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,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which pet COS Control Web shows (Miles 11/06 23:06: "build the change pet picker ... Just use the
|
|
3
|
+
* existing 300 pack that we have on the COS control").
|
|
4
|
+
*
|
|
5
|
+
* A choice is one of:
|
|
6
|
+
* mac the pet COS Control shows (its own session-pet-states.json, or its single sprite)
|
|
7
|
+
* shipped:<id> a character COS Control ships in its app bundle (BundledCharacters/<id>, animated),
|
|
8
|
+
* or shipped:cloud-puff, the StarterPet still
|
|
9
|
+
* gallery:<id> one of the OpenPets gallery pets COS Control lists (openpets-catalog.json, about 300).
|
|
10
|
+
* As on the Mac (installOpenPetsThumb), a gallery pick is the gallery picture as a still.
|
|
11
|
+
*
|
|
12
|
+
* The choice is kept per browser. Changing the Mac's own pet stays in COS Control on the Mac: the app does
|
|
13
|
+
* not watch its pet folder, so writing it from here would leave the Mac showing the old pet.
|
|
14
|
+
* Everything here reads COS Control's files and never writes them.
|
|
15
|
+
*/
|
|
16
|
+
import crypto from 'crypto'
|
|
17
|
+
import fs from 'fs'
|
|
18
|
+
import path from 'path'
|
|
19
|
+
import { controlSupportDir, readPetSources } from './web-pet.js'
|
|
20
|
+
|
|
21
|
+
export type PetChoice =
|
|
22
|
+
| { kind: 'mac' }
|
|
23
|
+
| { kind: 'shipped'; id: string }
|
|
24
|
+
| { kind: 'gallery'; id: string }
|
|
25
|
+
|
|
26
|
+
const ID = /^[a-z0-9][a-z0-9-]{0,80}$/
|
|
27
|
+
|
|
28
|
+
export function parsePetChoice(raw: unknown): PetChoice | null {
|
|
29
|
+
if (raw === undefined) return { kind: 'mac' }
|
|
30
|
+
// A repeated ?pet= arrives as an array: refuse it rather than guess.
|
|
31
|
+
if (typeof raw !== 'string') return null
|
|
32
|
+
const s = raw
|
|
33
|
+
if (s === '' || s === 'mac') return { kind: 'mac' }
|
|
34
|
+
const m = s.match(/^(shipped|gallery):(.+)$/)
|
|
35
|
+
if (!m || !ID.test(m[2])) return null
|
|
36
|
+
return { kind: m[1] as 'shipped' | 'gallery', id: m[2] }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function petChoiceKey(c: PetChoice): string {
|
|
40
|
+
return c.kind === 'mac' ? 'mac' : `${c.kind}:${c.id}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** COS Control's app resources. Most installs live in /Applications; ~/Applications is checked too. */
|
|
44
|
+
export function controlResourcesDir(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
45
|
+
if (env.COS_CONTROL_RESOURCES_DIR) return env.COS_CONTROL_RESOURCES_DIR
|
|
46
|
+
for (const base of ['/Applications', path.join(env.HOME || '', 'Applications')]) {
|
|
47
|
+
const dir = path.join(base, 'COS Control.app', 'Contents', 'Resources')
|
|
48
|
+
try { if (fs.statSync(dir).isDirectory()) return dir } catch { /* next */ }
|
|
49
|
+
}
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Names and summaries word for word from COS Control (Models.swift bundledCharacters). Cloud Puff has no
|
|
54
|
+
* summary there (defaultCharacterName only), so its line is ours. Keep in step when Control adds a character. */
|
|
55
|
+
const SHIPPED_NAMES: Record<string, { name: string; summary: string }> = {
|
|
56
|
+
'jedi-miles-windu': { name: 'Jedi Miles Windu', summary: 'Ten animated states. Purple saber and escalating droid fights.' },
|
|
57
|
+
'jedi-nia-solari': { name: 'Jedi Nia Solari', summary: 'Eight-frame idle and four combat loops. Purple saber, braided silhouette, and Force counters.' },
|
|
58
|
+
'jedi-elara-vale': { name: 'Jedi Elara Vale', summary: 'Eight-frame idle and four combat loops. Green saber, auburn braid, and agile redirects.' },
|
|
59
|
+
'jedi-rowan-vale': { name: 'Jedi Rowan Vale', summary: 'Eight-frame idle and four combat loops. Blue saber, two-handed guard, and grounded strikes.' },
|
|
60
|
+
'cloud-puff': { name: 'Cloud Puff', summary: 'A simple, calm figure. The default for new installs.' },
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isFile(p: string): boolean {
|
|
64
|
+
try { return fs.statSync(p).isFile() } catch { return false }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ShippedPet { id: string; name: string; summary: string; animated: boolean }
|
|
68
|
+
|
|
69
|
+
/** Characters the installed COS Control carries. Only folders with a valid state map count. */
|
|
70
|
+
export function shippedPets(resources: string | null = controlResourcesDir()): ShippedPet[] {
|
|
71
|
+
if (!resources) return []
|
|
72
|
+
const out: ShippedPet[] = []
|
|
73
|
+
let names: string[] = []
|
|
74
|
+
try { names = fs.readdirSync(path.join(resources, 'BundledCharacters')).filter((n) => ID.test(n)).sort() } catch { /* none */ }
|
|
75
|
+
for (const id of names) {
|
|
76
|
+
if (Object.keys(readPetSources(path.join(resources, 'BundledCharacters', id))).length === 0) continue
|
|
77
|
+
const label = SHIPPED_NAMES[id] ?? { name: id.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()), summary: '' }
|
|
78
|
+
out.push({ id, name: label.name, summary: label.summary, animated: true })
|
|
79
|
+
}
|
|
80
|
+
if (isFile(path.join(resources, 'StarterPet', 'cloud-puff.webp'))) out.push({ id: 'cloud-puff', ...SHIPPED_NAMES['cloud-puff'], animated: false })
|
|
81
|
+
return out
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface GalleryPet { id: string; name: string; description: string; category: string; preview: string }
|
|
85
|
+
|
|
86
|
+
/** The OpenPets gallery COS Control fetched (openpets-catalog.json). Only https previews on openpets.dev. */
|
|
87
|
+
export function galleryPets(dir: string = controlSupportDir()): GalleryPet[] {
|
|
88
|
+
const file = path.join(dir, 'openpets-catalog.json')
|
|
89
|
+
let raw: { catalog?: { pets?: unknown[] } }
|
|
90
|
+
try {
|
|
91
|
+
if (!isFile(file)) return []
|
|
92
|
+
raw = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
93
|
+
} catch { return [] }
|
|
94
|
+
const pets = Array.isArray(raw?.catalog?.pets) ? raw.catalog!.pets! : []
|
|
95
|
+
const out: GalleryPet[] = []
|
|
96
|
+
const seen = new Set<string>()
|
|
97
|
+
for (const p of pets as Array<Record<string, unknown>>) {
|
|
98
|
+
const id = typeof p?.id === 'string' ? p.id : ''
|
|
99
|
+
const preview = typeof p?.preview === 'string' ? p.preview : ''
|
|
100
|
+
if (!ID.test(id) || seen.has(id) || !isOpenPetsUrl(preview)) continue
|
|
101
|
+
seen.add(id)
|
|
102
|
+
out.push({
|
|
103
|
+
id,
|
|
104
|
+
name: typeof p.displayName === 'string' && p.displayName ? p.displayName : id,
|
|
105
|
+
description: typeof p.description === 'string' ? p.description : '',
|
|
106
|
+
category: [p.category, p.subcategory].filter((x) => typeof x === 'string' && x).join(' '),
|
|
107
|
+
preview,
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
return out
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The Mac helper's rule, exactly (HelperSources/main.swift isAllowedThumbURL): https, host openpets.dev, a path
|
|
114
|
+
* ending in /thumb.webp, no user or password. The catalog is untrusted; nothing else is ever fetched. */
|
|
115
|
+
export function isOpenPetsUrl(url: string): boolean {
|
|
116
|
+
try {
|
|
117
|
+
const u = new URL(url)
|
|
118
|
+
return u.protocol === 'https:' && u.hostname === 'openpets.dev' && u.port === '' && !u.username && !u.password && u.pathname.endsWith('/thumb.webp')
|
|
119
|
+
} catch { return false }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type ResolvedPet =
|
|
123
|
+
| { kind: 'strips'; dir: string; cacheName: string }
|
|
124
|
+
| { kind: 'still'; file: string; type: string }
|
|
125
|
+
| { kind: 'gallery'; id: string; preview: string }
|
|
126
|
+
|
|
127
|
+
const STILL_TYPES: Record<string, string> = { '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg' }
|
|
128
|
+
|
|
129
|
+
/** Where the chosen pet's art lives, or null when that pet is not available on this Mac. */
|
|
130
|
+
export function resolvePet(choice: PetChoice, opts: { supportDir?: string; resources?: string | null } = {}): ResolvedPet | null {
|
|
131
|
+
const support = opts.supportDir ?? controlSupportDir()
|
|
132
|
+
const resources = opts.resources === undefined ? controlResourcesDir() : opts.resources
|
|
133
|
+
if (choice.kind === 'mac') {
|
|
134
|
+
if (Object.keys(readPetSources(support)).length > 0) return { kind: 'strips', dir: support, cacheName: '' }
|
|
135
|
+
// A gallery pick or a single custom image on the Mac: COS Control's plain sprite (PetSpriteStore.fileStem).
|
|
136
|
+
for (const ext of Object.keys(STILL_TYPES)) {
|
|
137
|
+
const file = path.join(support, `session-pet-sprite${ext}`)
|
|
138
|
+
if (isFile(file)) return { kind: 'still', file, type: STILL_TYPES[ext] }
|
|
139
|
+
}
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
if (choice.kind === 'shipped') {
|
|
143
|
+
if (!resources) return null
|
|
144
|
+
if (choice.id === 'cloud-puff') {
|
|
145
|
+
const file = path.join(resources, 'StarterPet', 'cloud-puff.webp')
|
|
146
|
+
return isFile(file) ? { kind: 'still', file, type: 'image/webp' } : null
|
|
147
|
+
}
|
|
148
|
+
const dir = path.join(resources, 'BundledCharacters', choice.id)
|
|
149
|
+
return Object.keys(readPetSources(dir)).length > 0 ? { kind: 'strips', dir, cacheName: `shipped-${choice.id}` } : null
|
|
150
|
+
}
|
|
151
|
+
const pet = galleryPets(support).find((p) => p.id === choice.id)
|
|
152
|
+
return pet ? { kind: 'gallery', id: pet.id, preview: pet.preview } : null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The Mac helper's limits (openPetsThumbMaxBytes, timeout 6): 512 KB, enforced while reading, and 6 seconds. */
|
|
156
|
+
export const GALLERY_MAX_BYTES = 512 * 1024
|
|
157
|
+
export const GALLERY_TIMEOUT_MS = 6_000
|
|
158
|
+
/** COS Control keeps the thumbnails it already fetched here (main.swift openPetsThumbsDir, <id>.webp). */
|
|
159
|
+
export const MAC_THUMBS_DIR = 'openpets-thumbs'
|
|
160
|
+
|
|
161
|
+
function isWebP(b: Buffer): boolean {
|
|
162
|
+
return b.length >= 12 && b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP'
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Read at most max bytes from a response body, cancelling the stream past the limit. */
|
|
166
|
+
async function readCapped(res: Response, max: number): Promise<Buffer> {
|
|
167
|
+
const reader = res.body?.getReader()
|
|
168
|
+
if (!reader) return Buffer.from(await res.arrayBuffer())
|
|
169
|
+
const parts: Buffer[] = []
|
|
170
|
+
let total = 0
|
|
171
|
+
for (;;) {
|
|
172
|
+
const { done, value } = await reader.read()
|
|
173
|
+
if (done) break
|
|
174
|
+
total += value.byteLength
|
|
175
|
+
if (total > max) { await reader.cancel().catch(() => {}); throw new Error('gallery_too_large') }
|
|
176
|
+
parts.push(Buffer.from(value))
|
|
177
|
+
}
|
|
178
|
+
return Buffer.concat(parts)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export type PetFetcher = (url: string, init: { signal: AbortSignal; redirect: 'error' }) => Promise<Response>
|
|
182
|
+
const galleryInflight = new Map<string, Promise<{ file: string; type: string }>>()
|
|
183
|
+
/** A picture that just failed is not asked for again for this long, so a broken gallery is not hammered. */
|
|
184
|
+
export const GALLERY_RETRY_MS = 10 * 60_000
|
|
185
|
+
const galleryFailedAt = new Map<string, number>()
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The gallery picture for one pet, downloaded once from openpets.dev (as COS Control does) and kept under
|
|
189
|
+
* the data folder. Refuses anything the Mac helper would (not WebP, over 512 KB, redirected, slower than 6 s).
|
|
190
|
+
*/
|
|
191
|
+
export function galleryThumb(pet: { id: string; preview: string }, cacheDir: string, fetcher: PetFetcher = fetch as unknown as PetFetcher, macSupportDir: string | null = null): Promise<{ file: string; type: string }> {
|
|
192
|
+
if (!ID.test(pet.id) || !isOpenPetsUrl(pet.preview)) return Promise.reject(new Error('gallery_pet_invalid'))
|
|
193
|
+
// The thumbnail COS Control already fetched, when it is a real WebP: no second download (read only).
|
|
194
|
+
if (macSupportDir) {
|
|
195
|
+
const mac = path.join(macSupportDir, MAC_THUMBS_DIR, `${pet.id}.webp`)
|
|
196
|
+
try {
|
|
197
|
+
if (fs.statSync(mac).isFile() && fs.statSync(mac).size <= GALLERY_MAX_BYTES && isWebP(fs.readFileSync(mac))) return Promise.resolve({ file: mac, type: 'image/webp' })
|
|
198
|
+
} catch { /* not there: fetch below */ }
|
|
199
|
+
}
|
|
200
|
+
const tag = crypto.createHash('sha256').update(pet.preview).digest('hex').slice(0, 12)
|
|
201
|
+
const base = path.join(cacheDir, `${pet.id}-${tag}`)
|
|
202
|
+
if (isFile(base + '.webp')) return Promise.resolve({ file: base + '.webp', type: 'image/webp' })
|
|
203
|
+
const failed = galleryFailedAt.get(base)
|
|
204
|
+
if (failed && Date.now() - failed < GALLERY_RETRY_MS) return Promise.reject(new Error('gallery_recently_failed'))
|
|
205
|
+
let job = galleryInflight.get(base)
|
|
206
|
+
if (!job) {
|
|
207
|
+
job = (async () => {
|
|
208
|
+
const res = await fetcher(pet.preview, { signal: AbortSignal.timeout(GALLERY_TIMEOUT_MS), redirect: 'error' })
|
|
209
|
+
if (res.status !== 200) throw new Error(`gallery_http_${res.status}`)
|
|
210
|
+
const declared = Number(res.headers.get('content-length') || 0)
|
|
211
|
+
if (declared > GALLERY_MAX_BYTES) throw new Error('gallery_too_large')
|
|
212
|
+
const body = await readCapped(res, GALLERY_MAX_BYTES)
|
|
213
|
+
// WebP only, as the Mac (isOpenPetsWebP).
|
|
214
|
+
if (!isWebP(body)) throw new Error('gallery_not_image')
|
|
215
|
+
fs.mkdirSync(cacheDir, { recursive: true })
|
|
216
|
+
const tmp = `${base}.${process.pid}.${Date.now()}.tmp`
|
|
217
|
+
fs.writeFileSync(tmp, body)
|
|
218
|
+
fs.renameSync(tmp, base + '.webp')
|
|
219
|
+
return { file: base + '.webp', type: 'image/webp' }
|
|
220
|
+
})().then((ok) => { galleryFailedAt.delete(base); return ok }, (err) => { galleryFailedAt.set(base, Date.now()); throw err })
|
|
221
|
+
.finally(() => galleryInflight.delete(base))
|
|
222
|
+
galleryInflight.set(base, job)
|
|
223
|
+
}
|
|
224
|
+
return job
|
|
225
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session pet on COS Control Web (Miles 11/06: "pets brought to the COS ... on home").
|
|
3
|
+
*
|
|
4
|
+
* COS Control keeps the active pet as horizontal PNG strips plus `session-pet-states.json`
|
|
5
|
+
* in `~/Library/Application Support/COS Control`. The web reads that same map, so the phone
|
|
6
|
+
* shows the pet the Mac shows, with no copy to keep in step.
|
|
7
|
+
*
|
|
8
|
+
* The Mac's 1x strips are 256 px tall and up to ~56,000 px wide (Windu's working cast is
|
|
9
|
+
* 184 frames, 3.2 MB). That is too much for a phone to decode at once, so each pose is
|
|
10
|
+
* resized to WEB_PET_HEIGHT with macOS `sips` and cut into chunks no wider than
|
|
11
|
+
* WEB_PET_MAX_CHUNK_WIDTH, each holding whole frames. The result is cached on disk, keyed by
|
|
12
|
+
* the source file's size and mtime, so a pet change on the Mac shows on the next load.
|
|
13
|
+
*
|
|
14
|
+
* `sips` quirk (measured 11/06): `--cropOffset 0 0` crops from the CENTER, while any other
|
|
15
|
+
* offset is measured from the top-left. The first chunk is therefore cut by flipping the
|
|
16
|
+
* strip, cropping from the far end, and flipping back. Pixel-identical to a direct crop.
|
|
17
|
+
*/
|
|
18
|
+
import crypto from 'crypto'
|
|
19
|
+
import { execFile } from 'child_process'
|
|
20
|
+
import fs from 'fs'
|
|
21
|
+
import os from 'os'
|
|
22
|
+
import path from 'path'
|
|
23
|
+
|
|
24
|
+
/** The poses COS Control's live pet uses (PetSpritePose.liveCases). */
|
|
25
|
+
export const WEB_PET_POSES = ['idle', 'patrol', 'waiting', 'working', 'duel', 'trio', 'swarm', 'done', 'error', 'attention'] as const
|
|
26
|
+
export type WebPetPose = typeof WEB_PET_POSES[number]
|
|
27
|
+
/** Frame height sent to the phone, in pixels. */
|
|
28
|
+
export const WEB_PET_HEIGHT = 160
|
|
29
|
+
/** Widest chunk sent, in pixels. Keeps each decoded image near 5 MB. */
|
|
30
|
+
export const WEB_PET_MAX_CHUNK_WIDTH = 8192
|
|
31
|
+
|
|
32
|
+
export interface PetPoseSource { pose: WebPetPose; file: string; frames: number; interval: number; width: number; height: number; size: number; mtimeMs: number }
|
|
33
|
+
export interface PetPosePlan extends PetPoseSource { outHeight: number; frameWidth: number; perChunk: number; chunks: number; key: string }
|
|
34
|
+
|
|
35
|
+
export function controlSupportDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
36
|
+
return env.COS_CONTROL_SUPPORT_DIR || path.join(os.homedir(), 'Library', 'Application Support', 'COS Control')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Width and height from a PNG's IHDR, or null for anything that is not a PNG. */
|
|
40
|
+
export function pngSize(file: string): { width: number; height: number } | null {
|
|
41
|
+
let fd: number | null = null
|
|
42
|
+
try {
|
|
43
|
+
// Non-blocking: a FIFO planted at this path would otherwise hang the whole server (hazard-invariants.test.ts).
|
|
44
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK)
|
|
45
|
+
if (!fs.fstatSync(fd).isFile()) return null
|
|
46
|
+
const head = Buffer.alloc(24)
|
|
47
|
+
if (fs.readSync(fd, head, 0, 24, 0) < 24) return null
|
|
48
|
+
if (head.readUInt32BE(0) !== 0x89504e47 || head.toString('ascii', 12, 16) !== 'IHDR') return null
|
|
49
|
+
return { width: head.readUInt32BE(16), height: head.readUInt32BE(20) }
|
|
50
|
+
} catch {
|
|
51
|
+
return null
|
|
52
|
+
} finally {
|
|
53
|
+
if (fd !== null) fs.closeSync(fd)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A file name from the state map, kept inside the support folder. */
|
|
58
|
+
function insideDir(dir: string, name: unknown): string | null {
|
|
59
|
+
if (typeof name !== 'string' || !name || name.includes('\0') || path.isAbsolute(name)) return null
|
|
60
|
+
const full = path.resolve(dir, name)
|
|
61
|
+
return full.startsWith(path.resolve(dir) + path.sep) ? full : null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Read the Mac's pet. Poses with a missing, unreadable or mismatched strip are left out. */
|
|
65
|
+
export function readPetSources(dir: string): Partial<Record<WebPetPose, PetPoseSource>> {
|
|
66
|
+
let map: { poses?: Record<string, { file?: unknown; frames?: unknown; interval?: unknown }> }
|
|
67
|
+
const mapFile = path.join(dir, 'session-pet-states.json')
|
|
68
|
+
try {
|
|
69
|
+
// Only a regular file is read: readFileSync on a FIFO blocks the event loop.
|
|
70
|
+
if (!fs.statSync(mapFile).isFile()) return {}
|
|
71
|
+
map = JSON.parse(fs.readFileSync(mapFile, 'utf8'))
|
|
72
|
+
} catch { return {} }
|
|
73
|
+
const out: Partial<Record<WebPetPose, PetPoseSource>> = {}
|
|
74
|
+
for (const pose of WEB_PET_POSES) {
|
|
75
|
+
const row = map?.poses?.[pose]
|
|
76
|
+
if (!row) continue
|
|
77
|
+
const file = insideDir(dir, row.file)
|
|
78
|
+
const frames = Number(row.frames)
|
|
79
|
+
if (!file || !Number.isInteger(frames) || frames < 1 || frames > 1000) continue
|
|
80
|
+
let stat: fs.Stats
|
|
81
|
+
try { stat = fs.statSync(file) } catch { continue }
|
|
82
|
+
if (!stat.isFile()) continue
|
|
83
|
+
const size = pngSize(file)
|
|
84
|
+
if (!size || size.height < 8 || size.width % frames !== 0) continue
|
|
85
|
+
const interval = Number(row.interval)
|
|
86
|
+
out[pose] = {
|
|
87
|
+
pose, file, frames, interval: Number.isFinite(interval) && interval >= 0.01 && interval <= 2 ? interval : 0.12,
|
|
88
|
+
width: size.width, height: size.height, size: stat.size, mtimeMs: stat.mtimeMs,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** How a pose is resized and chunked. Frame width is rounded once, so every chunk holds whole frames. */
|
|
95
|
+
export function planPose(src: PetPoseSource, height = WEB_PET_HEIGHT, maxChunk = WEB_PET_MAX_CHUNK_WIDTH): PetPosePlan {
|
|
96
|
+
const frameWidth = Math.max(1, Math.round((src.width / src.frames) * (height / src.height)))
|
|
97
|
+
const perChunk = Math.max(1, Math.floor(maxChunk / frameWidth))
|
|
98
|
+
const chunks = Math.ceil(src.frames / perChunk)
|
|
99
|
+
const key = crypto.createHash('sha256')
|
|
100
|
+
.update(`${src.file}\0${src.size}\0${src.mtimeMs}\0${src.frames}\0${height}\0${maxChunk}\0v1`)
|
|
101
|
+
.digest('hex').slice(0, 16)
|
|
102
|
+
return { ...src, outHeight: height, frameWidth, perChunk, chunks, key }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function webPetManifest(dir: string = controlSupportDir(), pet = 'mac') {
|
|
106
|
+
const sources = readPetSources(dir)
|
|
107
|
+
const poses: Record<string, { frames: number; interval: number; frameWidth: number; height: number; perChunk: number; chunks: string[] }> = {}
|
|
108
|
+
for (const pose of WEB_PET_POSES) {
|
|
109
|
+
const src = sources[pose]
|
|
110
|
+
if (!src) continue
|
|
111
|
+
const p = planPose(src)
|
|
112
|
+
poses[pose] = {
|
|
113
|
+
frames: p.frames, interval: p.interval, frameWidth: p.frameWidth, height: WEB_PET_HEIGHT, perChunk: p.perChunk,
|
|
114
|
+
chunks: Array.from({ length: p.chunks }, (_, i) => `/api/web-pet/sheet/${pose}/${i}?pet=${encodeURIComponent(pet)}&v=${p.key}`),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { available: Object.keys(poses).length > 0, poses }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
type Run = (cmd: string, args: string[]) => Promise<void>
|
|
121
|
+
const sips: Run = (cmd, args) => new Promise((resolve, reject) => {
|
|
122
|
+
execFile(cmd, args, { timeout: 30_000 }, (err) => (err ? reject(err) : resolve()))
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
export interface PetChunkOptions { cacheDir: string; run?: Run; sipsPath?: string }
|
|
126
|
+
const inflight = new Map<string, Promise<string>>()
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The PNG path for one chunk of one pose, building it on first request. Every chunk of the pose
|
|
130
|
+
* is built together, so a pose costs one resize. Concurrent requests share one build.
|
|
131
|
+
*/
|
|
132
|
+
export function petChunk(plan: PetPosePlan, index: number, opts: PetChunkOptions): Promise<string> {
|
|
133
|
+
if (!Number.isInteger(index) || index < 0 || index >= plan.chunks) return Promise.reject(new Error('no_such_chunk'))
|
|
134
|
+
const base = path.join(opts.cacheDir, `${plan.pose}-${plan.key}`)
|
|
135
|
+
const target = `${base}-${index}.png`
|
|
136
|
+
if (fs.existsSync(target)) return Promise.resolve(target)
|
|
137
|
+
let job = inflight.get(base)
|
|
138
|
+
if (!job) {
|
|
139
|
+
job = buildPose(plan, base, opts).finally(() => inflight.delete(base))
|
|
140
|
+
inflight.set(base, job)
|
|
141
|
+
}
|
|
142
|
+
return job.then(() => target)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function buildPose(plan: PetPosePlan, base: string, opts: PetChunkOptions): Promise<string> {
|
|
146
|
+
const run = opts.run ?? sips
|
|
147
|
+
const bin = opts.sipsPath ?? '/usr/bin/sips'
|
|
148
|
+
fs.mkdirSync(opts.cacheDir, { recursive: true })
|
|
149
|
+
const tmp = fs.mkdtempSync(path.join(opts.cacheDir, '.build-'))
|
|
150
|
+
try {
|
|
151
|
+
const H = plan.outHeight
|
|
152
|
+
const total = plan.frameWidth * plan.frames
|
|
153
|
+
const resized = path.join(tmp, 'resized.png')
|
|
154
|
+
await run(bin, ['-z', String(H), String(total), plan.file, '--out', resized])
|
|
155
|
+
let flipped: string | null = null
|
|
156
|
+
for (let i = 0; i < plan.chunks; i++) {
|
|
157
|
+
const x = i * plan.perChunk * plan.frameWidth
|
|
158
|
+
const w = Math.min(plan.perChunk, plan.frames - i * plan.perChunk) * plan.frameWidth
|
|
159
|
+
const out = path.join(tmp, `${i}.png`)
|
|
160
|
+
if (plan.chunks === 1) {
|
|
161
|
+
fs.copyFileSync(resized, out)
|
|
162
|
+
} else if (x === 0) {
|
|
163
|
+
// `--cropOffset 0 0` means centre to sips: cut the first chunk from the mirrored strip's far end.
|
|
164
|
+
if (!flipped) { flipped = path.join(tmp, 'flipped.png'); await run(bin, ['-f', 'horizontal', resized, '--out', flipped]) }
|
|
165
|
+
const far = path.join(tmp, `${i}-far.png`)
|
|
166
|
+
await run(bin, ['-c', String(H), String(w), '--cropOffset', '0', String(total - w), flipped, '--out', far])
|
|
167
|
+
await run(bin, ['-f', 'horizontal', far, '--out', out])
|
|
168
|
+
} else {
|
|
169
|
+
await run(bin, ['-c', String(H), String(w), '--cropOffset', '0', String(x), resized, '--out', out])
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (let i = 0; i < plan.chunks; i++) fs.renameSync(path.join(tmp, `${i}.png`), `${base}-${i}.png`)
|
|
173
|
+
// Drop this pose's older builds (a pet change on the Mac makes a new key).
|
|
174
|
+
const keep = path.basename(base) + '-'
|
|
175
|
+
for (const name of fs.readdirSync(opts.cacheDir)) {
|
|
176
|
+
if (name.startsWith(`${plan.pose}-`) && !name.startsWith(keep) && name.endsWith('.png')) fs.rmSync(path.join(opts.cacheDir, name), { force: true })
|
|
177
|
+
}
|
|
178
|
+
return base
|
|
179
|
+
} finally {
|
|
180
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Height of the first-frame previews in the pet picker. */
|
|
185
|
+
export const WEB_PET_PREVIEW_HEIGHT = 96
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* One still frame (the first frame of idle, else of the first pose this pet has) for the picker, built once
|
|
189
|
+
* with sips and cached. Uses the same flip-and-crop as the first chunk, since sips reads offset 0 as centre.
|
|
190
|
+
*/
|
|
191
|
+
export function petPreview(dir: string, cacheDir: string, opts: { run?: Run; sipsPath?: string } = {}): Promise<string> {
|
|
192
|
+
const sources = readPetSources(dir)
|
|
193
|
+
const src = sources.idle ?? WEB_PET_POSES.map((p) => sources[p]).find(Boolean)
|
|
194
|
+
if (!src) return Promise.reject(new Error('no_pet_art'))
|
|
195
|
+
const plan = planPose(src, WEB_PET_PREVIEW_HEIGHT)
|
|
196
|
+
const target = path.join(cacheDir, `preview-${plan.key}.png`)
|
|
197
|
+
if (fs.existsSync(target)) return Promise.resolve(target)
|
|
198
|
+
let job = inflight.get(target)
|
|
199
|
+
if (!job) {
|
|
200
|
+
job = (async () => {
|
|
201
|
+
const run = opts.run ?? sips
|
|
202
|
+
const bin = opts.sipsPath ?? '/usr/bin/sips'
|
|
203
|
+
fs.mkdirSync(cacheDir, { recursive: true })
|
|
204
|
+
const tmp = fs.mkdtempSync(path.join(cacheDir, '.preview-'))
|
|
205
|
+
try {
|
|
206
|
+
const H = plan.outHeight, total = plan.frameWidth * plan.frames, w = plan.frameWidth
|
|
207
|
+
const resized = path.join(tmp, 'resized.png'), out = path.join(tmp, 'out.png')
|
|
208
|
+
await run(bin, ['-z', String(H), String(total), plan.file, '--out', resized])
|
|
209
|
+
if (plan.frames === 1) {
|
|
210
|
+
fs.copyFileSync(resized, out)
|
|
211
|
+
} else {
|
|
212
|
+
const flipped = path.join(tmp, 'flipped.png'), far = path.join(tmp, 'far.png')
|
|
213
|
+
await run(bin, ['-f', 'horizontal', resized, '--out', flipped])
|
|
214
|
+
await run(bin, ['-c', String(H), String(w), '--cropOffset', '0', String(total - w), flipped, '--out', far])
|
|
215
|
+
await run(bin, ['-f', 'horizontal', far, '--out', out])
|
|
216
|
+
}
|
|
217
|
+
fs.renameSync(out, target)
|
|
218
|
+
return target
|
|
219
|
+
} finally {
|
|
220
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
221
|
+
}
|
|
222
|
+
})().finally(() => inflight.delete(target))
|
|
223
|
+
inflight.set(target, job)
|
|
224
|
+
}
|
|
225
|
+
return job
|
|
226
|
+
}
|
|
@@ -36,7 +36,14 @@ const MAX_SENDS_KEPT = 8
|
|
|
36
36
|
const MAX_STOPS_KEPT = 8
|
|
37
37
|
|
|
38
38
|
const WORK_ID = /^task:[a-z_]+:[a-f0-9]{12}$/
|
|
39
|
-
const CLIENT_ROUND_ID = /^lens-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
39
|
+
const CLIENT_ROUND_ID = /^(lens|web)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
40
|
+
/**
|
|
41
|
+
* Who made the plan: the lens, or COS Control Web (Work chat on the phone or in a browser; GET /api/work/chat/web-plans
|
|
42
|
+
* says this server takes it). The client round id starts with its source (`lens-`, `web-`). A web round is the same contract as a
|
|
43
|
+
* lens round: recorded, sent only by COS Control through its own Send, never by this server.
|
|
44
|
+
*/
|
|
45
|
+
export const ROUND_SOURCES = ['lens', 'web'] as const
|
|
46
|
+
export type RoundSource = typeof ROUND_SOURCES[number]
|
|
40
47
|
const SID = /^(claude|codex|cursor):[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
|
|
41
48
|
const ITEM_ID = /^[a-z]{1,8}(:[a-f0-9]{12})?$/
|
|
42
49
|
const RECEIPT_ID = /^[A-Za-z0-9_.:-]{1,80}$/
|
|
@@ -72,6 +79,8 @@ export interface LensRound {
|
|
|
72
79
|
/** The card's sessions (Control snapshot links and receipts) when the plan was recorded; null when no snapshot. */
|
|
73
80
|
cardSessions: string[] | null
|
|
74
81
|
sends: LensSendRequest[]; stops: LensStopRequest[]; updatedAt: number
|
|
82
|
+
/** Set only on a round COS Control Web made. A lens round has no `source`, so its stored shape is unchanged. */
|
|
83
|
+
source?: 'web'
|
|
75
84
|
/** COS Control's own Send of this round's assignments on the Mac (the lens was told to send from the Mac): when, and which. */
|
|
76
85
|
mac?: { at: number; ids: string[] } | null
|
|
77
86
|
}
|
|
@@ -89,7 +98,7 @@ const newId = (prefix: string) => `${prefix}_${randomBytes(8).toString('hex')}`
|
|
|
89
98
|
|
|
90
99
|
export interface LensRoundInput {
|
|
91
100
|
workID: string; clientRoundId: string; question: string; answer: string; planner: string | null; effort: string | null
|
|
92
|
-
orchestrator: boolean; assignments: Omit<LensAssignment, 'state'>[]; createdAt: string
|
|
101
|
+
orchestrator: boolean; assignments: Omit<LensAssignment, 'state'>[]; createdAt: string; source: RoundSource
|
|
93
102
|
}
|
|
94
103
|
|
|
95
104
|
/** The record body. Unknown keys are ignored and never stored. */
|
|
@@ -103,7 +112,8 @@ export function parseLensRound(v: unknown): LensRoundInput {
|
|
|
103
112
|
if (v.planner !== undefined && !(WORK_CHAT_PLANNERS as readonly unknown[]).includes(v.planner)) throw invalid('planner')
|
|
104
113
|
if (v.effort !== undefined && !(WORK_CHAT_EFFORTS as readonly unknown[]).includes(v.effort)) throw invalid('effort')
|
|
105
114
|
if (v.orchestrator !== undefined && typeof v.orchestrator !== 'boolean') throw invalid('orchestrator')
|
|
106
|
-
if (v.source
|
|
115
|
+
if (!(ROUND_SOURCES as readonly unknown[]).includes(v.source)) throw invalid('source')
|
|
116
|
+
if (!v.clientRoundId.startsWith(`${v.source}-`)) throw invalid('clientRoundId', 'The round id must start with its source.')
|
|
107
117
|
if (!iso(v.createdAt)) throw invalid('createdAt')
|
|
108
118
|
if (!Array.isArray(v.assignments) || v.assignments.length > WORK_CHAT_MAX_ASSIGNMENTS) throw invalid('assignments')
|
|
109
119
|
const assignments: LensRoundInput['assignments'] = []
|
|
@@ -122,7 +132,19 @@ export function parseLensRound(v: unknown): LensRoundInput {
|
|
|
122
132
|
...(a.contextItemIDs ? { contextItemIDs: [...new Set<string>(a.contextItemIDs)] } : {}) })
|
|
123
133
|
})
|
|
124
134
|
return { workID: v.workID, clientRoundId: v.clientRoundId, question: v.question, answer: v.answer, planner: v.planner ?? null,
|
|
125
|
-
effort: v.effort ?? null, orchestrator: v.orchestrator === true, assignments, createdAt: v.createdAt }
|
|
135
|
+
effort: v.effort ?? null, orchestrator: v.orchestrator === true, assignments, createdAt: v.createdAt, source: v.source }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A web round sent again under the same id must be the same plan. The lens keeps its replay rule (the stored round is
|
|
140
|
+
* answered, whatever the repeat carries); a web repeat with any other question, answer, planner, effort or assignment is
|
|
141
|
+
* refused, so one id can never stand for two plans. createdAt is when the plan was made and may be re-sent as it was.
|
|
142
|
+
*/
|
|
143
|
+
export function sameRoundContent(round: LensRound, input: LensRoundInput): boolean {
|
|
144
|
+
const plan = (r: { workID: string; question: string; answer: string; planner: string | null; effort: string | null; orchestrator: boolean;
|
|
145
|
+
assignments: Omit<LensAssignment, 'state'>[] }) => JSON.stringify([r.workID, r.question, r.answer, r.planner, r.effort, r.orchestrator,
|
|
146
|
+
r.assignments.map(a => [a.id, a.mode, a.sessionID ?? null, a.provider ?? null, a.prompt, a.contextItemIDs ?? null])])
|
|
147
|
+
return plan(round) === plan(input)
|
|
126
148
|
}
|
|
127
149
|
|
|
128
150
|
/** The card's sessions as COS Control's snapshot records them: confirmed links, receipt targets and fork children. */
|
|
@@ -240,6 +262,7 @@ export class LensRoundStore {
|
|
|
240
262
|
answer: input.answer, planner: input.planner, effort: input.effort, orchestrator: input.orchestrator,
|
|
241
263
|
assignments: input.assignments.map(a => ({ ...a, state: 'pending' as const })), createdAt: input.createdAt, recordedAt: now,
|
|
242
264
|
inHistory: false, historyAt: null, autoSendCard: false, cardSessions, sends: [], stops: [], updatedAt: now,
|
|
265
|
+
...(input.source === 'web' ? { source: 'web' as const } : {}),
|
|
243
266
|
}
|
|
244
267
|
this.file.rounds.push(round)
|
|
245
268
|
while (this.file.rounds.length > LENS_ROUNDS_MAX) {
|
|
@@ -320,6 +343,7 @@ export class LensRoundStore {
|
|
|
320
343
|
if (changed) this.persist()
|
|
321
344
|
const content = (r: LensRound) => ({ roundId: r.roundId, workID: r.workID, clientRoundId: r.clientRoundId, question: r.question, answer: r.answer,
|
|
322
345
|
planner: r.planner, effort: r.effort, orchestrator: r.orchestrator, createdAt: r.createdAt, recordedAt: new Date(r.recordedAt).toISOString(),
|
|
346
|
+
...(r.source === 'web' ? { source: 'web' as const } : {}),
|
|
323
347
|
assignments: r.assignments.map(a => ({ id: a.id, mode: a.mode, ...(a.sessionID ? { sessionID: a.sessionID } : {}), ...(a.provider ? { provider: a.provider } : {}),
|
|
324
348
|
prompt: a.prompt, ...(a.contextItemIDs ? { contextItemIDs: a.contextItemIDs } : {}) })) })
|
|
325
349
|
return {
|
|
@@ -432,6 +456,7 @@ export function roundView(r: LensRound) {
|
|
|
432
456
|
}
|
|
433
457
|
return {
|
|
434
458
|
roundId: r.roundId, workID: r.workID, state, inHistory: r.inHistory, autoSendCard: r.autoSendCard,
|
|
459
|
+
...(r.source === 'web' ? { source: 'web' as const } : {}),
|
|
435
460
|
assignments: r.assignments.map(a => ({ id: a.id, state: a.state, ...(a.reportedSessionID ?? a.sessionID ? { sessionID: a.reportedSessionID ?? a.sessionID } : {}),
|
|
436
461
|
...(a.receiptId ? { receiptId: a.receiptId } : {}), ...(a.detail ? { detail: a.detail } : {}) })),
|
|
437
462
|
updatedAt: new Date(r.updatedAt).toISOString(),
|
|
@@ -48,14 +48,20 @@ export const INPUT_SCAN_WAIT_MS = 20_000
|
|
|
48
48
|
|
|
49
49
|
/** What one plan input reads. The server passes the process-wide stores; a test passes its own. */
|
|
50
50
|
export interface OrchestratorParts { tags: TagIndex; usage: UsageMonitor; snapshot: () => ControlSnapshot | null }
|
|
51
|
+
export interface OrchestratorInputOptions {
|
|
52
|
+
/** Read the existing usage view only, without starting provider usage subprocesses. Default retains Work's refresh. */
|
|
53
|
+
usageMode?: 'cached'
|
|
54
|
+
}
|
|
51
55
|
|
|
52
|
-
export async function orchestratorInputFor(workID: string, meetingWorkIDs: string[], parts?: OrchestratorParts
|
|
56
|
+
export async function orchestratorInputFor(workID: string, meetingWorkIDs: string[], parts?: OrchestratorParts,
|
|
57
|
+
options: OrchestratorInputOptions = {}): Promise<OrchestratorInput> {
|
|
53
58
|
const tags = parts?.tags ?? orchestratorTagIndex()
|
|
54
59
|
const monitor = parts?.usage ?? orchestratorUsage()
|
|
55
60
|
const scan = tags.refresh().catch(() => null)
|
|
56
61
|
const scanned = await Promise.race([scan, new Promise<null>(r => setTimeout(() => r(null), INPUT_SCAN_WAIT_MS).unref?.())])
|
|
57
62
|
// On demand: the plan this input feeds may fork or start a session, so a reading older than 2 minutes is re-read.
|
|
58
|
-
|
|
63
|
+
// Ask's proposal-only adapter uses cache-only evidence; it does not own the usage probes' child lifetimes.
|
|
64
|
+
const usageViews = options.usageMode === 'cached' ? monitor.views() : await monitor.forPlan()
|
|
59
65
|
const roots = agentSessionRoots()
|
|
60
66
|
return buildOrchestratorInput(workID, meetingWorkIDs, {
|
|
61
67
|
snapshot: parts ? parts.snapshot() : orchestratorSnapshots().get(),
|