@gotcos/glasses-server 6.99.12 → 6.99.20
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 +124 -0
- package/package.json +1 -1
- package/server/index.ts +44 -2
- package/server/lib/ask-work-plan.ts +116 -0
- package/server/lib/batch-transcript-quality.ts +2 -0
- package/server/lib/cos-thread.ts +582 -0
- package/server/lib/drop-attachments.ts +55 -0
- package/server/lib/drop-service.ts +104 -13
- package/server/lib/drop-settings.ts +13 -2
- package/server/lib/drop-store.ts +38 -2
- package/server/lib/media-store.ts +80 -0
- package/server/lib/meeting-ask-runtime.ts +1 -1
- package/server/lib/meeting-batch-transcribe.ts +54 -2
- package/server/lib/permission-broker.ts +6 -4
- package/server/lib/phone-recording.ts +1 -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/work-orchestrator/runtime.ts +8 -2
- package/server/routes/ask-work-plan.ts +114 -0
- package/server/routes/cos-thread.ts +79 -0
- package/server/routes/drops.ts +4 -2
- package/server/routes/health.ts +8 -0
- package/server/routes/permission-broker.ts +2 -2
- package/server/routes/prep.ts +81 -0
- package/server/routes/transcribe-stream.ts +9 -1
- package/server/routes/welcome-context.ts +29 -1
- package/server/routes/work-orchestrator.ts +57 -0
- package/server/scripts/web-live-cls/cdp.mjs +57 -0
- package/server/scripts/web-live-cls/measure.mjs +80 -0
- package/server/scripts/web-live-cls/mock.mjs +97 -0
- package/server/scripts/web-live-cls/render.mjs +26 -0
- package/server/web-control/control.css +13 -0
- package/server/web-control/panes/cos-chat.css +39 -0
- package/server/web-control/panes/cos-chat.js +799 -41
- package/server/web-control/panes/meetings.css +91 -4
- package/server/web-control/panes/meetings.js +585 -58
- package/server/web-control/panes/messages.js +25 -18
- package/shared/meeting-ask.ts +6 -2
- package/shared/prep.ts +317 -0
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* a Messages row or moves the lens message counter (canary 11).
|
|
14
14
|
*/
|
|
15
15
|
import { randomBytes } from 'node:crypto'
|
|
16
|
-
import { DROP_DESTS, DROP_ID, DROP_LIMITS, DropStoreError, parseLink, type DropChoice, type DropDest, type DropKind, type DropLink,
|
|
17
|
-
type DropSource, type DropFileRef, type FeedAction, type FeedItem, type DropStore, type InboxFeedStore } from './drop-store.js'
|
|
16
|
+
import { ATTACHMENT_ID, DROP_DESTS, DROP_ID, DROP_LIMITS, DropStoreError, parseLink, type DropAttachment, type DropChoice, type DropDest, type DropKind, type DropLink,
|
|
17
|
+
type DropRecord, type DropSource, type DropFileRef, type FeedAction, type FeedItem, type DropStore, type InboxFeedStore } from './drop-store.js'
|
|
18
18
|
import { choiceFor, cleanDropForJev, DROP_ROUTER_LIMITS, ruleRoute, type DropRouter, type DropTarget, type RouteOutcome } from './drop-router.js'
|
|
19
19
|
import { DropDestinationError, type DropWriters } from './drop-destinations.js'
|
|
20
20
|
import { parseWhen, whenLabel, whenSuggestions, WHEN_MAX_AHEAD_MS } from './drop-when.js'
|
|
@@ -31,16 +31,29 @@ export interface DropInput {
|
|
|
31
31
|
source: DropSource
|
|
32
32
|
hint?: { sessionId: string; label?: string }
|
|
33
33
|
file?: DropFileRef
|
|
34
|
+
/** 6.99.13: up to five fresh uploads (POST /api/media, /api/media/file) to keep with the drop. Words are still required. */
|
|
35
|
+
attachmentIds?: string[]
|
|
34
36
|
}
|
|
35
37
|
export interface DropView {
|
|
36
38
|
id: string; text: string; source: DropSource; createdAt: string; updatedAt: string
|
|
37
39
|
state: DropRecord['state']; dest: DropDest | null; kind: DropKind; status: HistoryStatus
|
|
38
40
|
links: DropLink[]; choices?: DropChoice[]; route: DropRecord['route']; undoUntil: string | null
|
|
39
|
-
canUndo: boolean; hold?: DropRecord['hold']; remind?: DropRecord['remind']; file?: DropFileRef
|
|
41
|
+
canUndo: boolean; hold?: DropRecord['hold']; remind?: DropRecord['remind']; file?: DropFileRef; attachments?: DropAttachment[]
|
|
40
42
|
}
|
|
41
|
-
|
|
43
|
+
/** A drop read as a question: the client runs the Ask with these words, and the drop's files when it has any. */
|
|
44
|
+
export interface DropNextAsk { kind: 'ask'; prompt: string; attachmentIds?: string[] }
|
|
45
|
+
export interface DropAnswer { drop: DropView; items: FeedItem[]; replayed: boolean; next?: DropNextAsk }
|
|
42
46
|
export type HistoryStatus = 'open' | 'in_progress' | 'done' | 'undone'
|
|
43
47
|
export interface AskLookup { status: string; response?: string; sessionId?: string }
|
|
48
|
+
/**
|
|
49
|
+
* 6.99.13: where a drop's files live (lib/drop-attachments.ts, the media store). `pin` binds fresh uploads to the drop
|
|
50
|
+
* so they outlive the upload TTL, all or nothing, and throws a DropError that says what to do when one cannot be kept.
|
|
51
|
+
* `unpin` removes the bytes this drop pinned (Undo).
|
|
52
|
+
*/
|
|
53
|
+
export interface DropAttachmentPins {
|
|
54
|
+
pin(ids: string[], dropId: string): Promise<DropAttachment[]>
|
|
55
|
+
unpin(ids: string[], dropId: string): Promise<number>
|
|
56
|
+
}
|
|
44
57
|
|
|
45
58
|
export interface DropServiceDeps {
|
|
46
59
|
drops: DropStore
|
|
@@ -53,16 +66,36 @@ export interface DropServiceDeps {
|
|
|
53
66
|
timezone?: () => string
|
|
54
67
|
/** 6.99.11 settings: the reminders' default time and snooze length. Absent: 9:00 and an hour. */
|
|
55
68
|
p2Settings?: () => P2Settings
|
|
69
|
+
/** 6.99.13: files on a drop. Absent: a drop with attachmentIds is refused (attachments_unsupported). */
|
|
70
|
+
attachments?: DropAttachmentPins
|
|
56
71
|
}
|
|
57
72
|
|
|
58
73
|
const TEXT_MAX = DROP_LIMITS.text
|
|
59
74
|
const ASK_WINDOW_MS = 6 * 60 * 60_000
|
|
60
75
|
const ANSWER_CHARS = 1_200
|
|
76
|
+
/** The pinned row's line: the item's title, then its line, but never the title twice (6.99.15: a follow-up's line
|
|
77
|
+
* already names its card, so "X. 2 follow-ups from X." read as a stutter). A drop shows only its line. */
|
|
78
|
+
export function pinLine(item: { kind: string; title?: string | null; line: string }): string {
|
|
79
|
+
const title = String(item.title ?? '').trim()
|
|
80
|
+
const line = String(item.line ?? '').trim()
|
|
81
|
+
if (!title || item.kind === 'drop') return line
|
|
82
|
+
if (!line) return title
|
|
83
|
+
const t = title.replace(/[.!?]+$/, '').toLowerCase()
|
|
84
|
+
return line.toLowerCase().includes(t) ? line : `${title.replace(/[.!?]+$/, '')}. ${line}`
|
|
85
|
+
}
|
|
86
|
+
|
|
61
87
|
const short = (text: string, max = 60): string => {
|
|
62
88
|
const one = text.replace(/\s+/g, ' ').trim()
|
|
63
89
|
return one.length <= max ? one : one.slice(0, max - 1).trimEnd() + '…'
|
|
64
90
|
}
|
|
65
91
|
const feedId = () => `fi_${randomBytes(16).toString('hex')}`
|
|
92
|
+
/** A drop's files as chat links (kind `file`, the media id), so the chat can open each one. */
|
|
93
|
+
const fileLinks = (attachments: DropAttachment[] | undefined): DropLink[] =>
|
|
94
|
+
(attachments ?? []).map(a => ({ kind: 'file' as const, id: a.id, label: a.name.slice(0, DROP_LIMITS.label) }))
|
|
95
|
+
/** Where a drop's files are, said on its receipts: the destinations take words only. */
|
|
96
|
+
const filesLine = (n: number): string => n === 1 ? 'The file stays here in the COS chat with your words.' : `The ${n} files stay here in the COS chat with your words.`
|
|
97
|
+
const nextAsk = (drop: DropRecord): DropNextAsk =>
|
|
98
|
+
({ kind: 'ask', prompt: drop.text, ...(drop.attachments?.length ? { attachmentIds: drop.attachments.map(a => a.id) } : {}) })
|
|
66
99
|
/** One item that waits on an answer: a live question, or a live item marked needsReply (6.99.10). */
|
|
67
100
|
export const isWaiting = (i: FeedItem): boolean => i.state === 'live' && (i.kind === 'question' || i.needsReply === true)
|
|
68
101
|
const A = {
|
|
@@ -114,15 +147,23 @@ export class DropService {
|
|
|
114
147
|
}
|
|
115
148
|
|
|
116
149
|
private async run(input: DropInput): Promise<DropAnswer> {
|
|
150
|
+
// Files first (6.99.13): they are pinned to the drop before it is saved, so a saved drop never points at an upload
|
|
151
|
+
// the store could still expire. A file that cannot be kept refuses the whole drop and nothing is saved.
|
|
152
|
+
let attachments: DropAttachment[] = []
|
|
153
|
+
if (input.attachmentIds?.length) {
|
|
154
|
+
if (!this.deps.attachments) throw new DropError('attachments_unsupported', 400, 'This Mac takes words only on a drop. Ask sends the files, or remove them to drop.')
|
|
155
|
+
attachments = await this.deps.attachments.pin(input.attachmentIds, input.dropId)
|
|
156
|
+
}
|
|
117
157
|
const at = this.now().toISOString()
|
|
118
158
|
const fresh: DropRecord = {
|
|
119
159
|
id: input.dropId, text: input.text, source: input.source, createdAt: at, updatedAt: at, state: 'routing', dest: null,
|
|
120
160
|
kind: input.file ? 'file' : 'note', links: [], route: null, undoUntil: null, timeline: [{ at, state: 'routing' }],
|
|
121
161
|
...(input.hint ? { hint: { sessionId: input.hint.sessionId } } : {}), ...(input.file ? { file: input.file } : {}),
|
|
162
|
+
...(attachments.length ? { attachments } : {}),
|
|
122
163
|
}
|
|
123
164
|
this.deps.drops.insert(fresh)
|
|
124
165
|
this.deps.feed.insert([{ id: feedId(), kind: 'drop', threadId: fresh.id, createdAt: at, readAt: at, source: input.source,
|
|
125
|
-
line: input.text, links:
|
|
166
|
+
line: input.text, links: fileLinks(attachments), actions: [], state: 'live', key: `${fresh.id}:drop` }])
|
|
126
167
|
// Before any rule (plan section 8): a drop that looks like it holds a password or key goes nowhere but the drop
|
|
127
168
|
// store. Not to memory (the bridge embeds it), not to the Ask job, not to Jev.
|
|
128
169
|
if (cleanDropForJev(input.text).tripped) return this.file(fresh, 'note', undefined, { by: 'fallback', reason: 'redacted' })
|
|
@@ -155,8 +196,8 @@ export class DropService {
|
|
|
155
196
|
const at = this.now().toISOString()
|
|
156
197
|
const saved = this.deps.drops.update(drop.id, d => ({ ...d, state: 'asked', dest: 'ask', kind: 'ask', route: { by: 'rule' },
|
|
157
198
|
undoUntil: null, timeline: [...d.timeline, { at, state: 'asked', dest: 'ask' }] }))
|
|
158
|
-
this.receipt(saved, 'receipt', 'Asking COS', 'The answer lands here when it is ready.', [], [], 1)
|
|
159
|
-
return { ...this.answer(saved, false), next:
|
|
199
|
+
this.receipt(saved, 'receipt', 'Asking COS', saved.attachments?.length ? 'The answer lands here when it is ready. The files go with the question.' : 'The answer lands here when it is ready.', [], [], 1)
|
|
200
|
+
return { ...this.answer(saved, false), next: nextAsk(saved) }
|
|
160
201
|
}
|
|
161
202
|
|
|
162
203
|
private question(drop: DropRecord, choices: DropChoice[], route: DropRecord['route'], clear: number): DropAnswer {
|
|
@@ -263,6 +304,10 @@ export class DropService {
|
|
|
263
304
|
|
|
264
305
|
private receipt(drop: DropRecord, kind: 'receipt' | 'question' | 'followup', title: string, line: string, links: DropLink[], actions: FeedAction[], generation: number, needsReply = false): FeedItem {
|
|
265
306
|
const at = this.now().toISOString()
|
|
307
|
+
// 6.99.13: a drop's files ride on every receipt and question it gets, each one openable, with a line saying where
|
|
308
|
+
// they are (no destination holds a file: a card, a thread note and a memory take words).
|
|
309
|
+
const files = kind === 'followup' ? [] : fileLinks(drop.attachments)
|
|
310
|
+
if (files.length) { links = [...links, ...files]; line = `${line} ${filesLine(files.length)}` }
|
|
266
311
|
const [item] = this.deps.feed.insert([{ id: feedId(), kind, threadId: drop.id, createdAt: at,
|
|
267
312
|
// Receipts are born read (plan section 1); a question and a follow-up count as unread on the pinned row.
|
|
268
313
|
// A receipt that asks (a reminder with no time) is waiting on him until he picks one (P2).
|
|
@@ -357,15 +402,36 @@ export class DropService {
|
|
|
357
402
|
if (drop.state === 'undone') return this.answer(drop, true)
|
|
358
403
|
const generation = this.nextGeneration(drop.id)
|
|
359
404
|
await this.reverse(drop)
|
|
405
|
+
// 6.99.13: Undo takes the drop's files with it. Only after the destination let go, so a refused Undo keeps them.
|
|
406
|
+
const fileIds = (drop.attachments ?? []).map(a => a.id)
|
|
407
|
+
let files: 'none' | 'removed' | 'kept' = 'none'
|
|
408
|
+
if (fileIds.length) {
|
|
409
|
+
try {
|
|
410
|
+
if (!this.deps.attachments) throw new Error('no attachment store')
|
|
411
|
+
await this.deps.attachments.unpin(fileIds, drop.id)
|
|
412
|
+
files = 'removed'
|
|
413
|
+
} catch {
|
|
414
|
+
files = 'kept'
|
|
415
|
+
console.warn(`[drops] ${drop.id} files not removed on undo`)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
360
418
|
const at = this.now().toISOString()
|
|
361
419
|
const saved = this.deps.drops.update(drop.id, d => {
|
|
362
420
|
const next: DropRecord = { ...d, state: 'undone', links: [], undoUntil: null, timeline: [...d.timeline, { at, state: 'undone' }] }
|
|
363
421
|
delete next.hold; delete next.remind; delete next.choices
|
|
422
|
+
if (files === 'removed') delete next.attachments
|
|
364
423
|
return next
|
|
365
424
|
})
|
|
425
|
+
if (files === 'removed') {
|
|
426
|
+
// The chat stops offering files that are gone.
|
|
427
|
+
const withFiles = new Set(this.deps.feed.list().filter(i => i.threadId === drop.id && i.links.some(l => l.kind === 'file')).map(i => i.id))
|
|
428
|
+
if (withFiles.size) this.deps.feed.update(withFiles, i => ({ ...i, links: i.links.filter(l => l.kind !== 'file') }))
|
|
429
|
+
}
|
|
366
430
|
this.retire(drop.id)
|
|
367
|
-
|
|
368
|
-
|
|
431
|
+
const filesWords = files === 'removed' ? (fileIds.length === 1 ? ' The file is removed.' : ` The ${fileIds.length} files are removed.`)
|
|
432
|
+
: files === 'kept' ? ' COS could not remove the files.' : ''
|
|
433
|
+
this.receipt(saved, 'receipt', 'Undone', undoLine(drop) + filesWords, [], [], generation)
|
|
434
|
+
console.log(`[drops] ${drop.id} undone from=${drop.dest ?? drop.state}${files === 'none' ? '' : ` files=${files}`}`)
|
|
369
435
|
return this.answer(this.deps.drops.get(drop.id)!, false)
|
|
370
436
|
})
|
|
371
437
|
}
|
|
@@ -500,7 +566,7 @@ export class DropService {
|
|
|
500
566
|
const asked = items.filter(i => isWaiting(i)).length
|
|
501
567
|
const work = typeof extra.yourMove === 'number' && Number.isSafeInteger(extra.yourMove) && extra.yourMove > 0 ? extra.yourMove : 0
|
|
502
568
|
const last = items[items.length - 1]
|
|
503
|
-
return { unread, waiting: asked + work, lastLine: last ? short(last
|
|
569
|
+
return { unread, waiting: asked + work, lastLine: last ? short(pinLine(last), 120) : '', lastAt: last?.createdAt ?? null, version: this.deps.feed.version() }
|
|
504
570
|
}
|
|
505
571
|
|
|
506
572
|
/**
|
|
@@ -567,7 +633,7 @@ export class DropService {
|
|
|
567
633
|
if (options.q) {
|
|
568
634
|
const terms = options.q.normalize('NFKC').toLowerCase().split(/\s+/).filter(Boolean)
|
|
569
635
|
rows = rows.filter(r => {
|
|
570
|
-
const hay = [r.text, ...r.links.map(l => l.label), r.hold?.label ?? '', r.file?.displayName ?? ''].join(' ').normalize('NFKC').toLowerCase()
|
|
636
|
+
const hay = [r.text, ...r.links.map(l => l.label), r.hold?.label ?? '', r.file?.displayName ?? '', ...(r.attachments ?? []).map(a => a.name)].join(' ').normalize('NFKC').toLowerCase()
|
|
571
637
|
return terms.every(t => hay.includes(t))
|
|
572
638
|
})
|
|
573
639
|
}
|
|
@@ -582,6 +648,18 @@ export class DropService {
|
|
|
582
648
|
return { drops: views.slice(0, limit), more: views.length > limit, cardStates }
|
|
583
649
|
}
|
|
584
650
|
|
|
651
|
+
/**
|
|
652
|
+
* 6.99.16: the drops a COS thread digest carries (lib/cos-thread.ts): created at or after `sinceIso`, newest first,
|
|
653
|
+
* undone ones left out. Reads the store only; never touches Work.
|
|
654
|
+
*/
|
|
655
|
+
recentDrops(sinceIso: string, limit: number): Array<Pick<DropRecord, 'text' | 'createdAt' | 'state' | 'dest' | 'links'>> {
|
|
656
|
+
return this.deps.drops.list()
|
|
657
|
+
.filter(r => r.state !== 'undone' && r.createdAt >= sinceIso)
|
|
658
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
|
659
|
+
.slice(0, Math.max(0, limit))
|
|
660
|
+
.map(r => ({ text: r.text, createdAt: r.createdAt, state: r.state, dest: r.dest, links: r.links }))
|
|
661
|
+
}
|
|
662
|
+
|
|
585
663
|
/** Releases both stores' writer locks (shutdown and tests). */
|
|
586
664
|
close(): void { this.deps.drops.close(); this.deps.feed.close() }
|
|
587
665
|
|
|
@@ -589,7 +667,7 @@ export class DropService {
|
|
|
589
667
|
|
|
590
668
|
private answer(drop: DropRecord, replayed: boolean): DropAnswer {
|
|
591
669
|
const items = this.deps.feed.list().filter(i => i.threadId === drop.id)
|
|
592
|
-
return { drop: this.view(drop, null), items, replayed, ...(drop.dest === 'ask' && !drop.ask ? { next:
|
|
670
|
+
return { drop: this.view(drop, null), items, replayed, ...(drop.dest === 'ask' && !drop.ask ? { next: nextAsk(drop) } : {}) }
|
|
593
671
|
}
|
|
594
672
|
|
|
595
673
|
private view(drop: DropRecord, cards: Map<string, { workStage: string; checked: boolean }> | null): DropView {
|
|
@@ -599,6 +677,7 @@ export class DropService {
|
|
|
599
677
|
route: drop.route, undoUntil: drop.undoUntil,
|
|
600
678
|
canUndo: ['filed', 'held', 'asking'].includes(drop.state) && !!drop.undoUntil && this.now().getTime() <= Date.parse(drop.undoUntil) && !drop.hold?.sentAt,
|
|
601
679
|
...(drop.hold ? { hold: drop.hold } : {}), ...(drop.remind ? { remind: drop.remind } : {}), ...(drop.file ? { file: drop.file } : {}),
|
|
680
|
+
...(drop.attachments?.length ? { attachments: drop.attachments } : {}),
|
|
602
681
|
}
|
|
603
682
|
}
|
|
604
683
|
|
|
@@ -639,11 +718,23 @@ function checkTarget(target: DropTarget): void {
|
|
|
639
718
|
function validateInput(input: DropInput): DropInput {
|
|
640
719
|
if (!input || typeof input !== 'object') throw new DropError('invalid_drop', 400)
|
|
641
720
|
if (typeof input.dropId !== 'string' || !DROP_ID.test(input.dropId)) throw new DropError('invalid_drop_id', 400, 'dropId must be a lowercase UUID v4.')
|
|
721
|
+
// 6.99.13: files, held to the Ask's own rules (media ids, deduplicated, at most five), before the words.
|
|
722
|
+
let attachmentIds: string[] = []
|
|
723
|
+
if (input.attachmentIds !== undefined) {
|
|
724
|
+
const list: unknown = input.attachmentIds
|
|
725
|
+
if (!Array.isArray(list) || list.some(id => typeof id !== 'string' || !ATTACHMENT_ID.test(id))) {
|
|
726
|
+
throw new DropError('invalid_attachment', 400, 'A file was not one this Mac uploaded. Add it again, then Drop.')
|
|
727
|
+
}
|
|
728
|
+
attachmentIds = [...new Set(list as string[])]
|
|
729
|
+
if (attachmentIds.length > DROP_LIMITS.attachments) throw new DropError('too_many_attachments', 400, `Up to ${DROP_LIMITS.attachments} files per drop. Remove one, then Drop.`)
|
|
730
|
+
}
|
|
731
|
+
// A drop is filed by its words, so files alone have nowhere to go: say so instead of guessing.
|
|
732
|
+
if (attachmentIds.length && (typeof input.text !== 'string' || !input.text.trim())) throw new DropError('words_needed', 400, 'Add a few words with the files so COS knows where they go.')
|
|
642
733
|
if (typeof input.text !== 'string') throw new DropError('invalid_text', 400)
|
|
643
734
|
const text = input.text.replace(/\r\n?/g, '\n').trim()
|
|
644
735
|
if (!text || [...text].length > TEXT_MAX || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(text)) throw new DropError('invalid_text', 400, `Text must be 1 to ${TEXT_MAX} characters.`)
|
|
645
736
|
if (!['mac', 'web', 'g2', 'telegram'].includes(input.source)) throw new DropError('invalid_source', 400)
|
|
646
|
-
const out: DropInput = { dropId: input.dropId, text, source: input.source }
|
|
737
|
+
const out: DropInput = { dropId: input.dropId, text, source: input.source, ...(attachmentIds.length ? { attachmentIds } : {}) }
|
|
647
738
|
if (input.hint !== undefined) {
|
|
648
739
|
const h = input.hint
|
|
649
740
|
if (!h || typeof h.sessionId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$/.test(h.sessionId)
|
|
@@ -10,8 +10,11 @@ import { dirname } from 'node:path'
|
|
|
10
10
|
import { dataPath } from './data-dir.js'
|
|
11
11
|
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
12
12
|
import { securePrivateDirectory } from './secure-user-config.js'
|
|
13
|
+
import { MAX_ATTACHMENTS_PER_PROMPT } from '../../shared/media-attachment.js'
|
|
13
14
|
|
|
14
15
|
export const DROP_IT_PROTOCOL = 1
|
|
16
|
+
/** 6.99.13: files one drop takes, the Ask's own cap (shared/media-attachment.ts MAX_ATTACHMENTS_PER_PROMPT). */
|
|
17
|
+
export const DROP_ATTACHMENTS_MAX = MAX_ATTACHMENTS_PER_PROMPT
|
|
15
18
|
export const DROP_IT_SETTINGS_FILE = () => dataPath('drop-it-settings.json')
|
|
16
19
|
|
|
17
20
|
export function dropItSetting(file = DROP_IT_SETTINGS_FILE()): boolean {
|
|
@@ -39,7 +42,15 @@ export function writeDropItSetting(enabled: boolean, file = DROP_IT_SETTINGS_FIL
|
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
/** The capability block for /api/health and /api/models. Codes only: no counts, no words. Additive. */
|
|
42
|
-
export function dropItCapability(env: NodeJS.ProcessEnv = process.env): { protocol: number; enabled: boolean; source: 'env' | 'settings'; p2Settings: number } {
|
|
45
|
+
export function dropItCapability(env: NodeJS.ProcessEnv = process.env, file = DROP_IT_SETTINGS_FILE()): { protocol: number; enabled: boolean; source: 'env' | 'settings'; p2Settings: number; attachments: number; thread?: { protocol: number } } {
|
|
43
46
|
// p2Settings (6.99.11): GET/PUT /api/drops/settings/p2 exists. Clients that do not see it hide the P2 controls.
|
|
44
|
-
|
|
47
|
+
// attachments (6.99.13): POST /api/drops takes this many attachmentIds. A client that does not see it sends words only.
|
|
48
|
+
// thread (6.99.16): the COS thread routes (routes/cos-thread.ts), present only while Drop it is on, as they are.
|
|
49
|
+
const enabled = dropItEnabled(env, file)
|
|
50
|
+
return {
|
|
51
|
+
protocol: DROP_IT_PROTOCOL, enabled, source: dropItSource(env), p2Settings: 1, attachments: DROP_ATTACHMENTS_MAX,
|
|
52
|
+
...(enabled ? { thread: { protocol: COS_THREAD_CAPABILITY_PROTOCOL } } : {}),
|
|
53
|
+
}
|
|
45
54
|
}
|
|
55
|
+
/** 6.99.16: the COS thread's protocol (lib/cos-thread.ts COS_THREAD_PROTOCOL; kept here so this module imports nothing new). */
|
|
56
|
+
export const COS_THREAD_CAPABILITY_PROTOCOL = 1
|
package/server/lib/drop-store.ts
CHANGED
|
@@ -45,6 +45,11 @@ export interface DropLink {
|
|
|
45
45
|
export interface DropChoice { id: string; dest: DropDest; label: string; detail: string; target?: { id: string; domain?: string; label: string } }
|
|
46
46
|
export interface DropRoute { by: 'rule' | 'jev' | 'user' | 'fallback'; p?: number; reason?: string; tokens?: number; provider?: string }
|
|
47
47
|
export interface DropFileRef { store: string; key: string; fileID: string; sha256: string; displayName: string }
|
|
48
|
+
/**
|
|
49
|
+
* 6.99.13: a file dropped with the words (COS Control Web). `id` is the media store's id: the bytes live there, pinned
|
|
50
|
+
* to this drop (MediaStore.pinForDrop), and open through GET /api/media/:id/content. Undo removes them.
|
|
51
|
+
*/
|
|
52
|
+
export interface DropAttachment { id: string; name: string; mime: string; size: number }
|
|
48
53
|
export interface DropRemind { at: string | null; tz: string; firedAt: string | null; ackAt: string | null; snoozes: number }
|
|
49
54
|
export interface DropTimelineEntry { at: string; state: DropState; dest?: DropDest; note?: string }
|
|
50
55
|
export interface DropAsk { clientJobId: string; generation: number; answeredAt?: string }
|
|
@@ -64,6 +69,7 @@ export interface DropRecord {
|
|
|
64
69
|
hold?: { sessionId: string; label: string; sentAt?: string }
|
|
65
70
|
remind?: DropRemind
|
|
66
71
|
file?: DropFileRef
|
|
72
|
+
attachments?: DropAttachment[]
|
|
67
73
|
ask?: DropAsk
|
|
68
74
|
hint?: { sessionId: string }
|
|
69
75
|
/** Undo and Move work until this instant (24 hours after filing). */
|
|
@@ -73,7 +79,12 @@ export interface DropRecord {
|
|
|
73
79
|
|
|
74
80
|
export const FEED_KINDS = ['drop', 'receipt', 'question', 'followup', 'ping', 'answer'] as const
|
|
75
81
|
export type FeedKind = typeof FEED_KINDS[number]
|
|
76
|
-
|
|
82
|
+
/**
|
|
83
|
+
* 6.99.19: the prep card's "Prep me" may carry the event it is about, so a client can open a Prep draft for it
|
|
84
|
+
* (ping-sources.ts prepCandidates). Display fields only; occurrence is null until the calendar sends event ids.
|
|
85
|
+
*/
|
|
86
|
+
export interface FeedActionPrep { title: string; startsAt: string; occurrence: null }
|
|
87
|
+
export interface FeedAction { id: string; label: string; prep?: FeedActionPrep }
|
|
77
88
|
export interface FeedItem {
|
|
78
89
|
id: string
|
|
79
90
|
kind: FeedKind
|
|
@@ -102,6 +113,8 @@ export interface FeedItem {
|
|
|
102
113
|
export const DROP_LIMITS = {
|
|
103
114
|
text: 2_000, label: 200, line: 2_000, title: 200, links: 12, choices: 3, timeline: 40,
|
|
104
115
|
drops: 20_000, feed: 20_000, fileBytes: 32_000_000, quarantineKept: 500,
|
|
116
|
+
/** Files on one drop: the Ask's own cap (shared/media-attachment.ts MAX_ATTACHMENTS_PER_PROMPT). */
|
|
117
|
+
attachments: 5, attachmentName: 255,
|
|
105
118
|
/** Retention (plan section 6): drop records and links a year; undone drops and chat receipts 30 days. */
|
|
106
119
|
keepDropDays: 365, keepUndoneDays: 30, keepReceiptDays: 30,
|
|
107
120
|
undoMs: 24 * 60 * 60_000,
|
|
@@ -109,6 +122,8 @@ export const DROP_LIMITS = {
|
|
|
109
122
|
|
|
110
123
|
export const DROP_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
|
111
124
|
export const FEED_ID = /^fi_[a-f0-9]{32}$/
|
|
125
|
+
/** A media store id (shared/media-attachment.ts MEDIA_ID_RE). */
|
|
126
|
+
export const ATTACHMENT_ID = /^m_[a-f0-9]{24}$/
|
|
112
127
|
const LINK_ID = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$/
|
|
113
128
|
const CONTROL = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/
|
|
114
129
|
|
|
@@ -145,6 +160,14 @@ function parseChoice(raw: unknown): DropChoice {
|
|
|
145
160
|
return choice
|
|
146
161
|
}
|
|
147
162
|
|
|
163
|
+
export function parseAttachment(raw: unknown): DropAttachment {
|
|
164
|
+
if (!record(raw)) throw new DropStoreError('invalid_drop', 400)
|
|
165
|
+
const id = typeof raw.id === 'string' && ATTACHMENT_ID.test(raw.id) ? raw.id : null
|
|
166
|
+
const name = str(raw.name, DROP_LIMITS.attachmentName), mime = str(raw.mime, 100)
|
|
167
|
+
if (!id || !name || !mime || !/^[a-z]+\/[a-z0-9.+-]+$/i.test(mime) || !Number.isSafeInteger(raw.size) || (raw.size as number) < 0) throw new DropStoreError('invalid_drop', 400)
|
|
168
|
+
return { id, name, mime, size: raw.size as number }
|
|
169
|
+
}
|
|
170
|
+
|
|
148
171
|
/** Validate one drop record from the journal. Unknown fields are dropped, never trusted. */
|
|
149
172
|
export function parseDropRecord(raw: unknown): DropRecord {
|
|
150
173
|
if (!record(raw)) throw new DropStoreError('invalid_drop', 400)
|
|
@@ -192,6 +215,10 @@ export function parseDropRecord(raw: unknown): DropRecord {
|
|
|
192
215
|
if (!store || !key || !fileID || !sha256 || !/^[a-f0-9]{64}$/.test(sha256) || !displayName) throw new DropStoreError('invalid_drop', 400)
|
|
193
216
|
drop.file = { store, key, fileID, sha256, displayName }
|
|
194
217
|
}
|
|
218
|
+
if (raw.attachments !== undefined) {
|
|
219
|
+
if (!Array.isArray(raw.attachments) || raw.attachments.length > DROP_LIMITS.attachments) throw new DropStoreError('invalid_drop', 400)
|
|
220
|
+
drop.attachments = raw.attachments.map(parseAttachment)
|
|
221
|
+
}
|
|
195
222
|
if (raw.ask !== undefined) {
|
|
196
223
|
const a = raw.ask
|
|
197
224
|
if (!record(a) || typeof a.clientJobId !== 'string' || !DROP_ID.test(a.clientJobId.toLowerCase()) || !Number.isSafeInteger(a.generation) || (a.generation as number) < 1) throw new DropStoreError('invalid_drop', 400)
|
|
@@ -214,6 +241,14 @@ export function parseDropRecord(raw: unknown): DropRecord {
|
|
|
214
241
|
return drop
|
|
215
242
|
}
|
|
216
243
|
|
|
244
|
+
/** A valid prep payload, or undefined (an invalid one is dropped, never a reason to refuse the item). */
|
|
245
|
+
function feedActionPrep(raw: unknown): FeedActionPrep | undefined {
|
|
246
|
+
if (!record(raw)) return undefined
|
|
247
|
+
const title = str(raw.title, 300), startsAt = typeof raw.startsAt === 'string' && raw.startsAt.length <= 64 && Number.isFinite(Date.parse(raw.startsAt)) ? raw.startsAt : null
|
|
248
|
+
if (!title || !startsAt || raw.occurrence !== null) return undefined
|
|
249
|
+
return { title, startsAt, occurrence: null }
|
|
250
|
+
}
|
|
251
|
+
|
|
217
252
|
export function parseFeedItem(raw: unknown): FeedItem {
|
|
218
253
|
if (!record(raw)) throw new DropStoreError('invalid_feed_item', 400)
|
|
219
254
|
const id = typeof raw.id === 'string' && FEED_ID.test(raw.id) ? raw.id : null
|
|
@@ -227,7 +262,8 @@ export function parseFeedItem(raw: unknown): FeedItem {
|
|
|
227
262
|
const actions = raw.actions.map(a => {
|
|
228
263
|
const aid = record(a) ? str(a.id, 40) : null, label = record(a) ? str(a.label, 80) : null
|
|
229
264
|
if (!aid || !/^[a-z0-9_:-]{1,40}$/.test(aid) || !label) throw new DropStoreError('invalid_feed_item', 400)
|
|
230
|
-
|
|
265
|
+
const prep = record(a) ? feedActionPrep(a.prep) : undefined
|
|
266
|
+
return { id: aid, label, ...(prep ? { prep } : {}) }
|
|
231
267
|
})
|
|
232
268
|
const item: FeedItem = { id, kind, threadId, createdAt, readAt, source, line, links: raw.links.map(parseLink), actions, state, key }
|
|
233
269
|
if (raw.title !== undefined) { const t = str(raw.title, DROP_LIMITS.title, true); if (t === null) throw new DropStoreError('invalid_feed_item', 400); item.title = t }
|
|
@@ -187,8 +187,18 @@ export interface MediaRecord {
|
|
|
187
187
|
* the public attachment ref; it lets a lost finalize response or server
|
|
188
188
|
* restart rediscover the one record that upload already published. */
|
|
189
189
|
videoUploadId?: string
|
|
190
|
+
/** 6.99.13: the Drop it drop that holds this upload (pinForDrop). Kept apart
|
|
191
|
+
* from runId, which an Ask's association overwrites. Never exposed. */
|
|
192
|
+
dropId?: string
|
|
190
193
|
}
|
|
191
194
|
|
|
195
|
+
/** A drop id (lowercase UUID v4), as lib/drop-store.ts DROP_ID. Kept local so
|
|
196
|
+
* the media store never imports the drop store. */
|
|
197
|
+
const DROP_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
|
198
|
+
/** The kinds a person uploads. Traffic frames and generated visuals carry
|
|
199
|
+
* their own content TTL, so they can never be pinned to a drop. */
|
|
200
|
+
const DROPPABLE_KINDS: ReadonlySet<string> = new Set(['user_photo', 'user_document', 'user_video'])
|
|
201
|
+
|
|
192
202
|
interface MediaIndexFile {
|
|
193
203
|
v: 1
|
|
194
204
|
records: Record<string, MediaRecord>
|
|
@@ -324,6 +334,7 @@ function sanitizeRecord(raw: unknown): MediaRecord | null {
|
|
|
324
334
|
...(typeof r.videoUploadId === 'string' && /^vu_[0-9a-f]{24}$/.test(r.videoUploadId)
|
|
325
335
|
? { videoUploadId: r.videoUploadId }
|
|
326
336
|
: {}),
|
|
337
|
+
...(typeof r.dropId === 'string' && DROP_ID_RE.test(r.dropId) ? { dropId: r.dropId } : {}),
|
|
327
338
|
}
|
|
328
339
|
}
|
|
329
340
|
|
|
@@ -1212,6 +1223,75 @@ export class MediaStore {
|
|
|
1212
1223
|
})
|
|
1213
1224
|
}
|
|
1214
1225
|
|
|
1226
|
+
/**
|
|
1227
|
+
* 6.99.13 Drop it with files: bind fresh uploads to one drop so they outlive
|
|
1228
|
+
* the staged TTL (4 h). An associated record has no lifecycle TTL and GC
|
|
1229
|
+
* never touches it, so the bytes live as long as the drop holds them.
|
|
1230
|
+
* All-or-nothing, like reserve. Refused (nothing changes):
|
|
1231
|
+
* - unknown or deleted id media_not_found
|
|
1232
|
+
* - expired, or its bytes already gone media_expired
|
|
1233
|
+
* - queued with an Ask, sent with a message, held
|
|
1234
|
+
* by another drop, or not a person's upload media_conflict
|
|
1235
|
+
* - bytes missing on disk media_unavailable
|
|
1236
|
+
* Replaying the same drop is a no-op that answers the same refs.
|
|
1237
|
+
*/
|
|
1238
|
+
pinForDrop(ids: string[], dropId: string): Promise<MediaAttachmentRef[]> {
|
|
1239
|
+
return this.withLock(() => {
|
|
1240
|
+
if (!DROP_ID_RE.test(dropId)) throw new MediaStoreError('media_conflict', 'invalid drop id')
|
|
1241
|
+
const now = Date.now()
|
|
1242
|
+
for (const id of ids) {
|
|
1243
|
+
const rec = isValidMediaId(id) ? this.records.get(id) : undefined
|
|
1244
|
+
if (!rec || rec.lifecycle === 'deleted') throw new MediaStoreError('media_not_found', `attachment ${id} not found`)
|
|
1245
|
+
if (rec.lifecycle === 'associated' && rec.dropId === dropId) continue
|
|
1246
|
+
if (rec.lifecycle === 'expired' || rec.contentRemoved || this.isContentExpired(rec, now)) {
|
|
1247
|
+
throw new MediaStoreError('media_expired', `attachment ${id} expired`)
|
|
1248
|
+
}
|
|
1249
|
+
if (!DROPPABLE_KINDS.has(rec.ref.kind)) throw new MediaStoreError('media_conflict', `attachment ${id} is not an upload`)
|
|
1250
|
+
if (rec.lifecycle !== 'staged') throw new MediaStoreError('media_conflict', `attachment ${id} is already in use`)
|
|
1251
|
+
if (!existsSync(this.absPath(rec.storagePath))) throw new MediaStoreError('media_unavailable', `attachment ${id} content unavailable`)
|
|
1252
|
+
}
|
|
1253
|
+
let dirty = false
|
|
1254
|
+
const refs: MediaAttachmentRef[] = []
|
|
1255
|
+
for (const id of ids) {
|
|
1256
|
+
const rec = this.records.get(id)!
|
|
1257
|
+
refs.push(rec.ref)
|
|
1258
|
+
if (rec.lifecycle === 'associated' && rec.dropId === dropId) continue
|
|
1259
|
+
rec.lifecycle = 'associated'
|
|
1260
|
+
rec.dropId = dropId
|
|
1261
|
+
rec.associatedAtMs = now
|
|
1262
|
+
rec.updatedAtMs = now
|
|
1263
|
+
dirty = true
|
|
1264
|
+
}
|
|
1265
|
+
if (dirty) this.saveIndex()
|
|
1266
|
+
return refs
|
|
1267
|
+
})
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* 6.99.13: Undo of a drop with files. Removes the bytes of the uploads this
|
|
1272
|
+
* drop pinned, and only those: a record another drop holds, or one an Ask
|
|
1273
|
+
* since bound to a message (globalMsgNum), is left alone. Safe to replay.
|
|
1274
|
+
* Answers how many were removed.
|
|
1275
|
+
*/
|
|
1276
|
+
unpinDrop(ids: string[], dropId: string): Promise<number> {
|
|
1277
|
+
return this.withLock(() => {
|
|
1278
|
+
if (!DROP_ID_RE.test(dropId)) return 0
|
|
1279
|
+
const now = Date.now()
|
|
1280
|
+
let removed = 0
|
|
1281
|
+
for (const id of ids) {
|
|
1282
|
+
const rec = isValidMediaId(id) ? this.records.get(id) : undefined
|
|
1283
|
+
if (!rec || rec.lifecycle !== 'associated' || rec.dropId !== dropId || rec.globalMsgNum != null) continue
|
|
1284
|
+
this.removeAssetFiles(rec)
|
|
1285
|
+
rec.lifecycle = 'deleted'
|
|
1286
|
+
rec.contentRemoved = true
|
|
1287
|
+
rec.updatedAtMs = now
|
|
1288
|
+
removed++
|
|
1289
|
+
}
|
|
1290
|
+
if (removed) this.saveIndex()
|
|
1291
|
+
return removed
|
|
1292
|
+
})
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1215
1295
|
/** Hard delete — permitted only while unassociated (staged/reserved).
|
|
1216
1296
|
* Associated records follow conversation retention instead. */
|
|
1217
1297
|
deleteUnassociated(id: string): Promise<boolean> {
|
|
@@ -156,7 +156,7 @@ export function normalizeAskFiles(v: unknown): MeetingAskFile[] {
|
|
|
156
156
|
seen.add(f.id);
|
|
157
157
|
const text = redactSecrets(f.text);
|
|
158
158
|
const secret = text !== f.text;
|
|
159
|
-
return { id: f.id, revision: f.revision, name: redactSecrets(f.name), status: secret ? 'secret-held' : f.status, text: secret || f.status !== 'ready' ? '' : text, ...(typeof f.notice === 'string' ? { notice: redactSecrets(f.notice).slice(0, 400) } : {}), ...(f.origin === 'phone' ? { origin: 'phone'
|
|
159
|
+
return { id: f.id, revision: f.revision, name: redactSecrets(f.name), status: secret ? 'secret-held' : f.status, text: secret || f.status !== 'ready' ? '' : text, ...(typeof f.notice === 'string' ? { notice: redactSecrets(f.notice).slice(0, 400) } : {}), ...(f.origin === 'phone' || f.origin === 'prep' ? { origin: f.origin as 'phone' | 'prep' } : {}) };
|
|
160
160
|
});
|
|
161
161
|
}
|
|
162
162
|
/**
|
|
@@ -469,13 +469,33 @@ export interface HqJobPlan {
|
|
|
469
469
|
frontEnd: FrontEndId
|
|
470
470
|
/** Canonical Mac/phone Parakeet must never silently substitute Whisper. */
|
|
471
471
|
strictPrimary?: true
|
|
472
|
+
/** iPhone (6.99.18): with no second opinion, the live Whisper words lend the
|
|
473
|
+
* saved Parakeet transcript the dictionary terms it missed. */
|
|
474
|
+
liveVocabulary?: true
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* An iPhone recording's saved transcript. Parakeet stays the strict primary. Parakeet takes
|
|
479
|
+
* no prompt, so the dictionary's vocabulary reaches it only through a second opinion: when
|
|
480
|
+
* one is on, it is Whisper Large-v3 (prompted with the vocabulary) and the resolver keeps
|
|
481
|
+
* the user's choice, so its vocabulary rescue can write a dictionary term Parakeet missed.
|
|
482
|
+
* Off stays off. Before this, a phone plan forced both off and the dictionary never applied
|
|
483
|
+
* to iPhone recordings beyond the fixed corrections (Miles, 2026-11-05).
|
|
484
|
+
*
|
|
485
|
+
* With the second opinion off (Large-v3 is too slow for Miles), the live words are the
|
|
486
|
+
* vocabulary source instead: since 6.99.16 an iPhone recording's live pass is the same
|
|
487
|
+
* prompted Whisper as the G2, so it already heard the dictionary (Miles, 2026-11-06).
|
|
488
|
+
*/
|
|
489
|
+
export function phoneHqPlan(selected: Pick<HqJobPlan, 'secondOpinion' | 'resolver' | 'frontEnd'>): HqJobPlan {
|
|
490
|
+
const secondOpinion = selected.secondOpinion === 'off' ? 'off' : 'large-v3'
|
|
491
|
+
return { engine: 'parakeet-ultra', secondOpinion, resolver: secondOpinion !== 'off' && selected.resolver, frontEnd: selected.frontEnd, strictPrimary: true, liveVocabulary: true }
|
|
472
492
|
}
|
|
473
493
|
|
|
474
494
|
export function currentHqJobPlan(sessionId?: string): HqJobPlan {
|
|
475
495
|
const selected = readSttSettings()
|
|
476
496
|
if (sessionId && PHONE_CAPTURE_ID.test(sessionId)) {
|
|
477
497
|
requirePhoneRecording(sessionId)
|
|
478
|
-
return
|
|
498
|
+
return phoneHqPlan(selected)
|
|
479
499
|
}
|
|
480
500
|
if (sessionId && MAC_CAPTURE_ID.test(sessionId) && selected.hq === 'parakeet-ultra') {
|
|
481
501
|
return { engine: selected.hq, secondOpinion: selected.secondOpinion, resolver: selected.resolver, frontEnd: selected.frontEnd, strictPrimary: true }
|
|
@@ -618,6 +638,16 @@ function liveWordsForWindow(segment: BatchSegment, entries: IndexedTranscriptChu
|
|
|
618
638
|
return words
|
|
619
639
|
}
|
|
620
640
|
|
|
641
|
+
/** The live words a vocabulary rescue may borrow: prompted server Whisper chunks only. A
|
|
642
|
+
* Parakeet or on-phone live chunk never heard the vocabulary prompt. */
|
|
643
|
+
function liveWhisperHypothesis(segment: BatchSegment, entries: IndexedTranscriptChunk[]): { hypothesis: Hypothesis; chunks: number } | null {
|
|
644
|
+
const whisper = entries.filter(e => e.chunkIndex >= segment.startChunkIdx && e.chunkIndex <= segment.endChunkIdx
|
|
645
|
+
&& e.chunk.asrProvider === 'server-whisper' && (e.chunk.text ?? '').trim())
|
|
646
|
+
if (!whisper.length) return null
|
|
647
|
+
const words = liveWordsForWindow(segment, whisper)
|
|
648
|
+
return { hypothesis: { engine: 'live-whisper', text: words.map(w => w.word).join(' '), words }, chunks: whisper.length }
|
|
649
|
+
}
|
|
650
|
+
|
|
621
651
|
async function decodeParakeetWindow(
|
|
622
652
|
deps: HqDecodeDeps,
|
|
623
653
|
input: Buffer,
|
|
@@ -796,6 +826,27 @@ async function decodeHqWindow(
|
|
|
796
826
|
disagreements = disagreements.map(d => ({ ...d, taken: 'primary' as const }))
|
|
797
827
|
}
|
|
798
828
|
}
|
|
829
|
+
let liveVocabulary: SegmentStt['liveVocabulary']
|
|
830
|
+
if (!second && plan.liveVocabulary && isParakeet(primary.engine) && primary.spoken) {
|
|
831
|
+
const live = liveWhisperHypothesis(segment, entries)
|
|
832
|
+
if (live) {
|
|
833
|
+
// Vocabulary only: Parakeet is not deletion-prone, and an anchored rescue never takes
|
|
834
|
+
// an insertion, so a term Whisper echoed into a pause is never written.
|
|
835
|
+
const resolved = resolveHypotheses(asHypothesis(primary), live.hypothesis, {
|
|
836
|
+
vocabulary: deps.vocabulary(),
|
|
837
|
+
primaryDeletionProne: false,
|
|
838
|
+
apply: true,
|
|
839
|
+
anchoredVocabularyOnly: true,
|
|
840
|
+
})
|
|
841
|
+
const used = resolved.wordsFromSecond > 0 && resolved.timed
|
|
842
|
+
liveVocabulary = { chunks: live.chunks, applied: used, wordsFromLive: used ? resolved.wordsFromSecond : 0, ...(resolved.skipped ? { skipped: resolved.skipped } : {}) }
|
|
843
|
+
if (used) {
|
|
844
|
+
text = applyCorrections(resolved.text)
|
|
845
|
+
words = resolved.words.map(w => ({ word: applyCorrections(w.word), start: w.start, end: w.end, probability: w.probability ?? 0 }))
|
|
846
|
+
disagreements = resolved.disagreements.filter(d => d.reason === 'vocabulary')
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
799
850
|
// Only a prompted Whisper decode can echo the previous window's text.
|
|
800
851
|
if (previousText && !isParakeet(primary.engine)) text = stripOverlap(text, previousText)
|
|
801
852
|
|
|
@@ -809,6 +860,7 @@ async function decodeHqWindow(
|
|
|
809
860
|
primary: hypothesisRecord(primary),
|
|
810
861
|
...(second ? { secondOpinion: hypothesisRecord(second) } : secondError ? { secondOpinion: { engine: secondEngine, text: '', error: secondError } } : {}),
|
|
811
862
|
resolver: { version: RESOLVER_VERSION, enabled: plan.resolver, applied, wordsFromSecond, ...(skipped ? { skipped } : {}) },
|
|
863
|
+
...(liveVocabulary ? { liveVocabulary } : {}),
|
|
812
864
|
disagreements,
|
|
813
865
|
}
|
|
814
866
|
return {
|
|
@@ -827,7 +879,7 @@ export async function transcribeSegments(
|
|
|
827
879
|
plan: HqJobPlan,
|
|
828
880
|
deps: HqDecodeDeps = defaultHqDeps,
|
|
829
881
|
): Promise<BatchResult[]> {
|
|
830
|
-
if (expectedSessionId?.startsWith('phone_')) { requirePhoneRecording(expectedSessionId); plan =
|
|
882
|
+
if (expectedSessionId?.startsWith('phone_')) { requirePhoneRecording(expectedSessionId); plan = phoneHqPlan(plan) }
|
|
831
883
|
if (expectedSessionId && MAC_CAPTURE_ID.test(expectedSessionId) && plan.engine === 'parakeet-ultra') plan = { ...plan, strictPrimary: true }
|
|
832
884
|
const results: BatchResult[] = []
|
|
833
885
|
const meetingId = basename(audioDir)
|
|
@@ -235,8 +235,9 @@ export const POLL_FUTURE_SKEW_MS = 5_000
|
|
|
235
235
|
/** The `client` values of a questions poll that count as a live answerer. */
|
|
236
236
|
export const ANSWERING_CLIENTS: ReadonlySet<string> = new Set(['glasses', 'phone'])
|
|
237
237
|
/**
|
|
238
|
-
* 6.99.12: the
|
|
239
|
-
*
|
|
238
|
+
* 6.99.12: the `client` value a COS Control Web poll (browser session cookie, never the token) sends while the page
|
|
239
|
+
* shows the cards. 6.99.13: it never counts as live (the Mac always pops up at once), and a token poll naming it
|
|
240
|
+
* never counted. Kept apart from ANSWERING_CLIENTS.
|
|
240
241
|
*/
|
|
241
242
|
export const WEB_ANSWERING_CLIENT = 'web'
|
|
242
243
|
/** The questions API's own version, advertised at /api/models. */
|
|
@@ -1787,8 +1788,9 @@ export interface SessionQuestionsCapability {
|
|
|
1787
1788
|
*/
|
|
1788
1789
|
awayRecentClientMs: number
|
|
1789
1790
|
/**
|
|
1790
|
-
* 6.99.12: the questions route takes the COS Control Web session too
|
|
1791
|
-
* live answering client
|
|
1791
|
+
* 6.99.12: the questions route takes the COS Control Web session too. 6.99.13: a `client=web` poll no longer
|
|
1792
|
+
* counts as a live answering client, so the Mac pops up at once; the web answers what the glasses or phone hold.
|
|
1793
|
+
* A page reads this before it calls the route (an older server answers a cookie 401).
|
|
1792
1794
|
*/
|
|
1793
1795
|
webClient: true
|
|
1794
1796
|
}
|
|
@@ -17,7 +17,7 @@ export function phoneMicrophoneMeetingsCapability(serverInstanceId: string | nul
|
|
|
17
17
|
try { reason = !serverInstanceId ? 'server_identity_unavailable' : readSttSettings().hq !== 'parakeet-ultra' ? 'parakeet_not_selected' : !parakeetInstalled().installed ? 'parakeet_not_installed' : 'ready' }
|
|
18
18
|
catch { reason = 'parakeet_unavailable' }
|
|
19
19
|
}
|
|
20
|
-
return { enabled: reason === 'ready', protocolVersion: 1 as const, sourceType: PHONE_SOURCE_TYPE, speakerPolicy: 'anonymous' as const, asrModel: 'parakeet-ultra', dictionary: 'server-profile-corrections', reason }
|
|
20
|
+
return { enabled: reason === 'ready', protocolVersion: 1 as const, sourceType: PHONE_SOURCE_TYPE, speakerPolicy: 'anonymous' as const, asrModel: 'parakeet-ultra', liveAsrModel: 'server-whisper' as const, dictionary: 'server-profile-corrections', reason }
|
|
21
21
|
}
|
|
22
22
|
/** Phone IDs and the new source marker must agree, including on rejected/replayed requests. */
|
|
23
23
|
export function assertPhoneProvenance(id: unknown, fields: Record<string, unknown> | undefined): boolean {
|