@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
@@ -11,14 +11,24 @@
11
11
  Steer and Cancel are the existing query-job routes. The Model and Effort menus come from /api/models (6.99.12).
12
12
  - Drop (outlined, Drop it on only): POST /api/drops, then the receipt with Undo and Move. A drop the server reads as a
13
13
  question runs the Ask the same way and is linked back (POST /api/drops/:id/ask), as COS Control does.
14
+ 6.99.13: Drop takes the composer's files too (up to capabilities.dropIt.attachments, 5), as attachmentIds. The
15
+ server keeps them with the drop; the receipt lists each one with Open, and Undo removes them. A drop read as a
16
+ question sends its files with the Ask. An older server without that field keeps the words-only note.
14
17
  - The clip (6.99.12) attaches photos, PDF, TXT, MD, CSV, JSON, XLSX, MP4 and MOV, as the Even app does: photos are
15
18
  resized to a 1024 px JPEG and posted to /api/media, other files go raw to /api/media/file, and the Ask carries up to
16
- five attachmentIds. Paste and drag-drop add files too.
19
+ five attachmentIds. Paste and drag-drop add files too. Drop sends them too where the server says it takes them.
17
20
  With a keyboard, Return asks and Option-Return drops; Shift-Return is a new line.
18
21
 
19
22
  The same composer answers in the message reader (6.99.12): Reply sends that turn's sessionId, so the answer continues
20
23
  that conversation when it is still live (lib/web-ask-admission.ts).
21
24
 
25
+ 6.99.16, the COS thread (capabilities.dropIt.thread, routes/cos-thread.ts): each Ask from the chat's own composer
26
+ reads GET /api/cos-thread first. `continue` sends that sessionId; `fresh` sends none and carries the server's digest
27
+ as the Ask's `reference` (the query-jobs field for quoted context, so the Messages row shows only the question).
28
+ While an Ask in the thread is still answering, the next one continues its conversation without asking. When an Ask
29
+ finishes, POST /api/cos-thread/turn once with its answer. "New thread" in the header posts /api/cos-thread/new.
30
+ Without the capability, the old rule stands: one conversation per day, kept in this browser.
31
+
22
32
  The rail badge on Messages is a number for `waiting` (GET /api/inbox/summary) and a dot for unread only. It is read
23
33
  with the shell's existing status read (shell.js checkServer), never on a timer of its own, and painting it touches
24
34
  only the badge, never the pane. */
@@ -41,7 +51,10 @@
41
51
  models: null, // [{ value, label, available }]
42
52
  host: null,
43
53
  onChange: null, // messages.js: re-render when Drop it or Ask turns on or off
44
- onAnswered: null // messages.js: re-read Recent when a web Ask lands
54
+ onAnswered: null, // messages.js: re-read Recent when a web Ask lands
55
+ thread: null, // 6.99.16: the last GET /api/cos-thread answer
56
+ threadCarried: '', // the thread's newest session started fresh: 'summary' (it carried a digest) or 'plain'
57
+ threadBusy: false
45
58
  };
46
59
  var saved = W.readStore(STORE);
47
60
  C.model = typeof saved.model === 'string' ? saved.model : '';
@@ -76,22 +89,42 @@
76
89
  // ── capabilities and the rail badge ───────────────────
77
90
  // Drop it on: the pinned COS chat. Ask on (durable query jobs): the composer, with or without Drop it.
78
91
  function dropOn() { return !!(C.caps && C.caps.dropIt); }
92
+ function dropFiles() { return C.caps && C.caps.dropIt && C.caps.dropFiles > 0 ? C.caps.dropFiles : 0; }
79
93
  function askOn() { return !!(C.caps && C.caps.jobs); }
80
94
  function on() { return dropOn() || askOn(); }
95
+ // 6.99.16: the COS thread, advertised inside dropIt while Drop it is on.
96
+ function threadOn() { return !!(C.caps && C.caps.dropIt && C.caps.thread); }
81
97
  function setCaps(h) {
82
98
  var caps = (h && h.capabilities) || {};
83
99
  var drop = caps.dropIt;
84
100
  var next = {
85
101
  dropIt: !!(drop && drop.enabled === true),
86
- jobs: caps.durableQueryJobs === true || h && h.durableQueryJobs === true || !!(caps.durableQueryJobs && caps.durableQueryJobs.enabled),
87
- steer: !!(C.caps && C.caps.steer)
102
+ // 6.99.13: how many files a drop takes. 0 (an older server): Drop is words only and says so.
103
+ dropFiles: drop && drop.enabled === true && Number.isInteger(drop.attachments) && drop.attachments > 0 ? Math.min(drop.attachments, MAX_ATTS) : 0,
104
+ // 6.99.16: /api/health carries it at features.durableQueryJobs (a boolean); /api/models at capabilities.durableQueryJobs.enabled.
105
+ jobs: caps.durableQueryJobs === true || h && h.durableQueryJobs === true || !!(caps.durableQueryJobs && caps.durableQueryJobs.enabled) ||
106
+ !!(h && h.features && h.features.durableQueryJobs === true),
107
+ steer: !!(C.caps && C.caps.steer),
108
+ thread: !!(drop && drop.enabled === true && drop.thread && drop.thread.protocol === 1),
109
+ // 6.99.19: Prep (capabilities.prep, protocol 1). Absent on an older server: "Prep me" stays a plain Ask.
110
+ prep: prepCapsOf(caps.prep)
88
111
  };
89
- var changed = !C.caps || C.caps.dropIt !== next.dropIt || C.caps.jobs !== next.jobs;
112
+ if (next.prep && next.prep.serverInstanceId) PREP_INST = next.prep.serverInstanceId;
113
+ var changed = !C.caps || C.caps.dropIt !== next.dropIt || C.caps.jobs !== next.jobs || C.caps.thread !== next.thread;
114
+ var filesChanged = !!C.caps && C.caps.dropFiles !== next.dropFiles;
90
115
  C.caps = next;
91
116
  if (!next.dropIt) { clearRail(); C.summary = null; }
92
117
  if (changed && C.onChange) C.onChange();
118
+ else if (filesChanged) paintButtons(CHAT);
93
119
  if (next.dropIt) refreshSummary();
94
120
  }
121
+ function prepCapsOf(p) {
122
+ if (!p || typeof p !== 'object' || !(Number(p.protocol) >= 1)) return null;
123
+ var files = Number.isInteger(p.maxFiles) && p.maxFiles > 0 ? Math.min(p.maxFiles, 10) : 10;
124
+ var chars = Number.isInteger(p.steerChars) && p.steerChars > 0 ? Math.min(p.steerChars, 4000) : 4000;
125
+ return { protocol: Number(p.protocol), maxFiles: files, steerChars: chars, bind: p.bind === true, suggest: p.suggest === true,
126
+ serverInstanceId: typeof p.serverInstanceId === 'string' ? p.serverInstanceId : '' };
127
+ }
95
128
  // The shell's status read (shell.js checkServer) hands its /api/health answer here.
96
129
  function fromHealth(h) { setCaps(h); }
97
130
  function ensureCaps() {
@@ -260,14 +293,16 @@
260
293
  }
261
294
 
262
295
  // ── the chat ──────────────────────────────────────────
263
- function chatMode() { return dropOn() ? 'drop' : 'ask'; }
296
+ function chatMode() { return dropOn() ? (threadOn() ? 'drop-thread' : 'drop') : 'ask'; }
264
297
  function chatSkeleton() {
265
298
  var drop = dropOn();
266
299
  return '<section class="cc" data-cos-chat data-mode="' + chatMode() + '" data-tint="messages" aria-label="' + (drop ? 'COS chat' : 'Ask COS') + '">' +
267
300
  '<header class="cc-head"><span class="cc-avatar big">' + MARK + '</span><div class="cc-head-text"><h2 class="cc-title">' + (drop ? 'COS' : 'Ask COS') + '</h2>' +
268
301
  '<p class="cc-sub">' + (drop ? 'Text it anything: a thought, an ask, a reminder. It texts back.' : 'Ask anything, with photos or files. Each answer lands in Messages with its number, here and on the glasses.') + '</p></div>' +
269
302
  '<div class="cc-head-acts"><button type="button" class="link-btn cc-all" data-cos="all">All messages</button>' +
270
- (drop ? '<button type="button" class="link-btn" data-cos="read-all">Mark all read</button>' : '') + '</div></header>' +
303
+ (drop ? '<button type="button" class="link-btn" data-cos="read-all">Mark all read</button>' : '') +
304
+ (threadOn() ? '<button type="button" class="link-btn" data-cos="new-thread">New thread</button>' : '') + '</div>' +
305
+ (threadOn() ? '<p class="cc-thread" data-cos-thread role="status">' + esc(threadLine()) + '</p>' : '') + '</header>' +
271
306
  '<div class="cc-feed" data-cos-feed aria-live="polite"></div>' +
272
307
  composerHtml(CHAT, { drop: drop, placeholder: drop ? 'Ask COS, or drop a thought, a task, a reminder' : 'Ask COS anything', label: 'Message to COS' }) +
273
308
  '</section>';
@@ -280,9 +315,11 @@
280
315
  if (!el || el.getAttribute('data-mode') !== chatMode()) {
281
316
  host.innerHTML = chatSkeleton();
282
317
  hydrate(CHAT);
318
+ if (P) paintPrep(true);
283
319
  }
284
320
  if (!visible) return;
285
321
  loadModels();
322
+ if (threadOn()) loadThread();
286
323
  // Ask only: no inbox to read; the chat holds this page's Asks. Inbox lines from a Drop it session are not shown.
287
324
  if (!dropOn()) { if (C.feedMode !== 'ask') { C.items = []; C.feedState = 'ready'; C.feedMode = 'ask'; } paintFeed(true); return; }
288
325
  if (C.items === null || C.feedState === 'error' || C.feedMode !== 'drop') loadFeed(); else paintFeed(true);
@@ -338,7 +375,8 @@
338
375
  }).join('') + '</ol>';
339
376
  }
340
377
  var rest = acts.filter(function (a) { return !/^choose:/.test(a.id); });
341
- if (item.needsReply) rest.push({ id: 'dismiss', label: 'Dismiss' });
378
+ // A P2 follow-up already carries its own Dismiss (6.99.11); add one only to an item that does not (6.99.15: it showed twice).
379
+ if (item.needsReply && !rest.some(function (a) { return a.id === 'dismiss'; })) rest.push({ id: 'dismiss', label: 'Dismiss' });
342
380
  if (rest.length) {
343
381
  html += '<div class="cc-acts">' + rest.map(function (a) {
344
382
  var cls = PRIMARY[a.id] ? 'cx-btn primary' : 'link-btn cc-text-btn';
@@ -360,16 +398,30 @@
360
398
  return '<button type="button" class="cc-choice" data-cos="move-to" data-item="' + esc(item.id) + '" data-dest="' + o.dest + '"' + (o.domain ? ' data-domain="' + esc(o.domain) + '"' : '') + '>' + esc(o.label) + '</button>';
361
399
  }).join('') + '<button type="button" class="link-btn cc-text-btn" data-cos="move-cancel">Keep it where it is</button></div>';
362
400
  }
401
+ // 6.99.13: a drop's files, each opening from the media store in a new tab. Under your words they are what you sent;
402
+ // on a receipt each says Open. Undo takes the links away with the files. The classes are cc-dfile*, never cc-file:
403
+ // .cc-file is the clip's invisible file input (opacity 0, absolute), and a link wearing it does not show.
404
+ function fileLinksHtml(item, mine) {
405
+ var files = (item.links || []).filter(function (l) { return l.kind === 'file' && MEDIA_ID.test(String(l.id)); });
406
+ if (!files.length) return '';
407
+ return '<div class="cc-dfiles' + (mine ? ' mine' : '') + '">' + files.map(function (f) {
408
+ var url = '/api/media/' + encodeURIComponent(f.id) + '/content';
409
+ var photo = /\.(jpe?g|png|gif|webp|heic|heif)$/i.test(String(f.label)) || f.label === 'Photo';
410
+ return '<a class="cc-dfile" href="' + esc(url + '?variant=phone') + '" target="_blank" rel="noopener" aria-label="Open ' + esc(f.label) + '">' +
411
+ '<span class="cc-dfile-thumb">' + (photo ? '<img src="' + esc(url + '?variant=thumb') + '" alt="" loading="lazy">' : '<svg class="icon" aria-hidden="true"><use href="#i-doc"/></svg>') + '</span>' +
412
+ '<span class="cc-dfile-name">' + esc(f.label) + '</span>' + (mine ? '' : '<span class="cc-dfile-open">Open</span>') + '</a>';
413
+ }).join('') + '</div>';
414
+ }
363
415
  function itemHtml(item) {
364
416
  if (item.kind === 'drop' || item.kind === 'answer') {
365
417
  return '<div class="cc-line you' + (item.kind === 'answer' ? ' answer' : '') + '"' + (item.kind === 'drop' ? ' data-drop-thread="' + esc(item.threadId) + '"' : '') + '><div class="cc-bubble">' + esc(item.line) + '</div>' +
366
- (item.kind === 'drop' ? '<div class="cc-src">' + esc(SOURCE[item.source] || '') + '</div>' : '') + '</div>';
418
+ (item.kind === 'drop' ? fileLinksHtml(item, true) + '<div class="cc-src">' + esc(SOURCE[item.source] || '') + '</div>' : '') + '</div>';
367
419
  }
368
420
  var quote = item.kind === 'followup' ? (function () { var d = dropOf(item); return d ? '<button type="button" class="cc-quote" data-cos="quote" data-thread="' + esc(item.threadId) + '">Re: ' + esc(clip(d.line, 70)) + '</button>' : ''; })() : '';
369
421
  return '<div class="cc-line cos' + (item.state !== 'live' ? ' done' : '') + (item.needsReply && item.state === 'live' ? ' waits' : '') + '" data-item-id="' + esc(item.id) + '">' +
370
422
  '<span class="cc-avatar">' + MARK + '</span><div class="cc-card">' + quote +
371
423
  (item.title ? '<p class="cc-card-title">' + esc(item.title) + '</p>' : '') +
372
- '<p class="cc-card-line">' + esc(item.line) + '</p>' + actionsHtml(item) + '</div></div>';
424
+ '<p class="cc-card-line">' + esc(item.line) + '</p>' + fileLinksHtml(item, false) + actionsHtml(item) + '</div></div>';
373
425
  }
374
426
  // The finished answer as Markdown; while it streams, plain words, so a half-written "**bold" never shows its stars.
375
427
  function answerHtml(a) {
@@ -458,7 +510,13 @@
458
510
  var jobs = !C.caps || C.caps.jobs;
459
511
  var waiting = uploadsPending(k), failed = uploadsFailed(k);
460
512
  if (ask) { ask.disabled = !text || !!k.busy || !jobs || waiting || failed; ask.textContent = k.busy === 'ask' ? 'Asking…' : waiting ? 'Uploading…' : (k.name === 'chat' ? 'Ask' : 'Reply'); }
461
- if (drop) { drop.disabled = !text || !!k.busy; drop.textContent = k.busy === 'drop' ? 'Dropping…' : 'Drop'; }
513
+ if (drop) {
514
+ // 6.99.13: with files on a server that takes them, Drop waits for the uploads as Ask does. Files with no words keep
515
+ // Drop tappable so the tap can say what is missing.
516
+ var withFiles = k.atts.length > 0 && dropFiles();
517
+ drop.disabled = !!k.busy || (!text && !withFiles) || (withFiles && (waiting || failed));
518
+ drop.textContent = k.busy === 'drop' ? 'Dropping…' : withFiles && waiting ? 'Uploading…' : 'Drop';
519
+ }
462
520
  var attach = form.querySelector('.cc-attach'), picker = form.querySelector('[data-cos-file]');
463
521
  if (attach) attach.classList.toggle('full', k.atts.length >= MAX_ATTS);
464
522
  if (picker) picker.disabled = k.atts.length >= MAX_ATTS || !!k.busy;
@@ -476,6 +534,7 @@
476
534
 
477
535
  // ── attachments (6.99.12, the Even app's composer) ────
478
536
  var MAX_ATTS = 5; // MAX_ATTACHMENTS_PER_PROMPT
537
+ var MEDIA_ID = /^m_[a-f0-9]{24}$/; // shared/media-attachment.ts MEDIA_ID_RE
479
538
  var MAX_PHOTO = 2 * 1024 * 1024; // MAX_IMAGE_BYTES, after the resize
480
539
  var MAX_DOC = 64 * 1024 * 1024; // MAX_OTHER_MEDIA_BYTES
481
540
  var MAX_VIDEO = 100 * 1024 * 1024; // MAX_VIDEO_MEDIA_BYTES
@@ -618,32 +677,47 @@
618
677
  k.signedOut = true;
619
678
  setNote(k, 'This browser was signed out. Your message and files are kept. Sign in, then tap Retry on each file.');
620
679
  } else {
621
- t.error = UPLOAD_COPY[err && err.code] || (err && err.status === 413 ? 'Too large for COS.' : 'Did not upload');
680
+ t.error = uploadErrorText(err);
622
681
  }
623
682
  paintChips(k); paintButtons(k);
624
683
  }
625
- function startUpload(k, t) {
626
- t.state = 'reading'; t.progress = 0; t.error = '';
684
+ /**
685
+ * One file to the media store, the way every web upload goes: a photo resized to a 1024 px JPEG and posted to /api/media,
686
+ * any other file raw to /api/media/file. Resolves with the media ref ({ id }). `t` is the upload's record ({ file, name,
687
+ * kind, mime }); it gets `xhr` while sending (Remove aborts it) and `thumb` for a photo. hooks.uploading() runs once the
688
+ * bytes start, hooks.progress(0..1) as they go. 6.99.14: the live meeting view's Add file uses this too.
689
+ */
690
+ function uploadMedia(t, hooks) {
627
691
  var at = new Date(t.file.lastModified || Date.now()).toISOString();
628
- var step;
629
692
  if (t.kind === 'photo') {
630
- step = resizePhoto(t.file).then(function (dataUrl) {
693
+ return resizePhoto(t.file).then(function (dataUrl) {
631
694
  var data = dataUrl.slice(dataUrl.indexOf(',') + 1);
632
695
  var bytes = Math.floor(data.length * 3 / 4);
633
696
  if (bytes > MAX_PHOTO) { var big = new Error('Over 2 MB even after resizing.'); big.code = 'photo_too_large'; throw big; }
634
- t.thumb = dataUrl; t.state = 'uploading'; paintChips(k); paintButtons(k);
697
+ t.thumb = dataUrl; hooks.uploading();
635
698
  var body = JSON.stringify({ images: [{ data: data, label: t.name.slice(0, 120), capturedAt: at }], kind: 'user_photo' });
636
- return uploadRequest('/api/media', body, { 'Content-Type': 'application/json' }, function (p) { t.progress = p; paintProgress(k, t); }, t);
699
+ return uploadRequest('/api/media', body, { 'Content-Type': 'application/json' }, function (p) { hooks.progress(p); }, t);
637
700
  }).then(function (d) { return d && d.attachments && d.attachments[0]; });
638
- } else {
639
- t.state = 'uploading';
640
- step = uploadRequest('/api/media/file', t.file, {
641
- 'Content-Type': t.mime || 'application/octet-stream',
642
- 'X-COS-Filename': encodeURIComponent(t.name),
643
- 'X-COS-Captured-At': at
644
- }, function (p) { t.progress = p; paintProgress(k, t); }, t).then(function (d) { return d && d.attachment; });
645
- paintChips(k);
646
701
  }
702
+ hooks.uploading();
703
+ return uploadRequest('/api/media/file', t.file, {
704
+ 'Content-Type': t.mime || 'application/octet-stream',
705
+ 'X-COS-Filename': encodeURIComponent(t.name),
706
+ 'X-COS-Captured-At': at
707
+ }, function (p) { hooks.progress(p); }, t).then(function (d) { return d && d.attachment; });
708
+ }
709
+ /** The chip's words for a failed upload (never the sign-in case, which each caller says its own way). */
710
+ function uploadErrorText(err) {
711
+ if (err && err.code === 'photo_too_large') return 'Over 2 MB even after resizing';
712
+ if (err && !err.status && !err.code) return err.message || 'Did not upload';
713
+ return UPLOAD_COPY[err && err.code] || (err && err.status === 413 ? 'Too large for COS.' : 'Did not upload');
714
+ }
715
+ function startUpload(k, t) {
716
+ t.state = 'reading'; t.progress = 0; t.error = '';
717
+ var step = uploadMedia(t, {
718
+ uploading: function () { t.state = 'uploading'; paintChips(k); paintButtons(k); },
719
+ progress: function (p) { t.progress = p; paintProgress(k, t); }
720
+ });
647
721
  step.then(function (ref) {
648
722
  if (!findAtt(k, t.id)) return;
649
723
  if (!ref || !ref.id) { var e = new Error('The Mac did not keep the file.'); e.code = ''; throw e; }
@@ -651,8 +725,8 @@
651
725
  paintChips(k); paintButtons(k);
652
726
  }).catch(function (err) {
653
727
  if (!findAtt(k, t.id)) return;
654
- if (err && err.code === 'photo_too_large') { t.state = 'failed'; t.error = 'Over 2 MB even after resizing'; paintChips(k); paintButtons(k); return; }
655
- if (err && !err.status && !err.code) { t.state = 'failed'; t.error = err.message || 'Did not upload'; paintChips(k); paintButtons(k); return; }
728
+ if (err && err.code === 'photo_too_large') { t.state = 'failed'; t.error = uploadErrorText(err); paintChips(k); paintButtons(k); return; }
729
+ if (err && !err.status && !err.code) { t.state = 'failed'; t.error = uploadErrorText(err); paintChips(k); paintButtons(k); return; }
656
730
  uploadError(k, t, err);
657
731
  });
658
732
  }
@@ -663,13 +737,20 @@
663
737
  function doDrop() {
664
738
  var k = CHAT;
665
739
  var input = inputOf(k); var text = input ? input.value.trim() : '';
666
- if (!text || k.busy) return;
667
- // A drop files words. Files go with an Ask (a drop takes no attachments yet).
668
- if (k.atts.length) { setNote(k, 'Drop takes words only for now. Ask sends the files with your words, or remove them to drop.'); return; }
740
+ if (k.busy) return;
741
+ // An older server files words only (no capabilities.dropIt.attachments): say so, and send nothing.
742
+ if (k.atts.length && !dropFiles()) { setNote(k, 'Drop takes words only on this Mac. Ask sends the files with your words, or remove them to drop.'); return; }
743
+ if (!text) { if (k.atts.length) setNote(k, 'Add a few words with the files so COS knows where they go.'); return; }
744
+ if (uploadsPending(k)) return;
745
+ if (uploadsFailed(k)) { setNote(k, 'A file did not upload. Retry it or remove it, then Drop.'); return; }
746
+ // What is shown as sent is exactly what is sent: never more than the server takes.
747
+ var ready = readyAtts(k).slice(0, dropFiles() || 0);
669
748
  var dropId = uuid();
749
+ var body = { dropId: dropId, text: text, source: 'web' };
750
+ if (ready.length) body.attachmentIds = ready.map(function (t) { return t.mediaId; });
670
751
  k.busy = 'drop'; setNote(k, ''); paintButtons(k);
671
752
  var send = function (retry) {
672
- return api('/api/drops', { method: 'POST', body: { dropId: dropId, text: text, source: 'web' }, timeoutMs: 30000 }).catch(function (err) {
753
+ return api('/api/drops', { method: 'POST', body: body, timeoutMs: 30000 }).catch(function (err) {
673
754
  // The same dropId is safe to send again: the server answers the first result.
674
755
  if (retry && err && err.code === 'timeout') return send(false);
675
756
  throw err;
@@ -678,14 +759,78 @@
678
759
  send(true).then(function (ans) {
679
760
  k.busy = ''; k.draft = ''; saveDraft(k);
680
761
  var box = inputOf(k); if (box) { box.value = ''; W.autoGrow(box); }
762
+ if (ready.length) clearAtts(k);
681
763
  mergeItems(ans.items);
682
764
  paintFeed(true); paintButtons(k);
683
- if (ans.next && ans.next.kind === 'ask' && C.caps && C.caps.jobs) submitAsk(ans.next.prompt, ans.drop.id);
765
+ if (ans.next && ans.next.kind === 'ask' && C.caps && C.caps.jobs) submitAsk(ans.next.prompt, ans.drop.id, null, askFilesOf(ans.next));
684
766
  refreshSummary();
685
767
  }, function (err) {
686
768
  k.busy = ''; paintButtons(k);
687
- // The words stay in the box until the server has them.
688
- setNote(k, err && err.code === 'drop_it_off' ? 'Drop it is off on this Mac.' : 'Not dropped: ' + ((err && err.message) || 'the Mac did not answer') + ' Your words are still here.');
769
+ // The words and the files stay in the box until the server has them.
770
+ var kept = ready.length ? ' Your words and files are still here.' : ' Your words are still here.';
771
+ // A refused file says why in the server's own words (attachment_expired, too_many_attachments, words_needed...).
772
+ setNote(k, err && err.code === 'drop_it_off' ? 'Drop it is off on this Mac.' : 'Not dropped: ' + ((err && err.message) || 'the Mac did not answer.') + kept);
773
+ });
774
+ }
775
+
776
+ // A drop read as a question runs the Ask with the drop's files (the server names them in next.attachmentIds).
777
+ function askFilesOf(next) {
778
+ var ids = next && Array.isArray(next.attachmentIds) ? next.attachmentIds.filter(function (id) { return MEDIA_ID.test(String(id)); }).slice(0, MAX_ATTS) : [];
779
+ return ids.length ? { attachmentIds: ids } : undefined;
780
+ }
781
+
782
+ // ── the COS thread (6.99.16) ──────────────────────────
783
+ var THREAD_REF = 'Summary carried over from earlier in this COS thread';
784
+ function threadLine() {
785
+ var t = C.thread && C.thread.thread, n = C.thread && C.thread.next;
786
+ var carried = C.threadCarried || (n && n.reason === 'manual' ? (n.digest ? 'summary' : 'plain') : '');
787
+ if (carried) return carried === 'summary' ? 'New thread. It carries a short summary of the last one.' : 'New thread.';
788
+ var asks = t && Number(t.asks) > 0 ? Number(t.asks) : 0;
789
+ return asks ? 'Continues your COS thread · ' + asks + (asks === 1 ? ' ask' : ' asks') : '';
790
+ }
791
+ function paintThread() {
792
+ var line = threadLine();
793
+ document.querySelectorAll('[data-cos-thread]').forEach(function (el) { el.textContent = line; });
794
+ }
795
+ function loadThread() {
796
+ return api('/api/cos-thread').then(function (t) { C.thread = t; paintThread(); return t; }, function () { return null; });
797
+ }
798
+ // What the next Ask in the thread sends: a sessionId to continue, or none and the digest to carry.
799
+ function threadPlan() {
800
+ // An Ask in the thread still answering: the next one continues its conversation (its turn is not recorded yet).
801
+ var live = C.asks.filter(function (x) { return x.threaded && x.sessionId && x.turnState !== 'done' && Date.now() - x.at < 20 * 60000; });
802
+ if (live.length) return Promise.resolve({ sessionId: live[live.length - 1].sessionId, digest: null });
803
+ return loadThread().then(function (t) {
804
+ var n = t && t.next;
805
+ if (n && n.mode === 'continue' && n.sessionId) return { sessionId: n.sessionId, digest: null };
806
+ if (n && n.mode === 'fresh') return { sessionId: '', digest: typeof n.digest === 'string' && n.digest ? n.digest : null };
807
+ // The thread could not be read: continue the session it last named, if any.
808
+ var last = C.thread && C.thread.thread && C.thread.thread.sessionId;
809
+ return { sessionId: last || '', digest: null };
810
+ });
811
+ }
812
+ // Once per Ask, when it has finished: the thread records it, with the answer when there is one.
813
+ function postTurn(a) {
814
+ if (!a.threaded || !a.sessionId || a.turnState) return;
815
+ a.turnState = 'posting';
816
+ var body = { clientId: a.cid, sessionId: a.sessionId, query: String(a.query).slice(0, 20000) };
817
+ if (a.status === 'completed' && typeof a.response === 'string' && a.response) body.answer = a.response.slice(0, 40000);
818
+ if (Number.isInteger(a.no) && a.no > 0) body.messageNo = a.no;
819
+ api('/api/cos-thread/turn', { method: 'POST', body: body }).then(function (t) {
820
+ a.turnState = 'done';
821
+ if (t && t.thread) { C.thread = t; paintThread(); }
822
+ }, function () { a.turnState = 'done'; });
823
+ }
824
+ function newThread() {
825
+ if (C.threadBusy) return;
826
+ C.threadBusy = true;
827
+ api('/api/cos-thread/new', { method: 'POST', body: {} }).then(function (t) {
828
+ C.threadBusy = false;
829
+ C.thread = t; C.threadCarried = '';
830
+ paintThread();
831
+ }, function (err) {
832
+ C.threadBusy = false;
833
+ W.toast((err && err.message) || 'New thread did not go through.');
689
834
  });
690
835
  }
691
836
 
@@ -713,7 +858,9 @@
713
858
  var body = { clientJobId: a.cid, generation: 1, query: text, source: 'web', activityToolMode: 'status' };
714
859
  if (C.model) body.model = C.model;
715
860
  if (C.effort) body.effort = C.effort;
716
- if (C.session && C.session.id) body.sessionId = C.session.id;
861
+ // 6.99.16: an Ask from the chat's own composer belongs to the COS thread (a Reply keeps its turn's conversation).
862
+ a.threaded = threadOn() && !opts.replyKey;
863
+ if (!a.threaded && C.session && C.session.id) body.sessionId = C.session.id;
717
864
  // A reply continues the turn's own conversation (kept when it is still live; else the server starts a new one).
718
865
  if (opts.sessionId) body.sessionId = opts.sessionId;
719
866
  if (opts.attachmentIds && opts.attachmentIds.length) body.attachmentIds = opts.attachmentIds.slice(0, MAX_ATTS);
@@ -727,10 +874,16 @@
727
874
  throw err;
728
875
  });
729
876
  };
730
- admit(true).then(function (d) {
877
+ var prepare = a.threaded ? threadPlan().then(function (plan) {
878
+ if (plan.sessionId) body.sessionId = plan.sessionId;
879
+ else if (plan.digest) body.reference = { query: THREAD_REF, response: plan.digest };
880
+ a.carried = plan.sessionId ? '' : plan.digest ? 'summary' : 'plain';
881
+ }) : Promise.resolve();
882
+ prepare.then(function () { return admit(true); }).then(function (d) {
731
883
  var job = d && d.job; if (!job) throw new Error('The Mac did not take the question.');
732
884
  take(a, job);
733
- if (job.sessionId && !a.replyKey) { C.session = { id: job.sessionId, day: today() }; save(); }
885
+ if (a.threaded) { C.threadCarried = a.carried; paintThread(); }
886
+ else if (job.sessionId && !a.replyKey) { C.session = { id: job.sessionId, day: today() }; save(); }
734
887
  if (dropId) api('/api/drops/' + encodeURIComponent(dropId) + '/ask', { method: 'POST', body: { clientJobId: a.cid, generation: 1 } }).catch(function () {});
735
888
  if (done) done(true);
736
889
  poll(a);
@@ -767,6 +920,7 @@
767
920
  setTimeout(tick, 600);
768
921
  }
769
922
  function finished(a) {
923
+ postTurn(a);
770
924
  if (a.replyKey) paintReplyFeed(a.replyKey); else paintFeed(false);
771
925
  if (C.onAnswered) C.onAnswered();
772
926
  // A drop that became a question gets its answer as a follow-up: read the chat again.
@@ -833,7 +987,8 @@
833
987
  if (action === 'open_result') return W.go(linkOf('session') ? 'sessions' : linkOf('card') ? 'work' : 'messages');
834
988
  if (action === 'open') { if (linkOf('thread')) return W.go('threads'); if (linkOf('meeting')) return W.go('meetings'); return; }
835
989
  if (action === 'review') return W.go('work');
836
- if (action === 'prep') { if (C.caps && C.caps.jobs) submitAsk('Prep me for ' + (item.title || 'my next meeting'), ''); return; }
990
+ // 6.99.19: with Prep on the Mac, Prep me opens the Prep composer (notes and files first); without it, the plain Ask.
991
+ if (action === 'prep') { if (prepOn()) return openPrep(item); if (C.caps && C.caps.jobs) submitAsk('Prep me for ' + (item.title || 'my next meeting'), ''); return; }
837
992
  if (action === 'reply') {
838
993
  var box = document.getElementById('cos-input');
839
994
  if (box) { box.value = 'remind me '; box.focus(); }
@@ -847,7 +1002,7 @@
847
1002
  if (ans && ans.item) mergeItems([ans.item]);
848
1003
  // A reminder action answers the finished item itself (merged above); dismiss answers counts only.
849
1004
  if (action === 'dismiss') item.state = 'done';
850
- if (ans && ans.next && ans.next.kind === 'ask' && C.caps && C.caps.jobs) submitAsk(ans.next.prompt, ans.drop.id);
1005
+ if (ans && ans.next && ans.next.kind === 'ask' && C.caps && C.caps.jobs) submitAsk(ans.next.prompt, ans.drop.id, null, askFilesOf(ans.next));
851
1006
  paintFeed(false); refreshSummary();
852
1007
  }, function (err) { W.toast((err && err.message) || 'That did not go through.'); });
853
1008
  }
@@ -876,13 +1031,599 @@
876
1031
  api('/api/query-jobs/' + a.jobId + '/cancel', { method: 'POST', body: { generation: 1 } }).then(function (d) { if (d && d.job) take(a, d.job); paintAsk(a); }, function (err) { W.toast((err && err.message) || 'Cancel did not go through.'); });
877
1032
  }
878
1033
 
1034
+ // ── Prep (6.99.19, PREP_CONTRACT_v1) ──────────────────
1035
+ // With capabilities.prep on the Mac, "Prep me" on a prep card opens this composer above the chat's own: what the prep
1036
+ // should focus on, and files, the way the live meeting view takes them. It is a server draft (POST /api/prep/drafts,
1037
+ // the clientDraftId kept in this browser, so opening it again resumes it through GET). Every write sends the draft's
1038
+ // revision; a 409 revision_conflict hands back the newer draft, which is shown, with one line saying so. Prep me
1039
+ // posts .../run, and the job it returns is drawn and polled in the chat exactly like an Ask (take, poll, and the
1040
+ // by-client lookup when the answer to the run was lost). Files reach a meeting only from that meeting's live view.
1041
+ var PREP_STORE = 'cos.web.prepDrafts';
1042
+ var PREP_ACCEPT = 'image/*,.heic,.heif,application/pdf,.pdf,text/plain,.txt,text/markdown,.md,text/csv,.csv,application/json,.json,.xlsx';
1043
+ var PREP_SAVE_MS = 700;
1044
+ var PREP_FILE_STATE = { unsupported: 'No readable text', failed: 'Could not be read', 'secret-held': 'Held: it appears to contain credentials' };
1045
+ // Amendment 19: every Prep write names the server it expects (X-COS-Expected-Server-Instance), the id last read from
1046
+ // capabilities.prep or the most recent Prep answer (every one carries it). The id survives restarts; a different id
1047
+ // means a different COS server answered (409 server_instance_mismatch).
1048
+ var PREP_INST = '';
1049
+ var P = null; // the open composer: { key, title, startsAt, draft, steer, dirty, ups, note, warn, running, runId, chain, timer, conflicts }
1050
+ function prepCaps() { return C.caps && C.caps.prep ? C.caps.prep : null; }
1051
+ function prepOn() { return !!prepCaps(); }
1052
+ function prepMax() { var c = prepCaps(); return c ? c.maxFiles : 10; }
1053
+ function prepChars() { var c = prepCaps(); return c ? c.steerChars : 4000; }
1054
+ function prepRec(key) { var r = W.readStore(PREP_STORE)[key]; return r && typeof r === 'object' && typeof r.cd === 'string' ? r : null; }
1055
+ function savePrepRec(key, rec) {
1056
+ var all = W.readStore(PREP_STORE);
1057
+ all[key] = rec;
1058
+ // The newest twenty: a prep card a week old has no reason to be found again here.
1059
+ var keys = Object.keys(all).sort(function (a, b) { return (Number(all[a] && all[a].at) || 0) - (Number(all[b] && all[b].at) || 0); });
1060
+ while (keys.length > 20) delete all[keys.shift()];
1061
+ W.writeStore(PREP_STORE, all);
1062
+ }
1063
+ function forgetPrepRec(key) { var all = W.readStore(PREP_STORE); delete all[key]; W.writeStore(PREP_STORE, all); }
1064
+ // The run in flight, kept with the draft in this browser (closing the composer or reloading the page does not lose it):
1065
+ // its clientRequestId and its own model, effort and continue, so a retry is the same request and the server re-drives it.
1066
+ function setPendingRun(p, run) {
1067
+ p.pending = run;
1068
+ var rec = prepRec(p.key); if (!rec) return;
1069
+ if (run) rec.run = run; else delete rec.run;
1070
+ savePrepRec(p.key, rec);
1071
+ }
1072
+ /** A Prep route, answered as { status, body } (a 409 carries the newer draft). Never rejects: no answer is status 0. */
1073
+ function prepCall(path, opts) {
1074
+ var o = opts || {}, method = o.method || 'GET';
1075
+ var headers = { 'X-COS-Web': '1' };
1076
+ if (o.body !== undefined) headers['Content-Type'] = 'application/json';
1077
+ if (method !== 'GET' && PREP_INST) headers['X-COS-Expected-Server-Instance'] = PREP_INST;
1078
+ var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
1079
+ var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, o.timeoutMs || (method === 'GET' ? 20000 : 30000)) : null;
1080
+ return fetch(path, { method: method, headers: headers, credentials: 'same-origin', body: o.body !== undefined ? JSON.stringify(o.body) : undefined, signal: ctrl ? ctrl.signal : undefined })
1081
+ .then(function (res) {
1082
+ return res.text().then(function (t) {
1083
+ clearTimeout(timer); var b = {}; try { b = t ? JSON.parse(t) : {}; } catch (_e) { b = {}; }
1084
+ b = b || {};
1085
+ var inst = typeof b.serverInstanceId === 'string' && b.serverInstanceId ? b.serverInstanceId : b.draft && typeof b.draft.serverInstanceId === 'string' ? b.draft.serverInstanceId : '';
1086
+ // The server's id as it answers now: from a read, a write it took, or the refusal that says it changed.
1087
+ if (inst && (res.status < 300 || (res.status === 409 && prepCode({ body: b }) === 'server_instance_mismatch'))) PREP_INST = inst;
1088
+ if (res.status === 428 && typeof console !== 'undefined') console.error('[prep] a write went without X-COS-Expected-Server-Instance', method, path);
1089
+ return { status: res.status, body: b };
1090
+ });
1091
+ })
1092
+ .catch(function () { clearTimeout(timer); return { status: 0, body: {} }; });
1093
+ }
1094
+ // sha256 of the bytes this browser uploaded (amendment 20). crypto.subtle needs a secure context, and a plain-http
1095
+ // tailnet page is not one, so a small FIPS 180-4 implementation stands in there.
1096
+ function sha256Hex(bytes) {
1097
+ var subtle = typeof crypto !== 'undefined' && crypto.subtle && (typeof window === 'undefined' || window.isSecureContext !== false) ? crypto.subtle : null;
1098
+ if (subtle) return subtle.digest('SHA-256', bytes).then(function (h) { return hex(new Uint8Array(h)); }, function () { return sha256Js(bytes); });
1099
+ return Promise.resolve(sha256Js(bytes));
1100
+ }
1101
+ function hex(a) { return Array.prototype.map.call(a, function (x) { return (x + 0x100).toString(16).slice(1); }).join(''); }
1102
+ var SHA_K = [0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
1103
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
1104
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
1105
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2];
1106
+ function sha256Js(bytes) {
1107
+ var len = bytes.length, total = ((len + 9 + 63) >> 6) << 6, m = new Uint8Array(total);
1108
+ m.set(bytes); m[len] = 0x80;
1109
+ var bits = len * 8, dv = new DataView(m.buffer);
1110
+ dv.setUint32(total - 8, Math.floor(bits / 0x100000000)); dv.setUint32(total - 4, bits >>> 0);
1111
+ var h = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19], w = new Array(64);
1112
+ var rotr = function (x, n) { return (x >>> n) | (x << (32 - n)); };
1113
+ for (var off = 0; off < total; off += 64) {
1114
+ for (var i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4);
1115
+ for (i = 16; i < 64; i++) {
1116
+ var s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3), s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
1117
+ w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
1118
+ }
1119
+ var a = h[0], b = h[1], c = h[2], d = h[3], e = h[4], f = h[5], g = h[6], k = h[7];
1120
+ for (i = 0; i < 64; i++) {
1121
+ var t1 = (k + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + SHA_K[i] + w[i]) | 0;
1122
+ var t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) | 0;
1123
+ k = g; g = f; f = e; e = (d + t1) | 0; d = c; c = b; b = a; a = (t1 + t2) | 0;
1124
+ }
1125
+ h[0] = (h[0] + a) | 0; h[1] = (h[1] + b) | 0; h[2] = (h[2] + c) | 0; h[3] = (h[3] + d) | 0; h[4] = (h[4] + e) | 0; h[5] = (h[5] + f) | 0; h[6] = (h[6] + g) | 0; h[7] = (h[7] + k) | 0;
1126
+ }
1127
+ return h.map(function (x) { return ('00000000' + (x >>> 0).toString(16)).slice(-8); }).join('');
1128
+ }
1129
+ /** The exact bytes the media upload sent: the resized JPEG for a photo (uploadMedia keeps it as t.thumb), else the file. */
1130
+ function uploadedBytes(t) {
1131
+ if (t.kind === 'photo' && /^data:/.test(t.thumb || '')) {
1132
+ var bin = atob(t.thumb.slice(t.thumb.indexOf(',') + 1)), out = new Uint8Array(bin.length);
1133
+ for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
1134
+ return Promise.resolve(out);
1135
+ }
1136
+ if (t.file && typeof t.file.arrayBuffer === 'function') return t.file.arrayBuffer().then(function (b) { return new Uint8Array(b); });
1137
+ return Promise.reject(new Error('This browser could not read the file again to check it. Remove it and add it again.'));
1138
+ }
1139
+ function prepCode(r) { var b = (r && r.body) || {}; return typeof b.error === 'string' ? b.error : (b.error && b.error.code) || b.code || b.reason || ''; }
1140
+ var PREP_CONFLICT = 'This prep changed on another device. It now shows the newest notes and files. Check them, then try again.';
1141
+ function prepErrorText(r) {
1142
+ var code = prepCode(r);
1143
+ if (r.status === 0) return 'COS on your Mac did not answer. Check that it is awake, then try again.';
1144
+ if (r.status === 401) return 'This browser was signed out. Your words are kept here. Sign in, then try again.';
1145
+ if (code === 'server_instance_mismatch') return 'The COS server on your Mac changed. Close this prep and tap Prep me on the card again.';
1146
+ if (r.status === 428) return 'This page did not name the Mac\u2019s COS server, so nothing was changed. Reload the page, then try again.';
1147
+ if (code === 'media_hash_mismatch') return 'The file your Mac kept does not match what this browser sent. Retry to send it again.';
1148
+ if (code === 'maintenance' || r.status === 503) return 'COS is updating. Try again in a minute.';
1149
+ if (code === 'prep_file_limit' || r.status === 413) return 'This prep holds ' + prepMax() + ' files. Remove one, then retry.';
1150
+ if (code === 'prep_draft_limit') return 'COS already keeps as many preps as it can. Open an old prep card, tap Archive this prep, then try again.';
1151
+ if (r.status === 507) return 'COS is out of room for prep files. Remove some files from older preps, then retry.';
1152
+ if (code === 'durable_query_jobs_disabled') return 'Prep me needs the durable query jobs switch on your Mac. Your notes and files are saved.';
1153
+ if (/^media_/.test(code)) return 'The upload expired before COS kept it. Retry.';
1154
+ if (r.status === 404) return 'This prep is no longer on your Mac. Close it and tap Prep me on the card again.';
1155
+ var msg = r.body && typeof r.body.message === 'string' ? r.body.message : '';
1156
+ return msg ? msg : 'COS did not take that (HTTP ' + r.status + '). Try again.';
1157
+ }
1158
+ function prepQueue(p, fn) { var next = p.chain.then(fn); p.chain = next.catch(function () {}); return next; }
1159
+ function prepPath(p) { return '/api/prep/drafts/' + encodeURIComponent(p.draft.id); }
1160
+ function prepFiles(d) { return ((d && d.files) || []).filter(function (f) { return f && f.status !== 'removed'; }); }
1161
+ function prepUploading(p) { return p.ups.some(function (t) { return t.state !== 'failed'; }); }
1162
+ function prepWhen(iso) { return iso && isFinite(Date.parse(iso)) ? timeLabel(iso) : ''; }
1163
+
1164
+ function openPrep(item) {
1165
+ var act = ((item && item.actions) || []).filter(function (a) { return a && a.id === 'prep'; })[0] || {};
1166
+ var info = (act.prep && typeof act.prep === 'object' ? act.prep : item.prep && typeof item.prep === 'object' ? item.prep : {}) || {};
1167
+ var key = 'item:' + item.id;
1168
+ if (P && P.key === key) { paintPrep(); focusPrep(); return; }
1169
+ if (P) closePrep();
1170
+ P = { key: key, title: clip(info.title || item.title || 'my next meeting', 200), startsAt: typeof info.startsAt === 'string' ? info.startsAt : null,
1171
+ draft: null, steer: '', dirty: false, ups: [], note: '', warn: false, running: false, pending: null, chain: Promise.resolve(), timer: null, conflicts: 0, loading: true };
1172
+ paintPrep(true); focusPrep();
1173
+ // On a phone the page scrolls: bring the composer up to where the card was tapped.
1174
+ var slot = prepSlot(false); if (slot && slot.scrollIntoView && !wide()) slot.scrollIntoView({ block: 'start' });
1175
+ loadPrep(P);
1176
+ }
1177
+ function closePrep() {
1178
+ if (!P) return;
1179
+ var p = P;
1180
+ // Words typed and not yet saved go out once more on the way.
1181
+ if (p.dirty && p.draft) savePrepSteer(p);
1182
+ clearTimeout(p.timer);
1183
+ p.ups.forEach(function (t) { if (t.xhr) { try { t.xhr.abort(); } catch (_e) {} } });
1184
+ P = null;
1185
+ paintPrep();
1186
+ }
1187
+ // Resume when this browser has the draft; otherwise create it (the same clientDraftId answers the same draft).
1188
+ function loadPrep(p) {
1189
+ var rec = prepRec(p.key);
1190
+ var run = rec && rec.run;
1191
+ if (run && typeof run.rid === 'string') p.pending = { rid: run.rid, model: run.model || '', effort: run.effort || '', cont: !!run.cont };
1192
+ var create = function (cd) {
1193
+ savePrepRec(p.key, { cd: cd, id: '', at: Date.now() });
1194
+ var body = { clientDraftId: cd, title: p.title };
1195
+ if (p.startsAt) body.startsAt = p.startsAt;
1196
+ return prepCall('/api/prep/drafts', { method: 'POST', body: body });
1197
+ };
1198
+ var step = rec && rec.id ? prepCall('/api/prep/drafts/' + encodeURIComponent(rec.id)).then(function (r) {
1199
+ return r.status === 404 ? create(uuid()) : r;
1200
+ }) : create(rec ? rec.cd : uuid());
1201
+ return step.then(function (r) {
1202
+ p.loading = false;
1203
+ if (r.status >= 200 && r.status < 300 && r.body.draft) { adoptPrep(p, r.body.draft, false); if (p.dirty) schedulePrepSave(p); return; }
1204
+ setPrepNote(p, prepErrorText(r), true);
1205
+ });
1206
+ }
1207
+ function adoptPrep(p, draft, conflict) {
1208
+ p.draft = draft;
1209
+ var rec = prepRec(p.key);
1210
+ var next = { cd: (draft && draft.clientDraftId) || (rec && rec.cd) || '', id: draft.id, at: Date.now() };
1211
+ if (rec && rec.run) next.run = rec.run;
1212
+ savePrepRec(p.key, next);
1213
+ if (conflict || !p.dirty) {
1214
+ p.steer = typeof draft.steer === 'string' ? draft.steer : '';
1215
+ if (conflict) { p.dirty = false; clearTimeout(p.timer); p.conflicts++; }
1216
+ var box = P === p && prepBox();
1217
+ if (box && box.value !== p.steer) { box.value = p.steer; W.autoGrow(box); }
1218
+ }
1219
+ if (conflict) setPrepNote(p, PREP_CONFLICT, true);
1220
+ paintPrep();
1221
+ }
1222
+ /** A write to the draft: the answer's draft is adopted (a 409 revision_conflict's too, with the line that says so). */
1223
+ function prepWrite(p, method, path, body) {
1224
+ return prepCall(path, { method: method, body: body }).then(function (r) {
1225
+ var conflict = r.status === 409 && prepCode(r) === 'revision_conflict';
1226
+ if (r.body && r.body.draft && (conflict || (r.status >= 200 && r.status < 300))) adoptPrep(p, r.body.draft, conflict);
1227
+ if (r.status === 409 && prepCode(r) === 'server_instance_mismatch') { r.mismatch = true; reloadAfterServerChange(p); }
1228
+ return r;
1229
+ });
1230
+ }
1231
+ // A different COS server answered than the one this prep was read from: say so, read the draft again,
1232
+ // and keep words not yet saved, which go out once the draft is read.
1233
+ var PREP_SERVER_CHANGED = 'The COS server on your Mac changed since this prep opened. It was read again. Check it, then try again.';
1234
+ function reloadAfterServerChange(p) {
1235
+ setPrepNote(p, PREP_SERVER_CHANGED, true);
1236
+ if (p.reloading || !p.draft) return;
1237
+ p.reloading = true;
1238
+ prepCall(prepPath(p)).then(function (r) {
1239
+ p.reloading = false;
1240
+ if (r.status === 200 && r.body.draft) { adoptPrep(p, r.body.draft, false); if (p.dirty) schedulePrepSave(p); }
1241
+ });
1242
+ }
1243
+ function schedulePrepSave(p) { clearTimeout(p.timer); p.timer = setTimeout(function () { savePrepSteer(p); }, PREP_SAVE_MS); }
1244
+ function savePrepSteer(p) {
1245
+ clearTimeout(p.timer); p.timer = null;
1246
+ return prepQueue(p, function () {
1247
+ if (!p.dirty || !p.draft) return null;
1248
+ var text = p.steer; p.dirty = false;
1249
+ return prepWrite(p, 'PATCH', prepPath(p), { revision: p.draft.revision, steer: text }).then(function (r) {
1250
+ if (r.mismatch) { if (p.steer === text) p.dirty = true; return r; }
1251
+ if ((r.status >= 200 && r.status < 300) || r.status === 409) return r;
1252
+ // Kept, and sent again with the next change or with Prep me.
1253
+ if (p.steer === text) p.dirty = true;
1254
+ setPrepNote(p, 'Your notes are not saved yet. ' + prepErrorText(r), true);
1255
+ return r;
1256
+ });
1257
+ });
1258
+ }
1259
+ function onPrepInput(box) {
1260
+ var p = P; if (!p) return;
1261
+ var max = prepChars();
1262
+ if (box.value.length > max) box.value = box.value.slice(0, max);
1263
+ p.steer = box.value; p.dirty = true;
1264
+ W.autoGrow(box);
1265
+ schedulePrepSave(p);
1266
+ paintPrepParts(p);
1267
+ }
1268
+
1269
+ // Files: the composer's own upload (uploadMedia), then POST .../files with the draft's revision.
1270
+ function addPrepFiles(list) {
1271
+ var p = P; if (!p) return;
1272
+ var files = Array.prototype.slice.call(list || []), refusals = [];
1273
+ if (!p.draft) { setPrepNote(p, 'This prep is still opening. Add the files again in a moment.', true); return; }
1274
+ files.forEach(function (file) {
1275
+ if (prepFiles(p.draft).length + p.ups.length >= prepMax()) { refusals.push('A prep holds ' + prepMax() + ' files. ' + (file.name || 'A file') + ' was not added.'); return; }
1276
+ var c = classify(file);
1277
+ if (c.refuse) { refusals.push(c.refuse); return; }
1278
+ if (c.kind === 'video') { refusals.push((file.name || 'That video') + ' is a video. Prep reads documents and the text in images, so attach a PDF, a text file or a screenshot.'); return; }
1279
+ var t = { id: uuid(), file: file, name: file.name || (c.kind === 'photo' ? 'Screenshot' : 'File'), size: file.size, kind: c.kind, mime: c.mime || '', state: 'reading', progress: 0, mediaId: '', error: '' };
1280
+ p.ups.push(t);
1281
+ sendPrepFile(p, t);
1282
+ });
1283
+ setPrepNote(p, refusals.join(' '), !!refusals.length);
1284
+ paintPrep();
1285
+ }
1286
+ function sendPrepFile(p, t) {
1287
+ t.state = t.mediaId ? 'adding' : 'reading'; t.progress = 0; t.error = '';
1288
+ var step = t.mediaId ? Promise.resolve({ id: t.mediaId }) : uploadMedia(t, {
1289
+ uploading: function () { t.state = 'uploading'; paintPrep(); },
1290
+ progress: function (x) { t.progress = x; paintPrepProgress(t); }
1291
+ });
1292
+ paintPrep();
1293
+ step.then(function (ref) {
1294
+ if (p.ups.indexOf(t) < 0) return null;
1295
+ if (!ref || !ref.id) { var e = new Error('COS did not keep the file. Retry.'); e.code = 'kept'; throw e; }
1296
+ t.mediaId = ref.id; t.state = 'adding'; paintPrep();
1297
+ // Amendment 20: the sha256 of exactly the bytes uploaded, which the server checks its copy against.
1298
+ return t.sha256 ? null : uploadedBytes(t).then(sha256Hex).then(function (h) { t.sha256 = h; });
1299
+ }).then(function () {
1300
+ if (p.ups.indexOf(t) < 0 || !t.mediaId) return null;
1301
+ // v1.1: one operationId per add, the same on a retry after a lost answer (the server answers the first result).
1302
+ t.opId = t.opId || uuid();
1303
+ return prepQueue(p, function () {
1304
+ var path = prepPath(p) + '/files';
1305
+ var body = function () { return { operationId: t.opId, revision: p.draft.revision, media: [{ id: t.mediaId, name: t.name.slice(0, 240), sha256: t.sha256 }] }; };
1306
+ return prepWrite(p, 'POST', path, body()).then(function (r) { return r.status === 0 ? prepWrite(p, 'POST', path, body()) : r; });
1307
+ });
1308
+ }).then(function (r) {
1309
+ if (!r || p.ups.indexOf(t) < 0) return;
1310
+ if (r.status >= 200 && r.status < 300) { p.ups = p.ups.filter(function (x) { return x !== t; }); paintPrep(); return; }
1311
+ // Refused (a 4xx), so nothing was recorded: Retry is a new operation. No answer or a 5xx: Retry is the same one.
1312
+ if (r.status >= 400 && r.status < 500) t.opId = '';
1313
+ // The staged copy is gone or differs: Retry uploads the bytes again.
1314
+ var code = prepCode(r);
1315
+ if (code === 'media_hash_mismatch' || /^media_(not_found|expired)$/.test(code)) { t.mediaId = ''; t.sha256 = ''; }
1316
+ t.state = 'failed';
1317
+ t.error = r.mismatch ? 'Not added: the Mac\u2019s COS server changed. Retry.' : r.status === 409 && code === 'revision_conflict' ? 'Not added: the prep changed. Retry.' : prepErrorText(r);
1318
+ paintPrep();
1319
+ }).catch(function (err) {
1320
+ if (p.ups.indexOf(t) < 0 || (err && err.code === 'aborted')) return;
1321
+ t.state = 'failed';
1322
+ t.error = err && err.code === 'kept' ? err.message : err && err.status === 401 ? 'Signed out. Sign in, then retry.' : uploadErrorText(err);
1323
+ paintPrep();
1324
+ });
1325
+ }
1326
+ function removePrepFile(p, fileId) {
1327
+ if (!p.draft) return;
1328
+ prepQueue(p, function () {
1329
+ return prepWrite(p, 'DELETE', prepPath(p) + '/files/' + encodeURIComponent(fileId) + '?revision=' + encodeURIComponent(p.draft.revision));
1330
+ }).then(function (r) { if (r && !(r.status >= 200 && r.status < 300) && r.status !== 409) setPrepNote(p, 'Not removed. ' + prepErrorText(r), true); });
1331
+ }
1332
+
1333
+ // Prep me: the notes saved first, then .../run. The run in flight (its clientRequestId and its own params) is kept with
1334
+ // the draft until its outcome is known, so the next tap, a close or a reload resends the same request.
1335
+ function runPrep(p) {
1336
+ if (!p || !p.draft || p.running || prepUploading(p)) return;
1337
+ p.running = true; setPrepNote(p, '', false); paintPrep();
1338
+ var seen = p.conflicts;
1339
+ if (p.dirty) savePrepSteer(p);
1340
+ prepQueue(p, function () {
1341
+ // The notes changed elsewhere, or did not save: nothing runs on words the person has not seen.
1342
+ if (p.conflicts !== seen) return { status: -1, body: {} };
1343
+ if (p.dirty) return { status: -2, body: {} };
1344
+ if (!p.pending) {
1345
+ var cont = !!((p.draft.runs || []).length && p.draft.conversation && p.draft.conversation.sessionId);
1346
+ setPendingRun(p, { rid: uuid(), model: C.model || '', effort: C.effort || '', cont: cont });
1347
+ }
1348
+ return postRun(p, p.pending, true);
1349
+ }).then(function (r) { return settleRun(p, r, true); });
1350
+ }
1351
+ function runBody(p, run) {
1352
+ var body = { clientRequestId: run.rid, revision: p.draft.revision };
1353
+ if (run.model) body.model = run.model;
1354
+ if (run.effort) body.effort = run.effort;
1355
+ if (run.cont) body.continue = true;
1356
+ return body;
1357
+ }
1358
+ function postRun(p, run, retryLost) {
1359
+ var path = prepPath(p) + '/run';
1360
+ return prepWrite(p, 'POST', path, runBody(p, run)).then(function (r) { return r.status === 0 && retryLost ? prepWrite(p, 'POST', path, runBody(p, run)) : r; });
1361
+ }
1362
+ function settleRun(p, r, mayRedrive) {
1363
+ p.running = false;
1364
+ var code = prepCode(r), run = r.body && r.body.run;
1365
+ if (r.status === 202 || r.status === 200) { setPendingRun(p, null); return ended(p, run, r.body.job) || startPrepJob(p, run || {}, r.body.job || null); }
1366
+ if (r.status === 0) return recoverPrepRun(p, p.pending ? p.pending.rid : '');
1367
+ // One run at a time per draft. Still being submitted: send THAT run again, with its own params (the server
1368
+ // re-drives it). Already taken: show it, as its own Ask, instead of a second one.
1369
+ if (r.status === 409 && code === 'run_in_progress' && run) {
1370
+ if (run.state === 'submitting' && typeof run.clientRequestId === 'string' && mayRedrive) {
1371
+ setPendingRun(p, { rid: run.clientRequestId, model: run.model || '', effort: run.effort || '', cont: !!run.continue });
1372
+ p.running = true; paintPrep();
1373
+ return prepQueue(p, function () { return postRun(p, p.pending, true); }).then(function (r2) { return settleRun(p, r2, false); });
1374
+ }
1375
+ if (run.clientJobId) { setPendingRun(p, null); return startPrepJob(p, run, null); }
1376
+ }
1377
+ // The run failed on the Mac: say so plainly; the next tap is a fresh run.
1378
+ if (r.status === 409 && code === 'run_failed') {
1379
+ setPendingRun(p, null);
1380
+ var why = run && typeof run.error === 'string' && run.error ? run.error.replace(/_/g, ' ') : '';
1381
+ setPrepNote(p, 'This prep did not start on your Mac' + (why ? ' (' + why + ')' : '') + '. Tap Prep me to try again.', true);
1382
+ return paintPrep();
1383
+ }
1384
+ // No answer the Mac could vouch for (503, run_status_unknown): keep the run; the next tap is the same request.
1385
+ if (r.status >= 500) { setPrepNote(p, prepErrorText(r) + ' Tap Prep me again; it will not run twice.', true); return paintPrep(); }
1386
+ // Refused, so no run exists: the next tap is a new request. (-1 and -2: stopped here; the note already says why.)
1387
+ if (r.status > 0) setPendingRun(p, null);
1388
+ if (r.status !== -1 && r.status !== -2 && r.status !== 409) setPrepNote(p, prepErrorText(r), true);
1389
+ paintPrep();
1390
+ }
1391
+ // The run's answer was lost twice. v1.1: GET .../runs/<clientRequestId> answers {run, job} when the run was taken and
1392
+ // 404 run_not_accepted when it was not. A server without that route: the draft lists its runs, and the run's
1393
+ // clientJobId finds the job (/api/query-jobs/by-client).
1394
+ // 202 {run}: the Mac is still admitting it, so ask again shortly (a few times). 503 run_status_unknown: the Mac could
1395
+ // not tell; the next tap is the same request, so it cannot run twice.
1396
+ var PREP_RUN_WAITS = [1500, 1500, 3000, 3000, 5000];
1397
+ function recoverPrepRun(p, rid, tries) {
1398
+ tries = tries || 0;
1399
+ return prepCall(prepPath(p) + '/runs/' + encodeURIComponent(rid)).then(function (r) {
1400
+ if (r.status === 200 && r.body.run && (r.body.run.clientJobId || r.body.run.outcome)) { setPendingRun(p, null); return ended(p, r.body.run, r.body.job) || startPrepJob(p, r.body.run, r.body.job || null); }
1401
+ if (r.status === 202 && tries < PREP_RUN_WAITS.length) {
1402
+ setPrepNote(p, 'Your Mac is starting this prep.', false); p.running = true; paintPrep();
1403
+ return new Promise(function (res) { setTimeout(res, PREP_RUN_WAITS[tries]); }).then(function () { p.running = false; return recoverPrepRun(p, rid, tries + 1); });
1404
+ }
1405
+ if (r.status === 202 || (r.status === 404 && prepCode(r) === 'run_not_accepted') || (r.status === 503 && prepCode(r) === 'run_status_unknown')) return notStarted(p);
1406
+ return recoverFromDraft(p, rid);
1407
+ });
1408
+ }
1409
+ // A run that already ended and whose job the Mac no longer keeps (outcome 'unavailable', job null): there is nothing
1410
+ // to poll. Its copied answer is shown when the Mac kept one; otherwise one line says so. The stored run is released
1411
+ // by the caller, so the next tap is a fresh run.
1412
+ function ended(p, run, job) {
1413
+ if (!run || job || !(run.outcome === 'unavailable' || run.settledAt)) return false;
1414
+ if (typeof run.answer === 'string' && run.answer) {
1415
+ var a = { cid: run.clientJobId || uuid(), query: prepQuery(p), at: Date.now(), status: 'completed', partial: '', response: run.answer, dropId: '', replyKey: '', prepDraftId: p.draft.id, atts: [] };
1416
+ C.asks.push(a);
1417
+ if (P === p) closePrep();
1418
+ paintFeed(true);
1419
+ return true;
1420
+ }
1421
+ setPrepNote(p, 'This prep finished earlier, but its answer is no longer kept. Tap Prep me to run it again.', true);
1422
+ paintPrep();
1423
+ return true;
1424
+ }
1425
+ function notStarted(p) {
1426
+ setPrepNote(p, 'COS on your Mac did not answer, so this prep may not have started. Tap Prep me again; it will not run twice.', true);
1427
+ paintPrep();
1428
+ }
1429
+ function recoverFromDraft(p, rid) {
1430
+ return prepCall(prepPath(p)).then(function (r) {
1431
+ if (r.status === 200 && r.body.draft) adoptPrep(p, r.body.draft, false);
1432
+ var run = r.status === 200 ? (((r.body.draft || {}).runs) || []).filter(function (x) { return x && x.clientRequestId === rid; })[0] : null;
1433
+ if (run && run.clientJobId) { setPendingRun(p, null); return startPrepJob(p, run, null); }
1434
+ notStarted(p);
1435
+ });
1436
+ }
1437
+ // v1.1: the job is an ordinary web Ask titled "Prep: <title>"; the chat shows the same words, and the focus under them.
1438
+ function prepQuery(p) {
1439
+ var steer = String((p.draft && p.draft.steer) || '').trim();
1440
+ return 'Prep: ' + p.title + (steer ? '\n' + clip(steer, 160) : '');
1441
+ }
1442
+ function startPrepJob(p, run, job) {
1443
+ var cid = (run && run.clientJobId) || (job && job.clientJobId) || '';
1444
+ var a = { cid: cid || uuid(), query: prepQuery(p), at: Date.now(), status: 'sending', partial: '', dropId: '', replyKey: '', prepDraftId: p.draft.id,
1445
+ atts: prepFiles(p.draft).filter(function (f) { return f.status === 'ready'; }).map(function (f) { return { name: f.name, kind: f.kind === 'image' ? 'photo' : 'doc' }; }) };
1446
+ C.asks.push(a);
1447
+ if (P === p) closePrep();
1448
+ paintFeed(true);
1449
+ if (job && job.jobId) { take(a, job); paintAsk(a); poll(a); return a; }
1450
+ if (!cid) { a.status = 'failed'; a.error = 'Not sent: the Mac did not say which answer is this prep. Tap Prep me on the card again.'; paintFeed(false); return a; }
1451
+ api('/api/query-jobs/by-client/' + encodeURIComponent(cid) + '?generation=1').then(function (d) {
1452
+ if (!d || !d.job) throw new Error('the Mac did not find the prep answer');
1453
+ take(a, d.job); paintAsk(a); poll(a);
1454
+ }).catch(function (err) {
1455
+ a.status = 'failed';
1456
+ a.error = 'Not sent: ' + ((err && err.message) || 'the Mac did not answer') + '. Tap Prep me on the card again; it will not run twice.';
1457
+ paintFeed(false);
1458
+ });
1459
+ return a;
1460
+ }
1461
+
1462
+ // Archive this prep (DELETE /api/prep/drafts/:id, the server keeps it as archived): it frees one of the drafts the Mac
1463
+ // keeps, and the card's Prep me then starts a new one. Nothing a meeting already took is touched.
1464
+ function archivePrep(p) {
1465
+ if (!p.draft || p.running || p.archiving) return;
1466
+ p.archiving = true; setPrepNote(p, 'Archiving this prep…', false);
1467
+ clearTimeout(p.timer);
1468
+ prepQueue(p, function () {
1469
+ return prepCall(prepPath(p) + '?revision=' + encodeURIComponent(p.draft.revision), { method: 'DELETE' });
1470
+ }).then(function (r) {
1471
+ p.archiving = false;
1472
+ if (r.status >= 200 && r.status < 300) {
1473
+ forgetPrepRec(p.key);
1474
+ p.dirty = false; p.pending = null;
1475
+ if (P === p) closePrep();
1476
+ W.toast('Prep archived. Prep me on the card starts a new one.');
1477
+ return;
1478
+ }
1479
+ var conflict = r.status === 409 && prepCode(r) === 'revision_conflict';
1480
+ if (conflict && r.body.draft) return adoptPrep(p, r.body.draft, true);
1481
+ setPrepNote(p, 'Not archived. ' + prepErrorText(r), true);
1482
+ });
1483
+ }
1484
+
1485
+ // ── the Prep composer on the page ─────────────────────
1486
+ // It sits above the chat's composer, outside the feed, so a poll that redraws the feed never takes the words or the
1487
+ // caret. Built once per draft; after that only its parts are written.
1488
+ function prepSlot(create) {
1489
+ var chat = C.host && C.host.querySelector && C.host.querySelector('[data-cos-chat]');
1490
+ if (!chat) return null;
1491
+ var slot = chat.querySelector('[data-cos-prep]');
1492
+ if (!P) { if (slot) slot.remove(); return null; }
1493
+ if (!slot && create !== false) {
1494
+ slot = document.createElement('div');
1495
+ slot.className = 'cc-prep'; slot.setAttribute('data-cos-prep', '');
1496
+ chat.insertBefore(slot, chat.querySelector('[data-cos-form="chat"]'));
1497
+ }
1498
+ return slot;
1499
+ }
1500
+ function prepBox() { var slot = prepSlot(false); return slot && slot.querySelector('[data-cos-prep-steer]'); }
1501
+ function focusPrep() { var box = prepBox(); if (box && keyboardFirst()) box.focus(); }
1502
+ function prepHeadLine(p) {
1503
+ var when = prepWhen(p.startsAt);
1504
+ return (when ? 'Starts ' + when + '. ' : '') + 'Add what matters and any files, then Prep me. Add them to the meeting from its live view.';
1505
+ }
1506
+ function prepPanelHtml(p) {
1507
+ return '<section class="mt-panel cc-prep-panel" aria-label="' + esc('Prep for ' + p.title) + '">' +
1508
+ '<div class="mt-ask-head"><h3 class="mt-ask-title cc-prep-title">' + esc('Prep for ' + p.title) + '</h3>' +
1509
+ '<span class="cc-prep-head-acts"><button type="button" class="mt-link" data-cos="prep-archive">Archive this prep</button>' +
1510
+ '<button type="button" class="mt-link" data-cos="prep-close">Close</button></span></div>' +
1511
+ '<p class="mt-ask-line">' + esc(prepHeadLine(p)) + '</p>' +
1512
+ '<form class="mt-composer cc-prep-form" data-cos-prep-form autocomplete="off">' +
1513
+ '<textarea class="field" rows="3" data-cos-prep-steer maxlength="' + prepChars() + '" placeholder="What should this prep focus on?" aria-label="What should this prep focus on?">' + esc(p.steer) + '</textarea></form>' +
1514
+ '<div class="cc-prep-acts"><span class="mt-muted cc-prep-count" data-prep-count>' + esc(prepCountText(p)) + '</span>' +
1515
+ '<label class="cx-btn quiet mt-addfile cc-prep-add" title="Add a screenshot or files to this prep">' + '<svg class="icon" aria-hidden="true"><use href="#i-clip"/></svg><span>Add file</span>' +
1516
+ '<input type="file" class="cc-file" multiple accept="' + PREP_ACCEPT + '" data-cos-prep-file aria-label="Add files to this prep"></label>' +
1517
+ '<button type="button" class="cx-btn primary" data-cos="prep-run">' + esc(prepRunLabel(p)) + '</button></div>' +
1518
+ '<p class="' + (p.warn ? 'mt-amber-line' : 'mt-muted') + ' cc-prep-note" data-prep-note role="status"' + (p.note ? '' : ' hidden') + '>' + esc(p.note) +
1519
+ (p.signedOut ? ' <button type="button" class="link-btn cc-text-btn" data-cos="sign-in">Sign in again</button>' : '') + '</p>' +
1520
+ // The box and Prep me first, so both show as soon as it opens; the files list under them grows downward.
1521
+ '<div data-prep-files>' + prepFilesHtml(p) + '</div>' +
1522
+ '</section>';
1523
+ }
1524
+ function prepFileMeta(f) {
1525
+ if (f.status === 'ready') return [f.notice || '', Number(f.bytes) > 0 ? sizeLabel(Number(f.bytes)) : ''].filter(Boolean).join(' · ');
1526
+ return f.notice || PREP_FILE_STATE[f.status] || '';
1527
+ }
1528
+ function prepUpMeta(t) {
1529
+ if (t.state === 'reading') return t.kind === 'photo' ? 'Preparing the image' : 'Preparing';
1530
+ if (t.state === 'uploading') return 'Uploading ' + Math.round(t.progress * 100) + '%';
1531
+ if (t.state === 'adding') return t.kind === 'photo' ? 'Reading the text in the image' : 'Adding to the prep';
1532
+ return t.error || 'Did not upload';
1533
+ }
1534
+ function prepFilesHtml(p) {
1535
+ var files = prepFiles(p.draft), ups = p.ups;
1536
+ var title = p.loading ? 'Opening this prep…' : files.length ? 'Files · ' + files.length + (ups.length ? ' · adding ' + ups.filter(function (t) { return t.state !== 'failed'; }).length : '')
1537
+ : ups.length ? 'Adding files' : 'No files yet. Add file, paste or drop one here.';
1538
+ var rows = files.map(function (f) {
1539
+ var meta = prepFileMeta(f);
1540
+ return '<li class="mt-up ready"><span class="mt-files-name">' + esc(f.name) + '</span>' + (meta ? '<span class="mt-up-meta mt-muted">' + esc(meta) + '</span>' : '') +
1541
+ '<span class="mt-up-acts"><button type="button" class="mt-link" data-cos="prep-remove" data-file="' + esc(f.id) + '" aria-label="' + esc('Remove ' + f.name) + '">Remove</button></span></li>';
1542
+ }).concat(ups.map(function (t) {
1543
+ return '<li class="mt-up ' + t.state + '" data-prep-up="' + esc(t.id) + '"><span class="mt-files-name">' + esc(t.name) + '</span>' +
1544
+ '<span class="mt-up-meta ' + (t.state === 'failed' ? 'mt-amber' : 'mt-muted') + '" data-up-meta>' + esc(prepUpMeta(t)) + '</span>' +
1545
+ '<span class="mt-up-acts">' + (t.state === 'failed' ? '<button type="button" class="mt-link" data-cos="prep-retry" data-up="' + esc(t.id) + '">Retry</button>' : '') +
1546
+ '<button type="button" class="mt-link" data-cos="prep-cancel" data-up="' + esc(t.id) + '" aria-label="' + esc('Remove ' + t.name) + '">' + (t.state === 'failed' ? 'Remove' : 'Cancel') + '</button></span>' +
1547
+ (t.state === 'uploading' || t.state === 'reading' ? '<span class="mt-up-bar" data-up-bar style="width:' + Math.round(t.progress * 100) + '%"></span>' : '') + '</li>';
1548
+ }));
1549
+ return '<div class="mt-files live"><div class="mt-files-head quiet"><svg class="icon" aria-hidden="true"><use href="#i-clip"/></svg><span>' + esc(title) + '</span></div>' +
1550
+ (rows.length ? '<ul class="mt-files-list">' + rows.join('') + '</ul>' : '') + '</div>';
1551
+ }
1552
+ function prepCountText(p) {
1553
+ var n = p.steer.length, max = prepChars();
1554
+ return n >= max - 400 ? n.toLocaleString() + ' of ' + max.toLocaleString() + ' characters' : '';
1555
+ }
1556
+ function prepRunLabel(p) { return p.running ? 'Prepping…' : prepUploading(p) ? 'Uploading…' : 'Prep me'; }
1557
+ function setPrepNote(p, text, warn) {
1558
+ p.note = text || ''; p.warn = !!warn && !!text;
1559
+ p.signedOut = /signed out/i.test(p.note);
1560
+ if (P === p) paintPrepParts(p);
1561
+ }
1562
+ function paintPrep(rebuild) {
1563
+ var slot = prepSlot(!!P);
1564
+ if (!slot || !P) return;
1565
+ if (rebuild || slot.getAttribute('data-key') !== P.key) {
1566
+ slot.setAttribute('data-key', P.key);
1567
+ slot.innerHTML = prepPanelHtml(P);
1568
+ var box = slot.querySelector('[data-cos-prep-steer]'); if (box) W.autoGrow(box);
1569
+ return;
1570
+ }
1571
+ var files = slot.querySelector('[data-prep-files]'); if (files) files.innerHTML = prepFilesHtml(P);
1572
+ paintPrepParts(P);
1573
+ }
1574
+ // The button, the count and the note: never the box (the words and the caret stay where they are).
1575
+ function paintPrepParts(p) {
1576
+ var slot = P === p ? prepSlot(false) : null; if (!slot) return;
1577
+ var run = slot.querySelector('[data-cos="prep-run"]');
1578
+ if (run) { run.disabled = !p.draft || p.running || prepUploading(p); run.textContent = prepRunLabel(p); }
1579
+ var count = slot.querySelector('[data-prep-count]'); if (count) count.textContent = prepCountText(p);
1580
+ var pick = slot.querySelector('[data-cos-prep-file]'); if (pick) pick.disabled = !p.draft || prepFiles(p.draft).length + p.ups.length >= prepMax();
1581
+ var note = slot.querySelector('[data-prep-note]');
1582
+ if (note) {
1583
+ note.className = (p.warn ? 'mt-amber-line' : 'mt-muted') + ' cc-prep-note';
1584
+ note.innerHTML = esc(p.note) + (p.signedOut ? ' <button type="button" class="link-btn cc-text-btn" data-cos="sign-in">Sign in again</button>' : '');
1585
+ note.hidden = !p.note;
1586
+ }
1587
+ }
1588
+ function paintPrepProgress(t) {
1589
+ var slot = prepSlot(false); if (!slot) return;
1590
+ var row = slot.querySelector('[data-prep-up="' + t.id + '"]'); if (!row) return;
1591
+ var meta = row.querySelector('[data-up-meta]'); if (meta) meta.textContent = prepUpMeta(t);
1592
+ var bar = row.querySelector('[data-up-bar]'); if (bar) bar.style.width = Math.round(t.progress * 100) + '%';
1593
+ }
1594
+ function onPrepClick(t, what) {
1595
+ var p = P; if (!p) return;
1596
+ if (what === 'prep-close') return closePrep();
1597
+ if (what === 'prep-run') return runPrep(p);
1598
+ if (what === 'prep-archive') return archivePrep(p);
1599
+ if (what === 'prep-remove') return removePrepFile(p, t.getAttribute('data-file'));
1600
+ var up = p.ups.filter(function (x) { return x.id === t.getAttribute('data-up'); })[0];
1601
+ if (!up) return;
1602
+ if (what === 'prep-retry' && up.state === 'failed') return sendPrepFile(p, up);
1603
+ if (what === 'prep-cancel') {
1604
+ if (up.xhr) { try { up.xhr.abort(); } catch (_e) {} }
1605
+ p.ups = p.ups.filter(function (x) { return x !== up; });
1606
+ paintPrep();
1607
+ }
1608
+ }
1609
+ function onPrepDrag(e, slot) {
1610
+ var types = e.dataTransfer && e.dataTransfer.types;
1611
+ if (!(types && Array.prototype.indexOf.call(types, 'Files') >= 0)) return;
1612
+ e.preventDefault();
1613
+ if (e.type === 'dragover' || e.type === 'dragenter') { slot.classList.add('dragging'); return; }
1614
+ slot.classList.remove('dragging');
1615
+ if (e.type === 'drop') addPrepFiles(e.dataTransfer.files);
1616
+ }
1617
+
879
1618
  // ── events (delegated from the Messages pane) ─────────
880
1619
  function onClick(t) {
881
1620
  var what = t.getAttribute('data-cos');
1621
+ if (/^prep-/.test(what || '')) return onPrepClick(t, what);
882
1622
  if (what === 'ask') return doAsk(kOf(t));
883
1623
  if (what === 'drop') return doDrop();
884
1624
  if (what === 'all') return W.go('messages');
885
1625
  if (what === 'read-all') return markReadIfShown(true);
1626
+ if (what === 'new-thread') return newThread();
886
1627
  if (what === 'retry') { C.feedState = 'idle'; return loadFeed(); }
887
1628
  if (what === 'act') return act(t.getAttribute('data-item'), t.getAttribute('data-act'));
888
1629
  if (what === 'move-to') return moveTo(t.getAttribute('data-item'), t.getAttribute('data-dest'), t.getAttribute('data-domain'));
@@ -916,6 +1657,7 @@
916
1657
  doAsk(k);
917
1658
  }
918
1659
  function onInput(e) {
1660
+ if (e.target && e.target.hasAttribute && e.target.hasAttribute('data-cos-prep-steer')) return onPrepInput(e.target);
919
1661
  if (isComposerInput(e.target)) { var k = kOf(e.target); k.draft = e.target.value; saveDraft(k); W.autoGrow(e.target); paintButtons(k); }
920
1662
  }
921
1663
  function onChangeEvent(e) {
@@ -924,6 +1666,7 @@
924
1666
  if (t.hasAttribute('data-cos-model')) { C.model = t.value; save(); syncMenus(); }
925
1667
  else if (t.hasAttribute('data-cos-effort')) { C.effort = t.value; save(); syncMenus(); }
926
1668
  else if (t.hasAttribute('data-cos-file')) { addFiles(kOf(t), t.files); t.value = ''; }
1669
+ else if (t.hasAttribute('data-cos-prep-file')) { addPrepFiles(t.files); t.value = ''; }
927
1670
  }
928
1671
  // Two composers can be on screen (the chat beside a reader is not, but a reload can leave either): keep both menus true.
929
1672
  function syncMenus() {
@@ -934,15 +1677,23 @@
934
1677
  var f = e.target.closest && e.target.closest('[data-cos-steer]');
935
1678
  if (f) { e.preventDefault(); steer(f.getAttribute('data-cos-steer'), (f.querySelector('input') || {}).value || ''); return true; }
936
1679
  if (e.target.matches && e.target.matches('[data-cos-form]')) { e.preventDefault(); return true; }
1680
+ if (e.target.matches && e.target.matches('[data-cos-prep-form]')) { e.preventDefault(); return true; }
937
1681
  return false;
938
1682
  }
939
1683
  // Paste a photo or a file into the box; drop files on the composer.
940
1684
  function onPaste(e) {
1685
+ if (e.target && e.target.hasAttribute && e.target.hasAttribute('data-cos-prep-steer')) {
1686
+ var pf = e.clipboardData && e.clipboardData.files;
1687
+ if (pf && pf.length) { e.preventDefault(); addPrepFiles(pf); }
1688
+ return;
1689
+ }
941
1690
  if (!isComposerInput(e.target)) return;
942
1691
  var files = e.clipboardData && e.clipboardData.files;
943
1692
  if (files && files.length) { e.preventDefault(); addFiles(kOf(e.target), files); }
944
1693
  }
945
1694
  function onDrag(e) {
1695
+ var prep = e.target && e.target.closest && e.target.closest('[data-cos-prep]');
1696
+ if (prep) return onPrepDrag(e, prep);
946
1697
  var form = e.target && e.target.closest && e.target.closest('[data-cos-form]');
947
1698
  if (!form) return;
948
1699
  var types = e.dataTransfer && e.dataTransfer.types;
@@ -968,6 +1719,13 @@
968
1719
  onClick: onClick, onKey: onKey, onInput: onInput, onChange: onChangeEvent, onSubmit: onSubmit, onPaste: onPaste, onDrag: onDrag,
969
1720
  setOnChange: function (fn) { C.onChange = fn; }, setOnAnswered: function (fn) { C.onAnswered = fn; },
970
1721
  // Test and render hooks: what the chat holds right now.
971
- _state: C, _composer: composer, _classify: classify, _modelsFrom: modelsFrom, _addFiles: addFiles
1722
+ classify: classify, uploadMedia: uploadMedia, uploadErrorText: uploadErrorText, uuid: uuid,
1723
+ // 6.99.19: Prep. meetings.js reads the capability from here (one /api/health read for the page).
1724
+ prepCaps: prepCaps,
1725
+ // The Prep server id to name on a write: the newest from capabilities.prep or any Prep answer (meetings.js reports its own).
1726
+ prepInstance: function () { return PREP_INST; }, notePrepInstance: function (id) { if (typeof id === 'string' && id) PREP_INST = id; },
1727
+ _prep: function () { return P; }, _openPrep: openPrep, _prepHtml: function () { return P ? prepPanelHtml(P) : ''; }, _addPrepFiles: addPrepFiles,
1728
+ _runPrep: function () { runPrep(P); }, _sha256Js: sha256Js, _closePrep: closePrep, _onPrepInput: onPrepInput, _savePrepSteer: function () { return P ? savePrepSteer(P) : null; },
1729
+ _state: C, _composer: composer, _classify: classify, _modelsFrom: modelsFrom, _addFiles: addFiles, _itemHtml: itemHtml, _setCaps: setCaps, _threadLine: threadLine, _postTurn: postTurn
972
1730
  };
973
1731
  })();