@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,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prep v1.1 (6.99.19): the durable Prep store at dataPath('prep').
|
|
3
|
+
*
|
|
4
|
+
* drafts/<draftId>.json one PrepDraft, written whole (fsync + rename, mode 0600)
|
|
5
|
+
* files/<draftId>/<fileId>.bin the original bytes, copied out of /api/media at add time (the media stage expires in 4 h)
|
|
6
|
+
* files/<draftId>/<fileId>.txt the extracted text a run and a bind read (ready files only)
|
|
7
|
+
* runs/<draftId>/<clientRequestId>.txt a run's exact prompt, saved before the job is submitted
|
|
8
|
+
* binds/<draftId>/<sessionId>/<revision>.json the exact Meeting Ask rows a bind attaches, saved before its intent
|
|
9
|
+
*
|
|
10
|
+
* Drafts are read back from disk on first use, so a restarted server (a fresh instance) serves the same drafts. Writes
|
|
11
|
+
* are synchronous inside one server process; prep-runtime.ts serializes each draft's read-modify-write. Nothing is
|
|
12
|
+
* auto-deleted in v1. A draft file that cannot be read is skipped and logged, never deleted.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from 'node:crypto'
|
|
15
|
+
import { lstatSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'
|
|
16
|
+
import { join, resolve } from 'node:path'
|
|
17
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
18
|
+
import { dataPath } from './data-dir.js'
|
|
19
|
+
import { ASK_SESSION } from './ask-session-id.js'
|
|
20
|
+
import { PREP_DRAFT_ID, PREP_FILE_ID, PREP_PROVIDERS, PREP_UUID_V4, type PrepDraft, type PrepOccurrence } from '../../shared/prep.js'
|
|
21
|
+
|
|
22
|
+
export class PrepError extends Error {
|
|
23
|
+
constructor(readonly code: string, readonly status = 400, readonly draft?: PrepDraft, readonly extra: Record<string, unknown> = {}) {
|
|
24
|
+
super(code)
|
|
25
|
+
this.name = 'PrepError'
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* v1.1 #16: "occ:v2:" + sha256("occ:v2:" + provider|account|calendarId|eventId or ""|externalId or ""|startsAt exact
|
|
31
|
+
* UTC ISO). Every part, no quantization, so distinct occurrences never coalesce. Finds a draft; never binds one.
|
|
32
|
+
*/
|
|
33
|
+
export function prepOccurrenceKey(o: Pick<PrepOccurrence, 'provider' | 'account' | 'calendarId' | 'eventId' | 'externalId' | 'startsAt'>): string {
|
|
34
|
+
const text = 'occ:v2:' + [o.provider, o.account ?? '', o.calendarId, o.eventId ?? '', o.externalId ?? '', new Date(Date.parse(o.startsAt)).toISOString()].join('|')
|
|
35
|
+
return 'occ:v2:' + createHash('sha256').update(text).digest('hex')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The provider identity, or null. All of provider, calendarId, eventId or externalId, and startsAt must be present
|
|
40
|
+
* (account is optional and part of the key); anything less is no identity at all, never a guess from title or time.
|
|
41
|
+
* A client-sent `key` is ignored and recomputed. A non-object is a 400.
|
|
42
|
+
*/
|
|
43
|
+
export function parsePrepOccurrence(raw: unknown): PrepOccurrence | null {
|
|
44
|
+
if (raw === null) return null
|
|
45
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new PrepError('invalid_occurrence')
|
|
46
|
+
const o = raw as Record<string, unknown>
|
|
47
|
+
const id = (v: unknown) => typeof v === 'string' && v.trim() && v.length <= 512 ? v : null
|
|
48
|
+
const iso = (v: unknown) => typeof v === 'string' && v.length <= 64 && Number.isFinite(Date.parse(v)) ? new Date(Date.parse(v)).toISOString() : null
|
|
49
|
+
const provider = PREP_PROVIDERS.includes(o.provider as never) ? o.provider as PrepOccurrence['provider'] : null
|
|
50
|
+
const account = id(o.account), calendarId = id(o.calendarId), eventId = id(o.eventId), externalId = id(o.externalId)
|
|
51
|
+
const startsAt = iso(o.startsAt)
|
|
52
|
+
if (!provider || !calendarId || (!eventId && !externalId) || !startsAt) return null
|
|
53
|
+
const base = { provider, account, calendarId, eventId, externalId, startsAt }
|
|
54
|
+
return { key: prepOccurrenceKey(base), ...base, endsAt: iso(o.endsAt) }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The loader skips a draft file larger than this; writes stop well short of it. */
|
|
58
|
+
export const DRAFT_LOAD_BYTES = 5_000_000
|
|
59
|
+
export const DRAFT_WRITE_BYTES = 4_000_000
|
|
60
|
+
|
|
61
|
+
export class PrepStore {
|
|
62
|
+
readonly root: string
|
|
63
|
+
private cache: Map<string, PrepDraft> | null = null
|
|
64
|
+
|
|
65
|
+
constructor(root = dataPath('prep')) { this.root = resolve(root) }
|
|
66
|
+
|
|
67
|
+
private dir(...parts: string[]): string {
|
|
68
|
+
mkdirSync(this.root, { recursive: true, mode: 0o700 })
|
|
69
|
+
if (lstatSync(this.root).isSymbolicLink()) throw new PrepError('prep_store_unsafe', 503)
|
|
70
|
+
let p = this.root
|
|
71
|
+
for (const part of parts) {
|
|
72
|
+
p = join(p, part)
|
|
73
|
+
mkdirSync(p, { recursive: true, mode: 0o700 })
|
|
74
|
+
if (lstatSync(p).isSymbolicLink()) throw new PrepError('prep_store_unsafe', 503)
|
|
75
|
+
}
|
|
76
|
+
return p
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private load(): Map<string, PrepDraft> {
|
|
80
|
+
if (this.cache) return this.cache
|
|
81
|
+
const drafts = new Map<string, PrepDraft>()
|
|
82
|
+
const dir = this.dir('drafts')
|
|
83
|
+
for (const name of readdirSync(dir)) {
|
|
84
|
+
if (!name.endsWith('.json')) continue
|
|
85
|
+
const id = name.slice(0, -5)
|
|
86
|
+
try {
|
|
87
|
+
const path = join(dir, name), st = lstatSync(path)
|
|
88
|
+
if (!PREP_DRAFT_ID.test(id) || !st.isFile() || st.size > DRAFT_LOAD_BYTES) throw new Error('unsafe')
|
|
89
|
+
const draft = JSON.parse(readFileSync(path, 'utf8')) as PrepDraft
|
|
90
|
+
if (draft?.id !== id || !Number.isSafeInteger(draft.revision) || !Array.isArray(draft.files)) throw new Error('shape')
|
|
91
|
+
draft.snapshots ??= []
|
|
92
|
+
draft.fileOperations ??= []
|
|
93
|
+
drafts.set(id, draft)
|
|
94
|
+
} catch {
|
|
95
|
+
// Kept on disk for a person to look at; it simply is not served.
|
|
96
|
+
console.warn(`[prep] skipped an unreadable draft file (${id.slice(0, 12)})`)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
this.cache = drafts
|
|
100
|
+
return drafts
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
get(id: string): PrepDraft | undefined {
|
|
104
|
+
if (!PREP_DRAFT_ID.test(id)) return undefined
|
|
105
|
+
const d = this.load().get(id)
|
|
106
|
+
return d ? structuredClone(d) : undefined
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
list(): PrepDraft[] { return [...this.load().values()].map(d => structuredClone(d)) }
|
|
110
|
+
|
|
111
|
+
byClientDraftId(clientDraftId: string): PrepDraft | undefined {
|
|
112
|
+
for (const d of this.load().values()) if (d.clientDraftId === clientDraftId) return structuredClone(d)
|
|
113
|
+
return undefined
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
write(draft: PrepDraft): PrepDraft {
|
|
117
|
+
if (!PREP_DRAFT_ID.test(draft.id)) throw new PrepError('invalid_draft_id')
|
|
118
|
+
const encoded = JSON.stringify(draft)
|
|
119
|
+
// Never write a draft the loader would refuse: it would vanish on the next restart.
|
|
120
|
+
if (Buffer.byteLength(encoded, 'utf8') > DRAFT_WRITE_BYTES) throw new PrepError('prep_draft_too_large', 507)
|
|
121
|
+
try {
|
|
122
|
+
durableAtomicWriteFileSync(join(this.dir('drafts'), draft.id + '.json'), encoded, { mode: 0o600 })
|
|
123
|
+
} catch (e) {
|
|
124
|
+
if (e instanceof PrepError) throw e
|
|
125
|
+
throw new PrepError('prep_write_failed', 507)
|
|
126
|
+
}
|
|
127
|
+
this.load().set(draft.id, structuredClone(draft))
|
|
128
|
+
return structuredClone(draft)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private blobPath(draftId: string, fileId: string, ext: 'bin' | 'txt'): string {
|
|
132
|
+
if (!PREP_DRAFT_ID.test(draftId) || !PREP_FILE_ID.test(fileId)) throw new PrepError('invalid_file_id')
|
|
133
|
+
return join(this.dir('files', draftId), `${fileId}.${ext}`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
putOriginal(draftId: string, fileId: string, bytes: Buffer): void {
|
|
137
|
+
try { durableAtomicWriteFileSync(this.blobPath(draftId, fileId, 'bin'), bytes, { mode: 0o600 }) } catch (e) {
|
|
138
|
+
if (e instanceof PrepError) throw e
|
|
139
|
+
throw new PrepError('prep_write_failed', 507)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
putText(draftId: string, fileId: string, text: string): void {
|
|
144
|
+
try { durableAtomicWriteFileSync(this.blobPath(draftId, fileId, 'txt'), text, { mode: 0o600 }) } catch (e) {
|
|
145
|
+
if (e instanceof PrepError) throw e
|
|
146
|
+
throw new PrepError('prep_write_failed', 507)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
readOriginal(draftId: string, fileId: string): Buffer {
|
|
151
|
+
try { return readFileSync(this.blobPath(draftId, fileId, 'bin')) } catch (e) {
|
|
152
|
+
if (e instanceof PrepError) throw e
|
|
153
|
+
throw new PrepError('file_not_found', 404)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The stored text, or '' when the file has none (not ready, or never extracted). */
|
|
158
|
+
readText(draftId: string, fileId: string): string {
|
|
159
|
+
try { return readFileSync(this.blobPath(draftId, fileId, 'txt'), 'utf8') } catch (e) {
|
|
160
|
+
if (e instanceof PrepError) throw e
|
|
161
|
+
return ''
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The exact prompt of a run, saved before its job is submitted so a retry after a crash resubmits the same bytes. */
|
|
166
|
+
putPrompt(draftId: string, clientRequestId: string, prompt: string): void {
|
|
167
|
+
try { durableAtomicWriteFileSync(this.promptPath(draftId, clientRequestId), prompt, { mode: 0o600 }) } catch (e) {
|
|
168
|
+
if (e instanceof PrepError) throw e
|
|
169
|
+
throw new PrepError('prep_write_failed', 507)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
readPrompt(draftId: string, clientRequestId: string): string | null {
|
|
174
|
+
try { return readFileSync(this.promptPath(draftId, clientRequestId), 'utf8') } catch (e) {
|
|
175
|
+
if (e instanceof PrepError) throw e
|
|
176
|
+
return null
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private promptPath(draftId: string, clientRequestId: string): string {
|
|
181
|
+
if (!PREP_DRAFT_ID.test(draftId) || !PREP_UUID_V4.test(clientRequestId)) throw new PrepError('invalid_client_request_id')
|
|
182
|
+
return join(this.dir('runs', draftId), `${clientRequestId}.txt`)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The exact Meeting Ask rows one bind attaches, saved before the binding intent, so a retry after a crash re-applies
|
|
187
|
+
* the frozen revision rather than whatever the draft holds by then.
|
|
188
|
+
*/
|
|
189
|
+
putBindPayload(draftId: string, sessionId: string, revision: number, rows: unknown[]): void {
|
|
190
|
+
try { durableAtomicWriteFileSync(this.bindPath(draftId, sessionId, revision), JSON.stringify(rows), { mode: 0o600 }) } catch (e) {
|
|
191
|
+
if (e instanceof PrepError) throw e
|
|
192
|
+
throw new PrepError('prep_write_failed', 507)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
readBindPayload(draftId: string, sessionId: string, revision: number): unknown[] | null {
|
|
197
|
+
try {
|
|
198
|
+
const rows = JSON.parse(readFileSync(this.bindPath(draftId, sessionId, revision), 'utf8'))
|
|
199
|
+
return Array.isArray(rows) ? rows : null
|
|
200
|
+
} catch (e) {
|
|
201
|
+
if (e instanceof PrepError) throw e
|
|
202
|
+
return null
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Keyed by (draft, meeting, revision): a replay of one bind can never read another bind's rows. */
|
|
207
|
+
private bindPath(draftId: string, sessionId: string, revision: number): string {
|
|
208
|
+
if (!PREP_DRAFT_ID.test(draftId) || !ASK_SESSION.test(sessionId) || !Number.isSafeInteger(revision) || revision < 0) throw new PrepError('invalid_session_id')
|
|
209
|
+
return join(this.dir('binds', draftId, sessionId), `${revision}.json`)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Bytes on disk under the store root (drafts, originals, text). Walked on each add: at most 300 drafts x 10 files. */
|
|
213
|
+
usageBytes(): number {
|
|
214
|
+
let total = 0
|
|
215
|
+
const walk = (p: string) => {
|
|
216
|
+
for (const name of readdirSync(p)) {
|
|
217
|
+
const child = join(p, name), st = lstatSync(child)
|
|
218
|
+
if (st.isSymbolicLink()) continue
|
|
219
|
+
if (st.isDirectory()) walk(child)
|
|
220
|
+
else if (st.isFile()) total += st.size
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
walk(this.dir())
|
|
224
|
+
return total
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -17,6 +17,13 @@
|
|
|
17
17
|
// holds a profile vocabulary term the primary's side does not, the second
|
|
18
18
|
// opinion's words are taken.
|
|
19
19
|
//
|
|
20
|
+
// A third caller (6.99.18) is the saved iPhone transcript, where the second
|
|
21
|
+
// side is the live Whisper words the phone already heard with the vocabulary
|
|
22
|
+
// prompt. Those words have approximate clocks and a prompted Whisper can echo a
|
|
23
|
+
// vocabulary term into a pause, so that caller sets anchoredVocabularyOnly: a
|
|
24
|
+
// term is taken only where the primary heard other words in the same place,
|
|
25
|
+
// and the taken words are clocked across the primary words they replace.
|
|
26
|
+
//
|
|
20
27
|
// Everything else stays as the primary heard it. No region whose two sides
|
|
21
28
|
// differ in a negation, a number or a unit is ever changed, whatever its length
|
|
22
29
|
// (the 2026-10-13 diagnostic saw "born lacking" become "born having" and
|
|
@@ -37,7 +44,7 @@ export interface Hypothesis {
|
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
export type DisagreementKind = 'substitution' | 'primary_only' | 'second_only'
|
|
40
|
-
export type ResolutionReason = 'gap_fill' | 'vocabulary' | 'protected' | 'not_speech' | 'low_confidence' | 'too_wide' | null
|
|
47
|
+
export type ResolutionReason = 'gap_fill' | 'vocabulary' | 'protected' | 'not_speech' | 'low_confidence' | 'too_wide' | 'unanchored' | null
|
|
41
48
|
|
|
42
49
|
export interface Disagreement {
|
|
43
50
|
kind: DisagreementKind
|
|
@@ -66,6 +73,10 @@ export interface ResolveOptions {
|
|
|
66
73
|
* 10 s) made the resolver worse than plain text alignment, because Large-v3's
|
|
67
74
|
* remapped token clocks have a heavy tail (median skew 0.23 s, p90 1.0 s). */
|
|
68
75
|
maxSkewSec?: number | null
|
|
76
|
+
/** The second side's clocks are approximate (live words). A vocabulary term is
|
|
77
|
+
* taken only from a substitution, never an insertion the primary has nothing
|
|
78
|
+
* for, and the taken words are clocked across the primary words they replace. */
|
|
79
|
+
anchoredVocabularyOnly?: boolean
|
|
69
80
|
}
|
|
70
81
|
|
|
71
82
|
export interface ResolveResult {
|
|
@@ -241,6 +252,15 @@ function gapVerdict(q: Token[], options: ResolveOptions): ResolutionReason {
|
|
|
241
252
|
return 'gap_fill'
|
|
242
253
|
}
|
|
243
254
|
|
|
255
|
+
/** The second side's words spread evenly over the time the replaced primary words took. */
|
|
256
|
+
function reclock(q: Token[], p: Token[]): Token[] {
|
|
257
|
+
const { start, end } = span(p)
|
|
258
|
+
if (start === null) return q.map(t => ({ ...t, start: null, end: null }))
|
|
259
|
+
const stop = Math.max(start, end ?? start)
|
|
260
|
+
const step = (stop - start) / q.length
|
|
261
|
+
return q.map((t, i) => ({ ...t, start: start + i * step, end: start + (i + 1) * step }))
|
|
262
|
+
}
|
|
263
|
+
|
|
244
264
|
export function resolveHypotheses(primary: Hypothesis, second: Hypothesis, options: ResolveOptions): ResolveResult {
|
|
245
265
|
const P = tokens(primary), Q = tokens(second)
|
|
246
266
|
const toWords = (ts: Token[]): HypWord[] => ts.map(t => ({
|
|
@@ -290,6 +310,7 @@ export function resolveHypotheses(primary: Hypothesis, second: Hypothesis, optio
|
|
|
290
310
|
const wantVocab = newTerms.length > 0
|
|
291
311
|
if (wantGap || wantVocab) {
|
|
292
312
|
if (!sameProtected(p, q)) reason = 'protected'
|
|
313
|
+
else if (wantVocab && options.anchoredVocabularyOnly && !p.length) reason = 'unanchored'
|
|
293
314
|
else if (wantVocab && (q.length > MAX_VOCAB_REGION_WORDS || p.length > q.length + MAX_VOCAB_REGION_SLACK)) {
|
|
294
315
|
// A term inside a long disagreement is not a reason to swap the whole region.
|
|
295
316
|
reason = wantGap ? gapVerdict(q, options) : 'too_wide'
|
|
@@ -308,7 +329,10 @@ export function resolveHypotheses(primary: Hypothesis, second: Hypothesis, optio
|
|
|
308
329
|
taken: options.apply ? take : 'primary',
|
|
309
330
|
reason,
|
|
310
331
|
})
|
|
311
|
-
if (options.apply && take === 'second') {
|
|
332
|
+
if (options.apply && take === 'second') {
|
|
333
|
+
out.push(...(options.anchoredVocabularyOnly ? reclock(q, p) : q))
|
|
334
|
+
wordsFromSecond += q.length
|
|
335
|
+
}
|
|
312
336
|
else out.push(...p)
|
|
313
337
|
}
|
|
314
338
|
return {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
|
|
29
29
|
import { existsSync, mkdirSync, appendFileSync, readFileSync, renameSync, statSync } from 'node:fs'
|
|
30
30
|
import { dirname } from 'node:path'
|
|
31
|
+
import { isPrepCopyId } from '../../shared/prep.js'
|
|
31
32
|
import { LensGistLimitError, runLensGistEngine } from './lens-gist-engines.js'
|
|
32
33
|
import { resolveProviderBinary } from './provider-binary.js'
|
|
33
34
|
import { getVocabulary, getWhisperCorrections } from './profile.js'
|
|
@@ -167,11 +168,19 @@ const notesSearchDefault: CleanupDeps['searchNotes'] = async (queries, sessionId
|
|
|
167
168
|
})(queries, sessionId, signal)
|
|
168
169
|
}
|
|
169
170
|
|
|
171
|
+
/**
|
|
172
|
+
* The meeting files whose words may steer saved-transcript cleanup. Pasted text and read pages (paste_, web_) are
|
|
173
|
+
* article words, not the room's vocabulary, and so are Prep copies (prep_prep_..., 6.99.19): notes and documents
|
|
174
|
+
* written before the call. None of them may rewrite the saved transcript.
|
|
175
|
+
*/
|
|
176
|
+
export function cleanupVocabularyFiles(files: ReadonlyArray<{ id: string; name: string; status: string; text: string }>, isPastedFileId: (id: string) => boolean): CleanupFile[] {
|
|
177
|
+
return files.filter(f => !isPastedFileId(f.id) && !isPrepCopyId(f.id) && f.status === 'ready' && typeof f.text === 'string' && f.text.trim()).map(f => ({ name: f.name, text: f.text }))
|
|
178
|
+
}
|
|
179
|
+
|
|
170
180
|
const filesDefault: CleanupDeps['files'] = async sessionId => {
|
|
171
181
|
try {
|
|
172
182
|
const { MeetingAskStore, isPastedFileId } = await import('./meeting-ask-store.js')
|
|
173
|
-
|
|
174
|
-
return new MeetingAskStore().files(sessionId).filter(f => !isPastedFileId(f.id) && f.status === 'ready' && typeof f.text === 'string' && f.text.trim()).map(f => ({ name: f.name, text: f.text }))
|
|
183
|
+
return cleanupVocabularyFiles(new MeetingAskStore().files(sessionId), isPastedFileId)
|
|
175
184
|
} catch { return [] }
|
|
176
185
|
}
|
|
177
186
|
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fast static serving for COS Control Web (`/control/*`).
|
|
3
|
+
*
|
|
4
|
+
* Before this, `/control` went straight to `express.static`: no compression and
|
|
5
|
+
* `Cache-Control: public, max-age=0`, so every open on the phone re-asked the Mac about
|
|
6
|
+
* all 22 boot files over Tailscale and downloaded about 1.2 MB of uncompressed JS and CSS
|
|
7
|
+
* plus 770 KB of fonts whenever anything changed.
|
|
8
|
+
*
|
|
9
|
+
* What this does:
|
|
10
|
+
* - Every asset gets a content hash. `index.html` and the CSS files have their
|
|
11
|
+
* `/control/...` references rewritten to `/control/...?v=<hash>`.
|
|
12
|
+
* - A request whose `?v=` matches the current hash is cached for a year as `immutable`.
|
|
13
|
+
* Anything else (index.html, a stale or missing `?v=`) is `no-cache`, so it revalidates
|
|
14
|
+
* with its ETag and a new release is picked up on the next open.
|
|
15
|
+
* - Text assets are served brotli or gzip compressed when the browser accepts it. The
|
|
16
|
+
* compressed bytes are built on first request and kept in memory.
|
|
17
|
+
* - Files on disk are re-checked at most once per `recheckMs`, so a test copy served from
|
|
18
|
+
* a worktree picks up edits without a restart.
|
|
19
|
+
*
|
|
20
|
+
* Anything not in the table (a path this walker skips) falls through to `next()`, and the
|
|
21
|
+
* caller keeps `express.static` behind it, so no URL that worked before stops working.
|
|
22
|
+
*/
|
|
23
|
+
import crypto from 'crypto'
|
|
24
|
+
import fs from 'fs'
|
|
25
|
+
import path from 'path'
|
|
26
|
+
import zlib from 'zlib'
|
|
27
|
+
import type { NextFunction, Request, RequestHandler, Response } from 'express'
|
|
28
|
+
|
|
29
|
+
/** A versioned URL's lifetime. Safe because the hash changes whenever the bytes do. */
|
|
30
|
+
export const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable'
|
|
31
|
+
/** Unversioned and HTML responses: always revalidate, so a new release shows on the next open. */
|
|
32
|
+
export const REVALIDATE_CACHE = 'no-cache'
|
|
33
|
+
|
|
34
|
+
const TYPES: Record<string, string> = {
|
|
35
|
+
'.html': 'text/html; charset=utf-8',
|
|
36
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
37
|
+
'.css': 'text/css; charset=utf-8',
|
|
38
|
+
'.svg': 'image/svg+xml',
|
|
39
|
+
'.json': 'application/json; charset=utf-8',
|
|
40
|
+
'.ttf': 'font/ttf',
|
|
41
|
+
'.woff2': 'font/woff2',
|
|
42
|
+
'.png': 'image/png',
|
|
43
|
+
'.webp': 'image/webp',
|
|
44
|
+
'.jpg': 'image/jpeg',
|
|
45
|
+
}
|
|
46
|
+
/** Worth compressing. WOFF2 and images are already compressed. */
|
|
47
|
+
const COMPRESSIBLE = new Set(['.html', '.js', '.css', '.svg', '.json', '.ttf'])
|
|
48
|
+
/** Files whose `/control/...` references are rewritten to versioned URLs. */
|
|
49
|
+
const REWRITTEN = new Set(['.html', '.css'])
|
|
50
|
+
/** Only assets of these types get a versioned URL; HTML pages are always revalidated. */
|
|
51
|
+
const VERSIONABLE = new Set(['.js', '.css', '.svg', '.json', '.ttf', '.woff2', '.png', '.webp', '.jpg'])
|
|
52
|
+
|
|
53
|
+
export interface WebAsset {
|
|
54
|
+
/** Path under the mount, with a leading slash: `/panes/work.js`. */
|
|
55
|
+
urlPath: string
|
|
56
|
+
ext: string
|
|
57
|
+
type: string
|
|
58
|
+
body: Buffer
|
|
59
|
+
/** First 16 hex characters of the SHA-256 of `body` (after any rewrite). */
|
|
60
|
+
hash: string
|
|
61
|
+
gzip?: Promise<Buffer>
|
|
62
|
+
br?: Promise<Buffer>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type WebAssetTable = Map<string, WebAsset>
|
|
66
|
+
|
|
67
|
+
/** Dotfiles are never served. Sources (`.ts`) and maps stay out because TYPES does not list them. */
|
|
68
|
+
function skip(name: string): boolean {
|
|
69
|
+
return name.startsWith('.')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function walk(dir: string, base = ''): Array<{ urlPath: string; file: string; stat: fs.Stats }> {
|
|
73
|
+
const out: Array<{ urlPath: string; file: string; stat: fs.Stats }> = []
|
|
74
|
+
let names: string[]
|
|
75
|
+
try { names = fs.readdirSync(dir) } catch { return out }
|
|
76
|
+
for (const name of names.sort()) {
|
|
77
|
+
if (skip(name)) continue
|
|
78
|
+
const file = path.join(dir, name)
|
|
79
|
+
let stat: fs.Stats
|
|
80
|
+
try { stat = fs.statSync(file) } catch { continue }
|
|
81
|
+
const urlPath = `${base}/${name}`
|
|
82
|
+
if (stat.isDirectory()) out.push(...walk(file, urlPath))
|
|
83
|
+
else if (stat.isFile() && TYPES[path.extname(name).toLowerCase()]) out.push({ urlPath, file, stat })
|
|
84
|
+
}
|
|
85
|
+
return out
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A cheap fingerprint of the tree: every path, size and mtime. */
|
|
89
|
+
export function treeSignature(dir: string): string {
|
|
90
|
+
return walk(dir).map((f) => `${f.urlPath}:${f.stat.size}:${f.stat.mtimeMs}`).join('|')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sha(body: Buffer): string {
|
|
94
|
+
return crypto.createHash('sha256').update(body).digest('hex').slice(0, 16)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Rewrite `/control/<path>` references (quoted, or inside `url(...)`) to carry `?v=<hash>`.
|
|
99
|
+
* Only references to a known, versionable asset are touched; a reference that already has a
|
|
100
|
+
* query string, or names a file that does not exist, is left exactly as written.
|
|
101
|
+
*/
|
|
102
|
+
export function rewriteReferences(text: string, mount: string, hashes: Map<string, string>): string {
|
|
103
|
+
const prefix = mount.replace(/\/$/, '')
|
|
104
|
+
const re = new RegExp(`(["'(])${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(/[A-Za-z0-9_./-]+)(["')])`, 'g')
|
|
105
|
+
return text.replace(re, (whole, open: string, rel: string, close: string) => {
|
|
106
|
+
const hash = hashes.get(rel)
|
|
107
|
+
if (!hash || !VERSIONABLE.has(path.extname(rel).toLowerCase())) return whole
|
|
108
|
+
return `${open}${prefix}${rel}?v=${hash}${close}`
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Build the table. Leaves (JS, fonts, images) are hashed first, then CSS is rewritten against
|
|
114
|
+
* them and hashed, then HTML is rewritten against everything. So a font change gives its CSS a
|
|
115
|
+
* new hash, and that gives index.html new references.
|
|
116
|
+
*/
|
|
117
|
+
export function buildWebAssetTable(dir: string, mount = '/control'): WebAssetTable {
|
|
118
|
+
const files = walk(dir)
|
|
119
|
+
const hashes = new Map<string, string>()
|
|
120
|
+
const table: WebAssetTable = new Map()
|
|
121
|
+
const read = (file: string): Buffer | null => { try { return fs.readFileSync(file) } catch { return null } }
|
|
122
|
+
const add = (urlPath: string, ext: string, body: Buffer): void => {
|
|
123
|
+
const hash = sha(body)
|
|
124
|
+
hashes.set(urlPath, hash)
|
|
125
|
+
table.set(urlPath, { urlPath, ext, type: TYPES[ext], body, hash })
|
|
126
|
+
}
|
|
127
|
+
const pass = (pick: (ext: string) => boolean): void => {
|
|
128
|
+
for (const f of files) {
|
|
129
|
+
const ext = path.extname(f.urlPath).toLowerCase()
|
|
130
|
+
if (!pick(ext)) continue
|
|
131
|
+
const raw = read(f.file)
|
|
132
|
+
if (!raw) continue
|
|
133
|
+
const body = REWRITTEN.has(ext) ? Buffer.from(rewriteReferences(raw.toString('utf8'), mount, hashes), 'utf8') : raw
|
|
134
|
+
add(f.urlPath, ext, body)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
pass((ext) => !REWRITTEN.has(ext))
|
|
138
|
+
pass((ext) => ext === '.css')
|
|
139
|
+
pass((ext) => ext === '.html')
|
|
140
|
+
return table
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Which encoding to send: brotli, then gzip, then none. `q=0` refuses an encoding. */
|
|
144
|
+
export function pickEncoding(acceptEncoding: string | undefined): 'br' | 'gzip' | null {
|
|
145
|
+
const accepted = new Map<string, number>()
|
|
146
|
+
for (const part of String(acceptEncoding || '').toLowerCase().split(',')) {
|
|
147
|
+
const [name, ...params] = part.trim().split(';')
|
|
148
|
+
if (!name) continue
|
|
149
|
+
const q = params.map((p) => p.trim()).find((p) => p.startsWith('q='))
|
|
150
|
+
accepted.set(name.trim(), q ? Number(q.slice(2)) || 0 : 1)
|
|
151
|
+
}
|
|
152
|
+
const ok = (name: string): boolean => (accepted.get(name) ?? accepted.get('*') ?? 0) > 0
|
|
153
|
+
if (ok('br')) return 'br'
|
|
154
|
+
if (ok('gzip')) return 'gzip'
|
|
155
|
+
return null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function compressed(asset: WebAsset, encoding: 'br' | 'gzip'): Promise<Buffer> {
|
|
159
|
+
if (encoding === 'br') {
|
|
160
|
+
asset.br ??= new Promise((resolve, reject) => zlib.brotliCompress(asset.body, {
|
|
161
|
+
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 9, [zlib.constants.BROTLI_PARAM_SIZE_HINT]: asset.body.length },
|
|
162
|
+
}, (err, out) => (err ? reject(err) : resolve(out))))
|
|
163
|
+
return asset.br
|
|
164
|
+
}
|
|
165
|
+
asset.gzip ??= new Promise((resolve, reject) => zlib.gzip(asset.body, { level: 9 }, (err, out) => (err ? reject(err) : resolve(out))))
|
|
166
|
+
return asset.gzip
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface WebControlStaticOptions {
|
|
170
|
+
mount?: string
|
|
171
|
+
/** How often the tree is re-checked for edits. 0 checks on every request. */
|
|
172
|
+
recheckMs?: number
|
|
173
|
+
now?: () => number
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function createWebControlStatic(dir: string, opts: WebControlStaticOptions = {}): RequestHandler & { table: () => WebAssetTable } {
|
|
177
|
+
const mount = opts.mount ?? '/control'
|
|
178
|
+
const recheckMs = opts.recheckMs ?? 1000
|
|
179
|
+
const now = opts.now ?? Date.now
|
|
180
|
+
let signature = treeSignature(dir)
|
|
181
|
+
let table = buildWebAssetTable(dir, mount)
|
|
182
|
+
let checkedAt = now()
|
|
183
|
+
|
|
184
|
+
const current = (): WebAssetTable => {
|
|
185
|
+
if (now() - checkedAt >= recheckMs) {
|
|
186
|
+
checkedAt = now()
|
|
187
|
+
const next = treeSignature(dir)
|
|
188
|
+
if (next !== signature) { signature = next; table = buildWebAssetTable(dir, mount) }
|
|
189
|
+
}
|
|
190
|
+
return table
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const handler = (req: Request, res: Response, next: NextFunction): void => {
|
|
194
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') { next(); return }
|
|
195
|
+
let urlPath = req.path || '/'
|
|
196
|
+
if (urlPath.endsWith('/')) urlPath += 'index.html'
|
|
197
|
+
const asset = current().get(urlPath)
|
|
198
|
+
if (!asset) { next(); return }
|
|
199
|
+
|
|
200
|
+
const versioned = VERSIONABLE.has(asset.ext) && req.query.v === asset.hash
|
|
201
|
+
const encoding = COMPRESSIBLE.has(asset.ext) ? pickEncoding(req.headers['accept-encoding'] as string | undefined) : null
|
|
202
|
+
const etag = `"${asset.hash}${encoding ? `-${encoding}` : ''}"`
|
|
203
|
+
res.setHeader('Content-Type', asset.type)
|
|
204
|
+
res.setHeader('Cache-Control', versioned ? IMMUTABLE_CACHE : REVALIDATE_CACHE)
|
|
205
|
+
res.setHeader('ETag', etag)
|
|
206
|
+
if (COMPRESSIBLE.has(asset.ext)) res.setHeader('Vary', 'Accept-Encoding')
|
|
207
|
+
|
|
208
|
+
const inm = String(req.headers['if-none-match'] || '')
|
|
209
|
+
if (inm && inm.split(',').map((t) => t.trim().replace(/^W\//, '')).includes(etag)) { res.status(304).end(); return }
|
|
210
|
+
|
|
211
|
+
const send = (body: Buffer): void => {
|
|
212
|
+
if (encoding) res.setHeader('Content-Encoding', encoding)
|
|
213
|
+
res.setHeader('Content-Length', String(body.length))
|
|
214
|
+
if (req.method === 'HEAD') res.end(); else res.end(body)
|
|
215
|
+
}
|
|
216
|
+
if (!encoding) { send(asset.body); return }
|
|
217
|
+
compressed(asset, encoding).then(send, () => {
|
|
218
|
+
// Compression failed: send the plain bytes rather than nothing.
|
|
219
|
+
res.removeHeader('Content-Encoding')
|
|
220
|
+
res.setHeader('ETag', `"${asset.hash}"`)
|
|
221
|
+
res.setHeader('Content-Length', String(asset.body.length))
|
|
222
|
+
if (req.method === 'HEAD') res.end(); else res.end(asset.body)
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
return Object.assign(handler, { table: current })
|
|
226
|
+
}
|
|
@@ -306,14 +306,15 @@ export async function dispatchMemoriesWebOp(
|
|
|
306
306
|
return r.status === 200 ? reply(true, 'Duplicates ready', r.body) : reply(false, r.body.message || r.body.error || 'Failed')
|
|
307
307
|
}
|
|
308
308
|
case 'cues.summary': {
|
|
309
|
-
//
|
|
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', {})
|