@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/package.json +1 -1
  3. package/server/index.ts +44 -2
  4. package/server/lib/ask-work-plan.ts +116 -0
  5. package/server/lib/batch-transcript-quality.ts +2 -0
  6. package/server/lib/cos-thread.ts +582 -0
  7. package/server/lib/drop-attachments.ts +55 -0
  8. package/server/lib/drop-service.ts +104 -13
  9. package/server/lib/drop-settings.ts +13 -2
  10. package/server/lib/drop-store.ts +38 -2
  11. package/server/lib/media-store.ts +80 -0
  12. package/server/lib/meeting-ask-runtime.ts +1 -1
  13. package/server/lib/meeting-batch-transcribe.ts +54 -2
  14. package/server/lib/permission-broker.ts +6 -4
  15. package/server/lib/phone-recording.ts +1 -1
  16. package/server/lib/ping-sources.ts +6 -2
  17. package/server/lib/prep-runtime.ts +915 -0
  18. package/server/lib/prep-store.ts +226 -0
  19. package/server/lib/stt-resolver.ts +26 -2
  20. package/server/lib/transcript-cleanup.ts +11 -2
  21. package/server/lib/work-orchestrator/runtime.ts +8 -2
  22. package/server/routes/ask-work-plan.ts +114 -0
  23. package/server/routes/cos-thread.ts +79 -0
  24. package/server/routes/drops.ts +4 -2
  25. package/server/routes/health.ts +8 -0
  26. package/server/routes/permission-broker.ts +2 -2
  27. package/server/routes/prep.ts +81 -0
  28. package/server/routes/transcribe-stream.ts +9 -1
  29. package/server/routes/welcome-context.ts +29 -1
  30. package/server/routes/work-orchestrator.ts +57 -0
  31. package/server/scripts/web-live-cls/cdp.mjs +57 -0
  32. package/server/scripts/web-live-cls/measure.mjs +80 -0
  33. package/server/scripts/web-live-cls/mock.mjs +97 -0
  34. package/server/scripts/web-live-cls/render.mjs +26 -0
  35. package/server/web-control/control.css +13 -0
  36. package/server/web-control/panes/cos-chat.css +39 -0
  37. package/server/web-control/panes/cos-chat.js +799 -41
  38. package/server/web-control/panes/meetings.css +91 -4
  39. package/server/web-control/panes/meetings.js +585 -58
  40. package/server/web-control/panes/messages.js +25 -18
  41. package/shared/meeting-ask.ts +6 -2
  42. 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, type DropRecord,
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
- export interface DropAnswer { drop: DropView; items: FeedItem[]; replayed: boolean; next?: { kind: 'ask'; prompt: string } }
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: [], actions: [], state: 'live', key: `${fresh.id}:drop` }])
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: { kind: 'ask', prompt: saved.text } }
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
- this.receipt(saved, 'receipt', 'Undone', undoLine(drop), [], [], generation)
368
- console.log(`[drops] ${drop.id} undone from=${drop.dest ?? drop.state}`)
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.title && last.kind !== 'drop' ? `${last.title}. ${last.line}` : last.line, 120) : '', lastAt: last?.createdAt ?? null, version: this.deps.feed.version() }
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: { kind: 'ask' as const, prompt: drop.text } } : {}) }
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
- return { protocol: DROP_IT_PROTOCOL, enabled: dropItEnabled(env), source: dropItSource(env), p2Settings: 1 }
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
@@ -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
- export interface FeedAction { id: string; label: string }
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
- return { id: aid, label }
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' as const } : {}) };
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 { engine: 'parakeet-ultra', secondOpinion: 'off', resolver: false, frontEnd: selected.frontEnd, strictPrimary: true }
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 = { ...plan, engine: 'parakeet-ultra', secondOpinion: 'off', resolver: false, strictPrimary: true } }
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 one `client` value a COS Control Web poll (browser session cookie, never the token) counts under,
239
- * sent only while the page shows the cards. Kept apart from ANSWERING_CLIENTS: a token poll naming it never counts.
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, and a poll with `client=web` counts as a
1791
- * live answering client. A page reads this before it calls the route (an older server answers a cookie 401).
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 {