@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.
- package/CHANGELOG.md +78 -0
- package/package.json +1 -1
- package/server/index.ts +23 -2
- 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 +22 -0
- package/server/lib/media-store.ts +80 -0
- package/server/lib/meeting-batch-transcribe.ts +15 -2
- package/server/lib/permission-broker.ts +6 -4
- package/server/lib/phone-recording.ts +1 -1
- package/server/routes/cos-thread.ts +79 -0
- package/server/routes/drops.ts +4 -2
- package/server/routes/permission-broker.ts +2 -2
- package/server/routes/transcribe-stream.ts +9 -1
- 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 +17 -0
- package/server/web-control/panes/cos-chat.js +185 -40
- package/server/web-control/panes/meetings.css +61 -4
- package/server/web-control/panes/meetings.js +358 -54
- package/server/web-control/panes/messages.js +25 -18
|
@@ -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). */
|
|
@@ -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
|
|
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 =
|
|
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
|
|
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 {
|
|
@@ -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
|
+
}
|
package/server/routes/drops.ts
CHANGED
|
@@ -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.
|
|
201
|
-
|
|
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()
|