@gotcos/glasses-server 6.99.12 → 6.99.16

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.
@@ -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). */
@@ -102,6 +108,8 @@ export interface FeedItem {
102
108
  export const DROP_LIMITS = {
103
109
  text: 2_000, label: 200, line: 2_000, title: 200, links: 12, choices: 3, timeline: 40,
104
110
  drops: 20_000, feed: 20_000, fileBytes: 32_000_000, quarantineKept: 500,
111
+ /** Files on one drop: the Ask's own cap (shared/media-attachment.ts MAX_ATTACHMENTS_PER_PROMPT). */
112
+ attachments: 5, attachmentName: 255,
105
113
  /** Retention (plan section 6): drop records and links a year; undone drops and chat receipts 30 days. */
106
114
  keepDropDays: 365, keepUndoneDays: 30, keepReceiptDays: 30,
107
115
  undoMs: 24 * 60 * 60_000,
@@ -109,6 +117,8 @@ export const DROP_LIMITS = {
109
117
 
110
118
  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
119
  export const FEED_ID = /^fi_[a-f0-9]{32}$/
120
+ /** A media store id (shared/media-attachment.ts MEDIA_ID_RE). */
121
+ export const ATTACHMENT_ID = /^m_[a-f0-9]{24}$/
112
122
  const LINK_ID = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$/
113
123
  const CONTROL = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/
114
124
 
@@ -145,6 +155,14 @@ function parseChoice(raw: unknown): DropChoice {
145
155
  return choice
146
156
  }
147
157
 
158
+ export function parseAttachment(raw: unknown): DropAttachment {
159
+ if (!record(raw)) throw new DropStoreError('invalid_drop', 400)
160
+ const id = typeof raw.id === 'string' && ATTACHMENT_ID.test(raw.id) ? raw.id : null
161
+ const name = str(raw.name, DROP_LIMITS.attachmentName), mime = str(raw.mime, 100)
162
+ 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)
163
+ return { id, name, mime, size: raw.size as number }
164
+ }
165
+
148
166
  /** Validate one drop record from the journal. Unknown fields are dropped, never trusted. */
149
167
  export function parseDropRecord(raw: unknown): DropRecord {
150
168
  if (!record(raw)) throw new DropStoreError('invalid_drop', 400)
@@ -192,6 +210,10 @@ export function parseDropRecord(raw: unknown): DropRecord {
192
210
  if (!store || !key || !fileID || !sha256 || !/^[a-f0-9]{64}$/.test(sha256) || !displayName) throw new DropStoreError('invalid_drop', 400)
193
211
  drop.file = { store, key, fileID, sha256, displayName }
194
212
  }
213
+ if (raw.attachments !== undefined) {
214
+ if (!Array.isArray(raw.attachments) || raw.attachments.length > DROP_LIMITS.attachments) throw new DropStoreError('invalid_drop', 400)
215
+ drop.attachments = raw.attachments.map(parseAttachment)
216
+ }
195
217
  if (raw.ask !== undefined) {
196
218
  const a = raw.ask
197
219
  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)
@@ -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> {
@@ -471,11 +471,24 @@ export interface HqJobPlan {
471
471
  strictPrimary?: true
472
472
  }
473
473
 
474
+ /**
475
+ * An iPhone recording's saved transcript. Parakeet stays the strict primary. Parakeet takes
476
+ * no prompt, so the dictionary's vocabulary reaches it only through a second opinion: when
477
+ * one is on, it is Whisper Large-v3 (prompted with the vocabulary) and the resolver keeps
478
+ * the user's choice, so its vocabulary rescue can write a dictionary term Parakeet missed.
479
+ * Off stays off. Before this, a phone plan forced both off and the dictionary never applied
480
+ * to iPhone recordings beyond the fixed corrections (Miles, 2026-11-05).
481
+ */
482
+ export function phoneHqPlan(selected: Pick<HqJobPlan, 'secondOpinion' | 'resolver' | 'frontEnd'>): HqJobPlan {
483
+ const secondOpinion = selected.secondOpinion === 'off' ? 'off' : 'large-v3'
484
+ return { engine: 'parakeet-ultra', secondOpinion, resolver: secondOpinion !== 'off' && selected.resolver, frontEnd: selected.frontEnd, strictPrimary: true }
485
+ }
486
+
474
487
  export function currentHqJobPlan(sessionId?: string): HqJobPlan {
475
488
  const selected = readSttSettings()
476
489
  if (sessionId && PHONE_CAPTURE_ID.test(sessionId)) {
477
490
  requirePhoneRecording(sessionId)
478
- return { engine: 'parakeet-ultra', secondOpinion: 'off', resolver: false, frontEnd: selected.frontEnd, strictPrimary: true }
491
+ return phoneHqPlan(selected)
479
492
  }
480
493
  if (sessionId && MAC_CAPTURE_ID.test(sessionId) && selected.hq === 'parakeet-ultra') {
481
494
  return { engine: selected.hq, secondOpinion: selected.secondOpinion, resolver: selected.resolver, frontEnd: selected.frontEnd, strictPrimary: true }
@@ -827,7 +840,7 @@ export async function transcribeSegments(
827
840
  plan: HqJobPlan,
828
841
  deps: HqDecodeDeps = defaultHqDeps,
829
842
  ): Promise<BatchResult[]> {
830
- if (expectedSessionId?.startsWith('phone_')) { requirePhoneRecording(expectedSessionId); plan = { ...plan, engine: 'parakeet-ultra', secondOpinion: 'off', resolver: false, strictPrimary: true } }
843
+ if (expectedSessionId?.startsWith('phone_')) { requirePhoneRecording(expectedSessionId); plan = phoneHqPlan(plan) }
831
844
  if (expectedSessionId && MAC_CAPTURE_ID.test(expectedSessionId) && plan.engine === 'parakeet-ultra') plan = { ...plan, strictPrimary: true }
832
845
  const results: BatchResult[] = []
833
846
  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 {
@@ -0,0 +1,79 @@
1
+ /**
2
+ * 6.99.16: the COS thread (lib/cos-thread.ts). Auth comes from the parent /api mount. Every answer is
3
+ * `Cache-Control: private, no-store`. Gated exactly like the Drop it routes (routes/drops.ts): while Drop it is off,
4
+ * every route answers 404 `{ error: { code: 'drop_it_off' } }` and no file is created (the store opens on the first
5
+ * request after the feature is turned on). Advertised as `capabilities.dropIt.thread: { protocol: 1 }` while on.
6
+ *
7
+ * GET /cos-thread CosThreadAnswer: the thread and what the next Ask should do
8
+ * POST /cos-thread/new {} CosThreadAnswer (next.reason 'manual'): closes this thread, starts another
9
+ * POST /cos-thread/turn { clientId, sessionId, query, answer?, messageNo?, provider? } CosThreadAnswer
10
+ * GET /cos-thread/search ?q&limit { hits: [{ threadId, sessionId, at, query, answerHead, messageNo }], scanned }
11
+ * GET /cos-thread/threads ?limit { threads: [{ id, startedAt, endedAt, asks, segments, title }] }
12
+ */
13
+ import { Router, type Response } from 'express'
14
+ import { CosThread, CosThreadError, parseTurnInput } from '../lib/cos-thread.js'
15
+
16
+ export interface CosThreadRouterDependencies {
17
+ enabled: () => boolean
18
+ /** The store, opened on first use. Null when it cannot open. */
19
+ thread: () => CosThread | null
20
+ }
21
+
22
+ const fail = (res: Response, error: unknown) => {
23
+ if (error instanceof CosThreadError) return res.status(error.status).json({ error: { code: error.code } })
24
+ console.error('[cos-thread] request failed:', error instanceof Error ? error.name : 'error')
25
+ return res.status(500).json({ error: { code: 'cos_thread_failed' } })
26
+ }
27
+ /** A whole number in [min, max], the default when absent, or null when present and out of range. */
28
+ function intParam(raw: unknown, min: number, max: number, fallback: number): number | null {
29
+ if (raw === undefined) return fallback
30
+ if (typeof raw !== 'string' || !/^\d+$/.test(raw)) return null
31
+ const n = Number(raw)
32
+ return n >= min && n <= max ? n : null
33
+ }
34
+
35
+ export function createCosThreadRouter(deps: CosThreadRouterDependencies): Router {
36
+ const router = Router()
37
+ router.use('/cos-thread', (_req, res, next) => {
38
+ res.set('Cache-Control', 'private, no-store')
39
+ if (!deps.enabled()) return res.status(404).json({ error: { code: 'drop_it_off', message: 'Drop it is off on this server.' } })
40
+ next()
41
+ })
42
+ const withThread = (res: Response, fn: (t: CosThread) => unknown) => {
43
+ const thread = deps.thread()
44
+ if (!thread) return res.status(503).json({ error: { code: 'cos_thread_unavailable' } })
45
+ try { return fn(thread) } catch (e) { return fail(res, e) }
46
+ }
47
+
48
+ router.get('/cos-thread', (_req, res) => withThread(res, t => res.json(t.view())))
49
+
50
+ router.post('/cos-thread/new', (req, res) => {
51
+ const b = req.body
52
+ if (b !== undefined && (b === null || typeof b !== 'object' || Array.isArray(b) || Object.keys(b).length)) {
53
+ return res.status(400).json({ error: { code: 'invalid_request', message: 'Send {}.' } })
54
+ }
55
+ return withThread(res, t => res.json(t.startNew()))
56
+ })
57
+
58
+ router.post('/cos-thread/turn', (req, res) => {
59
+ const input = parseTurnInput(req.body)
60
+ if (!input) return res.status(400).json({ error: { code: 'invalid_turn' } })
61
+ return withThread(res, t => res.json(t.recordTurn(input)))
62
+ })
63
+
64
+ router.get('/cos-thread/search', (req, res) => {
65
+ const q = typeof req.query.q === 'string' ? req.query.q.trim() : ''
66
+ if (q.length < 2 || q.length > 200) return res.status(400).json({ error: { code: 'invalid_query', message: 'Search with 2 to 200 characters.' } })
67
+ const limit = intParam(req.query.limit, 1, 50, 20)
68
+ if (limit === null) return res.status(400).json({ error: { code: 'invalid_limit' } })
69
+ return withThread(res, t => res.json(t.search(q, limit)))
70
+ })
71
+
72
+ router.get('/cos-thread/threads', (req, res) => {
73
+ const limit = intParam(req.query.limit, 1, 100, 20)
74
+ if (limit === null) return res.status(400).json({ error: { code: 'invalid_limit' } })
75
+ return withThread(res, t => res.json(t.list(limit)))
76
+ })
77
+
78
+ return router
79
+ }
@@ -9,7 +9,9 @@
9
9
  *
10
10
  * GET /drops/settings { protocol, enabled, source, router }
11
11
  * PUT /drops/settings { enabled } same answer
12
- * POST /drops { dropId, text, source, hint?: { sessionId, label? }, file? } DropAnswer
12
+ * POST /drops { dropId, text, source, hint?: { sessionId, label? }, file?, attachmentIds? } DropAnswer
13
+ * 6.99.13: attachmentIds, up to 5 fresh uploads (POST /api/media, /api/media/file), kept with the
14
+ * drop (lib/drop-attachments.ts). Words are still required. Advertised as dropIt.attachments.
13
15
  * GET /drops ?q&kind&status&limit&before History
14
16
  * GET /drops/:id DropAnswer
15
17
  * POST /drops/:id/undo DropAnswer
@@ -144,7 +146,7 @@ export function createDropsRouter(deps: DropsRouterDependencies): Router {
144
146
 
145
147
  router.post('/drops', (req, res) => withService(res, async service => {
146
148
  const b = req.body
147
- if (!isRecord(b) || Object.keys(b).some(k => !['dropId', 'text', 'source', 'hint', 'file'].includes(k))) return bad(res, 'invalid_drop')
149
+ if (!isRecord(b) || Object.keys(b).some(k => !['dropId', 'text', 'source', 'hint', 'file', 'attachmentIds'].includes(k))) return bad(res, 'invalid_drop')
148
150
  try { return res.json(await service.submit(b as unknown as DropInput)) } catch (e) { return send(res, e) }
149
151
  }))
150
152
 
@@ -197,8 +197,8 @@ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps):
197
197
  // Only a client that says it can answer (and shows the cards) counts as live.
198
198
  const client = typeof req.query.client === 'string' ? req.query.client : ''
199
199
  if (byToken && ANSWERING_CLIENTS.has(client)) notePoll(now(), questionsPollFacts(req.query as Record<string, unknown>))
200
- // 6.99.12: a browser counts only as `web`, and never with presence (no away hold from a tab).
201
- else if (byWebSession && client === WEB_ANSWERING_CLIENT) notePoll(now(), {})
200
+ // 6.99.13 (Miles, option B): a browser poll never counts as live, so an open web tab never holds a question
201
+ // back from the Mac's own pop-up. The web still lists and answers what the glasses or phone are holding.
202
202
  const health = deps.broker.health()
203
203
  // Listed first: a deadline the timer has not caught yet is settled by the listing.
204
204
  const items = deps.broker.list()