@gotcos/glasses-server 6.99.16 → 6.99.25

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 (52) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/docs/work-orchestrator.md +20 -0
  3. package/package.json +1 -1
  4. package/server/index.ts +23 -1
  5. package/server/lib/ask-work-plan.ts +116 -0
  6. package/server/lib/batch-transcript-quality.ts +2 -0
  7. package/server/lib/drop-store.ts +16 -2
  8. package/server/lib/meeting-ask-runtime.ts +1 -1
  9. package/server/lib/meeting-batch-transcribe.ts +40 -1
  10. package/server/lib/ping-sources.ts +6 -2
  11. package/server/lib/prep-runtime.ts +915 -0
  12. package/server/lib/prep-store.ts +226 -0
  13. package/server/lib/stt-resolver.ts +26 -2
  14. package/server/lib/transcript-cleanup.ts +11 -2
  15. package/server/lib/web-control-static.ts +226 -0
  16. package/server/lib/web-memories-bridge.ts +9 -8
  17. package/server/lib/web-pet-choice.ts +225 -0
  18. package/server/lib/web-pet.ts +226 -0
  19. package/server/lib/work-lens-rounds.ts +29 -4
  20. package/server/lib/work-orchestrator/runtime.ts +8 -2
  21. package/server/routes/ask-work-plan.ts +114 -0
  22. package/server/routes/health.ts +8 -0
  23. package/server/routes/prep.ts +81 -0
  24. package/server/routes/web-control.ts +110 -7
  25. package/server/routes/welcome-context.ts +29 -1
  26. package/server/routes/work-lens-rounds.ts +19 -5
  27. package/server/routes/work-orchestrator.ts +57 -0
  28. package/server/web-control/app.css +24 -5
  29. package/server/web-control/app.js +646 -108
  30. package/server/web-control/control.css +1 -1
  31. package/server/web-control/index.html +27 -13
  32. package/server/web-control/memories/memory-workspace.js +1 -1
  33. package/server/web-control/panes/cos-chat.css +26 -0
  34. package/server/web-control/panes/cos-chat.js +641 -6
  35. package/server/web-control/panes/meetings.css +45 -2
  36. package/server/web-control/panes/meetings.js +230 -7
  37. package/server/web-control/panes/memories.css +291 -0
  38. package/server/web-control/panes/memories.js +2208 -0
  39. package/server/web-control/panes/questions.js +59 -21
  40. package/server/web-control/panes/settings.js +102 -4
  41. package/server/web-control/panes/today-actions.css +109 -0
  42. package/server/web-control/panes/today-actions.js +972 -0
  43. package/server/web-control/panes/work.css +54 -0
  44. package/server/web-control/panes/work.js +1037 -18
  45. package/server/web-control/shell.js +6 -2
  46. package/server/web-control/theme.js +0 -2
  47. package/shared/meeting-ask.ts +6 -2
  48. package/shared/prep.ts +317 -0
  49. package/server/web-control/memories/memories-app.js +0 -1561
  50. package/server/web-control/memories/memories-theme.css +0 -73
  51. package/server/web-control/memories/memories.html +0 -72
  52. package/server/web-control/memories/memory-stewardship.js +0 -63
@@ -105,8 +105,11 @@
105
105
  jobs: caps.durableQueryJobs === true || h && h.durableQueryJobs === true || !!(caps.durableQueryJobs && caps.durableQueryJobs.enabled) ||
106
106
  !!(h && h.features && h.features.durableQueryJobs === true),
107
107
  steer: !!(C.caps && C.caps.steer),
108
- thread: !!(drop && drop.enabled === true && drop.thread && drop.thread.protocol === 1)
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)
109
111
  };
112
+ if (next.prep && next.prep.serverInstanceId) PREP_INST = next.prep.serverInstanceId;
110
113
  var changed = !C.caps || C.caps.dropIt !== next.dropIt || C.caps.jobs !== next.jobs || C.caps.thread !== next.thread;
111
114
  var filesChanged = !!C.caps && C.caps.dropFiles !== next.dropFiles;
112
115
  C.caps = next;
@@ -115,6 +118,13 @@
115
118
  else if (filesChanged) paintButtons(CHAT);
116
119
  if (next.dropIt) refreshSummary();
117
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
+ }
118
128
  // The shell's status read (shell.js checkServer) hands its /api/health answer here.
119
129
  function fromHealth(h) { setCaps(h); }
120
130
  function ensureCaps() {
@@ -305,6 +315,7 @@
305
315
  if (!el || el.getAttribute('data-mode') !== chatMode()) {
306
316
  host.innerHTML = chatSkeleton();
307
317
  hydrate(CHAT);
318
+ if (P) paintPrep(true);
308
319
  }
309
320
  if (!visible) return;
310
321
  loadModels();
@@ -863,11 +874,11 @@
863
874
  throw err;
864
875
  });
865
876
  };
866
- var prepare = a.threaded ? threadPlan().then(function (plan) {
877
+ var prepare = (a.threaded ? threadPlan().then(function (plan) {
867
878
  if (plan.sessionId) body.sessionId = plan.sessionId;
868
879
  else if (plan.digest) body.reference = { query: THREAD_REF, response: plan.digest };
869
880
  a.carried = plan.sessionId ? '' : plan.digest ? 'summary' : 'plain';
870
- }) : Promise.resolve();
881
+ }) : Promise.resolve()).then(function () { withContext(body, opts.context); });
871
882
  prepare.then(function () { return admit(true); }).then(function (d) {
872
883
  var job = d && d.job; if (!job) throw new Error('The Mac did not take the question.');
873
884
  take(a, job);
@@ -886,6 +897,15 @@
886
897
  });
887
898
  return a;
888
899
  }
900
+ // An Ask from Today carries what Today shows (app.js askCos, panes/today-actions.js summary) as the quoted reference,
901
+ // after a carried thread summary when there is one. Bounded, and only on the Ask it was given to.
902
+ var TODAY_REF = 'What Today in COS Control shows right now', CONTEXT_MAX = 6000;
903
+ function withContext(body, context) {
904
+ var text = typeof context === 'string' ? context.trim().slice(0, CONTEXT_MAX) : '';
905
+ if (!text) return;
906
+ if (body.reference) body.reference = { query: body.reference.query + ', then ' + TODAY_REF.charAt(0).toLowerCase() + TODAY_REF.slice(1), response: body.reference.response + '\n\n' + text };
907
+ else body.reference = { query: TODAY_REF, response: text };
908
+ }
889
909
  function take(a, job) {
890
910
  a.jobId = job.jobId; a.status = job.status || a.status;
891
911
  if (job.globalMsgNum) a.no = job.globalMsgNum;
@@ -921,11 +941,12 @@
921
941
  api('/api/inbox/feed?limit=100').then(function (d) { C.items = (d && d.items) || []; paintFeed(false); markReadIfShown(); }, function () {});
922
942
  }
923
943
  // The Ask sheet (app.js askCos) hands its words here, so the answer streams in the chat instead of vanishing.
924
- function askFromSheet(text) {
944
+ function askFromSheet(text, extra) {
925
945
  return ensureCaps().then(function (caps) {
926
946
  if (!caps || !caps.jobs) { var off = new Error('Ask needs the durable query jobs switch on your Mac.'); off.code = 'durable_query_jobs_disabled'; throw off; }
927
947
  return new Promise(function (resolve, reject) {
928
- submitAsk(String(text || '').trim(), null, function (ok) { if (ok) resolve(['cos']); else reject(new Error('Not sent. The question is in Ask COS; try again there.')); });
948
+ submitAsk(String(text || '').trim(), null, function (ok) { if (ok) resolve(['cos']); else reject(new Error('Not sent. The question is in Ask COS; try again there.')); },
949
+ extra && extra.context ? { context: extra.context } : undefined);
929
950
  });
930
951
  });
931
952
  }
@@ -976,7 +997,8 @@
976
997
  if (action === 'open_result') return W.go(linkOf('session') ? 'sessions' : linkOf('card') ? 'work' : 'messages');
977
998
  if (action === 'open') { if (linkOf('thread')) return W.go('threads'); if (linkOf('meeting')) return W.go('meetings'); return; }
978
999
  if (action === 'review') return W.go('work');
979
- if (action === 'prep') { if (C.caps && C.caps.jobs) submitAsk('Prep me for ' + (item.title || 'my next meeting'), ''); return; }
1000
+ // 6.99.19: with Prep on the Mac, Prep me opens the Prep composer (notes and files first); without it, the plain Ask.
1001
+ if (action === 'prep') { if (prepOn()) return openPrep(item); if (C.caps && C.caps.jobs) submitAsk('Prep me for ' + (item.title || 'my next meeting'), ''); return; }
980
1002
  if (action === 'reply') {
981
1003
  var box = document.getElementById('cos-input');
982
1004
  if (box) { box.value = 'remind me '; box.focus(); }
@@ -1019,9 +1041,594 @@
1019
1041
  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.'); });
1020
1042
  }
1021
1043
 
1044
+ // ── Prep (6.99.19, PREP_CONTRACT_v1) ──────────────────
1045
+ // With capabilities.prep on the Mac, "Prep me" on a prep card opens this composer above the chat's own: what the prep
1046
+ // should focus on, and files, the way the live meeting view takes them. It is a server draft (POST /api/prep/drafts,
1047
+ // the clientDraftId kept in this browser, so opening it again resumes it through GET). Every write sends the draft's
1048
+ // revision; a 409 revision_conflict hands back the newer draft, which is shown, with one line saying so. Prep me
1049
+ // posts .../run, and the job it returns is drawn and polled in the chat exactly like an Ask (take, poll, and the
1050
+ // by-client lookup when the answer to the run was lost). Files reach a meeting only from that meeting's live view.
1051
+ var PREP_STORE = 'cos.web.prepDrafts';
1052
+ var PREP_ACCEPT = 'image/*,.heic,.heif,application/pdf,.pdf,text/plain,.txt,text/markdown,.md,text/csv,.csv,application/json,.json,.xlsx';
1053
+ var PREP_SAVE_MS = 700;
1054
+ var PREP_FILE_STATE = { unsupported: 'No readable text', failed: 'Could not be read', 'secret-held': 'Held: it appears to contain credentials' };
1055
+ // Amendment 19: every Prep write names the server it expects (X-COS-Expected-Server-Instance), the id last read from
1056
+ // capabilities.prep or the most recent Prep answer (every one carries it). The id survives restarts; a different id
1057
+ // means a different COS server answered (409 server_instance_mismatch).
1058
+ var PREP_INST = '';
1059
+ var P = null; // the open composer: { key, title, startsAt, draft, steer, dirty, ups, note, warn, running, runId, chain, timer, conflicts }
1060
+ function prepCaps() { return C.caps && C.caps.prep ? C.caps.prep : null; }
1061
+ function prepOn() { return !!prepCaps(); }
1062
+ function prepMax() { var c = prepCaps(); return c ? c.maxFiles : 10; }
1063
+ function prepChars() { var c = prepCaps(); return c ? c.steerChars : 4000; }
1064
+ function prepRec(key) { var r = W.readStore(PREP_STORE)[key]; return r && typeof r === 'object' && typeof r.cd === 'string' ? r : null; }
1065
+ function savePrepRec(key, rec) {
1066
+ var all = W.readStore(PREP_STORE);
1067
+ all[key] = rec;
1068
+ // The newest twenty: a prep card a week old has no reason to be found again here.
1069
+ var keys = Object.keys(all).sort(function (a, b) { return (Number(all[a] && all[a].at) || 0) - (Number(all[b] && all[b].at) || 0); });
1070
+ while (keys.length > 20) delete all[keys.shift()];
1071
+ W.writeStore(PREP_STORE, all);
1072
+ }
1073
+ function forgetPrepRec(key) { var all = W.readStore(PREP_STORE); delete all[key]; W.writeStore(PREP_STORE, all); }
1074
+ // The run in flight, kept with the draft in this browser (closing the composer or reloading the page does not lose it):
1075
+ // its clientRequestId and its own model, effort and continue, so a retry is the same request and the server re-drives it.
1076
+ function setPendingRun(p, run) {
1077
+ p.pending = run;
1078
+ var rec = prepRec(p.key); if (!rec) return;
1079
+ if (run) rec.run = run; else delete rec.run;
1080
+ savePrepRec(p.key, rec);
1081
+ }
1082
+ /** A Prep route, answered as { status, body } (a 409 carries the newer draft). Never rejects: no answer is status 0. */
1083
+ function prepCall(path, opts) {
1084
+ var o = opts || {}, method = o.method || 'GET';
1085
+ var headers = { 'X-COS-Web': '1' };
1086
+ if (o.body !== undefined) headers['Content-Type'] = 'application/json';
1087
+ if (method !== 'GET' && PREP_INST) headers['X-COS-Expected-Server-Instance'] = PREP_INST;
1088
+ var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
1089
+ var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, o.timeoutMs || (method === 'GET' ? 20000 : 30000)) : null;
1090
+ return fetch(path, { method: method, headers: headers, credentials: 'same-origin', body: o.body !== undefined ? JSON.stringify(o.body) : undefined, signal: ctrl ? ctrl.signal : undefined })
1091
+ .then(function (res) {
1092
+ return res.text().then(function (t) {
1093
+ clearTimeout(timer); var b = {}; try { b = t ? JSON.parse(t) : {}; } catch (_e) { b = {}; }
1094
+ b = b || {};
1095
+ var inst = typeof b.serverInstanceId === 'string' && b.serverInstanceId ? b.serverInstanceId : b.draft && typeof b.draft.serverInstanceId === 'string' ? b.draft.serverInstanceId : '';
1096
+ // The server's id as it answers now: from a read, a write it took, or the refusal that says it changed.
1097
+ if (inst && (res.status < 300 || (res.status === 409 && prepCode({ body: b }) === 'server_instance_mismatch'))) PREP_INST = inst;
1098
+ if (res.status === 428 && typeof console !== 'undefined') console.error('[prep] a write went without X-COS-Expected-Server-Instance', method, path);
1099
+ return { status: res.status, body: b };
1100
+ });
1101
+ })
1102
+ .catch(function () { clearTimeout(timer); return { status: 0, body: {} }; });
1103
+ }
1104
+ // sha256 of the bytes this browser uploaded (amendment 20). crypto.subtle needs a secure context, and a plain-http
1105
+ // tailnet page is not one, so a small FIPS 180-4 implementation stands in there.
1106
+ function sha256Hex(bytes) {
1107
+ var subtle = typeof crypto !== 'undefined' && crypto.subtle && (typeof window === 'undefined' || window.isSecureContext !== false) ? crypto.subtle : null;
1108
+ if (subtle) return subtle.digest('SHA-256', bytes).then(function (h) { return hex(new Uint8Array(h)); }, function () { return sha256Js(bytes); });
1109
+ return Promise.resolve(sha256Js(bytes));
1110
+ }
1111
+ function hex(a) { return Array.prototype.map.call(a, function (x) { return (x + 0x100).toString(16).slice(1); }).join(''); }
1112
+ var SHA_K = [0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
1113
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
1114
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
1115
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2];
1116
+ function sha256Js(bytes) {
1117
+ var len = bytes.length, total = ((len + 9 + 63) >> 6) << 6, m = new Uint8Array(total);
1118
+ m.set(bytes); m[len] = 0x80;
1119
+ var bits = len * 8, dv = new DataView(m.buffer);
1120
+ dv.setUint32(total - 8, Math.floor(bits / 0x100000000)); dv.setUint32(total - 4, bits >>> 0);
1121
+ var h = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19], w = new Array(64);
1122
+ var rotr = function (x, n) { return (x >>> n) | (x << (32 - n)); };
1123
+ for (var off = 0; off < total; off += 64) {
1124
+ for (var i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4);
1125
+ for (i = 16; i < 64; i++) {
1126
+ 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);
1127
+ w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
1128
+ }
1129
+ 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];
1130
+ for (i = 0; i < 64; i++) {
1131
+ var t1 = (k + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + SHA_K[i] + w[i]) | 0;
1132
+ var t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) | 0;
1133
+ k = g; g = f; f = e; e = (d + t1) | 0; d = c; c = b; b = a; a = (t1 + t2) | 0;
1134
+ }
1135
+ 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;
1136
+ }
1137
+ return h.map(function (x) { return ('00000000' + (x >>> 0).toString(16)).slice(-8); }).join('');
1138
+ }
1139
+ /** The exact bytes the media upload sent: the resized JPEG for a photo (uploadMedia keeps it as t.thumb), else the file. */
1140
+ function uploadedBytes(t) {
1141
+ if (t.kind === 'photo' && /^data:/.test(t.thumb || '')) {
1142
+ var bin = atob(t.thumb.slice(t.thumb.indexOf(',') + 1)), out = new Uint8Array(bin.length);
1143
+ for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
1144
+ return Promise.resolve(out);
1145
+ }
1146
+ if (t.file && typeof t.file.arrayBuffer === 'function') return t.file.arrayBuffer().then(function (b) { return new Uint8Array(b); });
1147
+ return Promise.reject(new Error('This browser could not read the file again to check it. Remove it and add it again.'));
1148
+ }
1149
+ function prepCode(r) { var b = (r && r.body) || {}; return typeof b.error === 'string' ? b.error : (b.error && b.error.code) || b.code || b.reason || ''; }
1150
+ var PREP_CONFLICT = 'This prep changed on another device. It now shows the newest notes and files. Check them, then try again.';
1151
+ function prepErrorText(r) {
1152
+ var code = prepCode(r);
1153
+ if (r.status === 0) return 'COS on your Mac did not answer. Check that it is awake, then try again.';
1154
+ if (r.status === 401) return 'This browser was signed out. Your words are kept here. Sign in, then try again.';
1155
+ if (code === 'server_instance_mismatch') return 'The COS server on your Mac changed. Close this prep and tap Prep me on the card again.';
1156
+ 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.';
1157
+ if (code === 'media_hash_mismatch') return 'The file your Mac kept does not match what this browser sent. Retry to send it again.';
1158
+ if (code === 'maintenance' || r.status === 503) return 'COS is updating. Try again in a minute.';
1159
+ if (code === 'prep_file_limit' || r.status === 413) return 'This prep holds ' + prepMax() + ' files. Remove one, then retry.';
1160
+ 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.';
1161
+ if (r.status === 507) return 'COS is out of room for prep files. Remove some files from older preps, then retry.';
1162
+ if (code === 'durable_query_jobs_disabled') return 'Prep me needs the durable query jobs switch on your Mac. Your notes and files are saved.';
1163
+ if (/^media_/.test(code)) return 'The upload expired before COS kept it. Retry.';
1164
+ if (r.status === 404) return 'This prep is no longer on your Mac. Close it and tap Prep me on the card again.';
1165
+ var msg = r.body && typeof r.body.message === 'string' ? r.body.message : '';
1166
+ return msg ? msg : 'COS did not take that (HTTP ' + r.status + '). Try again.';
1167
+ }
1168
+ function prepQueue(p, fn) { var next = p.chain.then(fn); p.chain = next.catch(function () {}); return next; }
1169
+ function prepPath(p) { return '/api/prep/drafts/' + encodeURIComponent(p.draft.id); }
1170
+ function prepFiles(d) { return ((d && d.files) || []).filter(function (f) { return f && f.status !== 'removed'; }); }
1171
+ function prepUploading(p) { return p.ups.some(function (t) { return t.state !== 'failed'; }); }
1172
+ function prepWhen(iso) { return iso && isFinite(Date.parse(iso)) ? timeLabel(iso) : ''; }
1173
+
1174
+ function openPrep(item) {
1175
+ var act = ((item && item.actions) || []).filter(function (a) { return a && a.id === 'prep'; })[0] || {};
1176
+ var info = (act.prep && typeof act.prep === 'object' ? act.prep : item.prep && typeof item.prep === 'object' ? item.prep : {}) || {};
1177
+ var key = 'item:' + item.id;
1178
+ if (P && P.key === key) { paintPrep(); focusPrep(); return; }
1179
+ if (P) closePrep();
1180
+ P = { key: key, title: clip(info.title || item.title || 'my next meeting', 200), startsAt: typeof info.startsAt === 'string' ? info.startsAt : null,
1181
+ draft: null, steer: '', dirty: false, ups: [], note: '', warn: false, running: false, pending: null, chain: Promise.resolve(), timer: null, conflicts: 0, loading: true };
1182
+ paintPrep(true); focusPrep();
1183
+ // On a phone the page scrolls: bring the composer up to where the card was tapped.
1184
+ var slot = prepSlot(false); if (slot && slot.scrollIntoView && !wide()) slot.scrollIntoView({ block: 'start' });
1185
+ loadPrep(P);
1186
+ }
1187
+ function closePrep() {
1188
+ if (!P) return;
1189
+ var p = P;
1190
+ // Words typed and not yet saved go out once more on the way.
1191
+ if (p.dirty && p.draft) savePrepSteer(p);
1192
+ clearTimeout(p.timer);
1193
+ p.ups.forEach(function (t) { if (t.xhr) { try { t.xhr.abort(); } catch (_e) {} } });
1194
+ P = null;
1195
+ paintPrep();
1196
+ }
1197
+ // Resume when this browser has the draft; otherwise create it (the same clientDraftId answers the same draft).
1198
+ function loadPrep(p) {
1199
+ var rec = prepRec(p.key);
1200
+ var run = rec && rec.run;
1201
+ if (run && typeof run.rid === 'string') p.pending = { rid: run.rid, model: run.model || '', effort: run.effort || '', cont: !!run.cont };
1202
+ var create = function (cd) {
1203
+ savePrepRec(p.key, { cd: cd, id: '', at: Date.now() });
1204
+ var body = { clientDraftId: cd, title: p.title };
1205
+ if (p.startsAt) body.startsAt = p.startsAt;
1206
+ return prepCall('/api/prep/drafts', { method: 'POST', body: body });
1207
+ };
1208
+ var step = rec && rec.id ? prepCall('/api/prep/drafts/' + encodeURIComponent(rec.id)).then(function (r) {
1209
+ return r.status === 404 ? create(uuid()) : r;
1210
+ }) : create(rec ? rec.cd : uuid());
1211
+ return step.then(function (r) {
1212
+ p.loading = false;
1213
+ if (r.status >= 200 && r.status < 300 && r.body.draft) { adoptPrep(p, r.body.draft, false); if (p.dirty) schedulePrepSave(p); return; }
1214
+ setPrepNote(p, prepErrorText(r), true);
1215
+ });
1216
+ }
1217
+ function adoptPrep(p, draft, conflict) {
1218
+ p.draft = draft;
1219
+ var rec = prepRec(p.key);
1220
+ var next = { cd: (draft && draft.clientDraftId) || (rec && rec.cd) || '', id: draft.id, at: Date.now() };
1221
+ if (rec && rec.run) next.run = rec.run;
1222
+ savePrepRec(p.key, next);
1223
+ if (conflict || !p.dirty) {
1224
+ p.steer = typeof draft.steer === 'string' ? draft.steer : '';
1225
+ if (conflict) { p.dirty = false; clearTimeout(p.timer); p.conflicts++; }
1226
+ var box = P === p && prepBox();
1227
+ if (box && box.value !== p.steer) { box.value = p.steer; W.autoGrow(box); }
1228
+ }
1229
+ if (conflict) setPrepNote(p, PREP_CONFLICT, true);
1230
+ paintPrep();
1231
+ }
1232
+ /** A write to the draft: the answer's draft is adopted (a 409 revision_conflict's too, with the line that says so). */
1233
+ function prepWrite(p, method, path, body) {
1234
+ return prepCall(path, { method: method, body: body }).then(function (r) {
1235
+ var conflict = r.status === 409 && prepCode(r) === 'revision_conflict';
1236
+ if (r.body && r.body.draft && (conflict || (r.status >= 200 && r.status < 300))) adoptPrep(p, r.body.draft, conflict);
1237
+ if (r.status === 409 && prepCode(r) === 'server_instance_mismatch') { r.mismatch = true; reloadAfterServerChange(p); }
1238
+ return r;
1239
+ });
1240
+ }
1241
+ // A different COS server answered than the one this prep was read from: say so, read the draft again,
1242
+ // and keep words not yet saved, which go out once the draft is read.
1243
+ var PREP_SERVER_CHANGED = 'The COS server on your Mac changed since this prep opened. It was read again. Check it, then try again.';
1244
+ function reloadAfterServerChange(p) {
1245
+ setPrepNote(p, PREP_SERVER_CHANGED, true);
1246
+ if (p.reloading || !p.draft) return;
1247
+ p.reloading = true;
1248
+ prepCall(prepPath(p)).then(function (r) {
1249
+ p.reloading = false;
1250
+ if (r.status === 200 && r.body.draft) { adoptPrep(p, r.body.draft, false); if (p.dirty) schedulePrepSave(p); }
1251
+ });
1252
+ }
1253
+ function schedulePrepSave(p) { clearTimeout(p.timer); p.timer = setTimeout(function () { savePrepSteer(p); }, PREP_SAVE_MS); }
1254
+ function savePrepSteer(p) {
1255
+ clearTimeout(p.timer); p.timer = null;
1256
+ return prepQueue(p, function () {
1257
+ if (!p.dirty || !p.draft) return null;
1258
+ var text = p.steer; p.dirty = false;
1259
+ return prepWrite(p, 'PATCH', prepPath(p), { revision: p.draft.revision, steer: text }).then(function (r) {
1260
+ if (r.mismatch) { if (p.steer === text) p.dirty = true; return r; }
1261
+ if ((r.status >= 200 && r.status < 300) || r.status === 409) return r;
1262
+ // Kept, and sent again with the next change or with Prep me.
1263
+ if (p.steer === text) p.dirty = true;
1264
+ setPrepNote(p, 'Your notes are not saved yet. ' + prepErrorText(r), true);
1265
+ return r;
1266
+ });
1267
+ });
1268
+ }
1269
+ function onPrepInput(box) {
1270
+ var p = P; if (!p) return;
1271
+ var max = prepChars();
1272
+ if (box.value.length > max) box.value = box.value.slice(0, max);
1273
+ p.steer = box.value; p.dirty = true;
1274
+ W.autoGrow(box);
1275
+ schedulePrepSave(p);
1276
+ paintPrepParts(p);
1277
+ }
1278
+
1279
+ // Files: the composer's own upload (uploadMedia), then POST .../files with the draft's revision.
1280
+ function addPrepFiles(list) {
1281
+ var p = P; if (!p) return;
1282
+ var files = Array.prototype.slice.call(list || []), refusals = [];
1283
+ if (!p.draft) { setPrepNote(p, 'This prep is still opening. Add the files again in a moment.', true); return; }
1284
+ files.forEach(function (file) {
1285
+ if (prepFiles(p.draft).length + p.ups.length >= prepMax()) { refusals.push('A prep holds ' + prepMax() + ' files. ' + (file.name || 'A file') + ' was not added.'); return; }
1286
+ var c = classify(file);
1287
+ if (c.refuse) { refusals.push(c.refuse); return; }
1288
+ 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; }
1289
+ 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: '' };
1290
+ p.ups.push(t);
1291
+ sendPrepFile(p, t);
1292
+ });
1293
+ setPrepNote(p, refusals.join(' '), !!refusals.length);
1294
+ paintPrep();
1295
+ }
1296
+ function sendPrepFile(p, t) {
1297
+ t.state = t.mediaId ? 'adding' : 'reading'; t.progress = 0; t.error = '';
1298
+ var step = t.mediaId ? Promise.resolve({ id: t.mediaId }) : uploadMedia(t, {
1299
+ uploading: function () { t.state = 'uploading'; paintPrep(); },
1300
+ progress: function (x) { t.progress = x; paintPrepProgress(t); }
1301
+ });
1302
+ paintPrep();
1303
+ step.then(function (ref) {
1304
+ if (p.ups.indexOf(t) < 0) return null;
1305
+ if (!ref || !ref.id) { var e = new Error('COS did not keep the file. Retry.'); e.code = 'kept'; throw e; }
1306
+ t.mediaId = ref.id; t.state = 'adding'; paintPrep();
1307
+ // Amendment 20: the sha256 of exactly the bytes uploaded, which the server checks its copy against.
1308
+ return t.sha256 ? null : uploadedBytes(t).then(sha256Hex).then(function (h) { t.sha256 = h; });
1309
+ }).then(function () {
1310
+ if (p.ups.indexOf(t) < 0 || !t.mediaId) return null;
1311
+ // v1.1: one operationId per add, the same on a retry after a lost answer (the server answers the first result).
1312
+ t.opId = t.opId || uuid();
1313
+ return prepQueue(p, function () {
1314
+ var path = prepPath(p) + '/files';
1315
+ var body = function () { return { operationId: t.opId, revision: p.draft.revision, media: [{ id: t.mediaId, name: t.name.slice(0, 240), sha256: t.sha256 }] }; };
1316
+ return prepWrite(p, 'POST', path, body()).then(function (r) { return r.status === 0 ? prepWrite(p, 'POST', path, body()) : r; });
1317
+ });
1318
+ }).then(function (r) {
1319
+ if (!r || p.ups.indexOf(t) < 0) return;
1320
+ if (r.status >= 200 && r.status < 300) { p.ups = p.ups.filter(function (x) { return x !== t; }); paintPrep(); return; }
1321
+ // Refused (a 4xx), so nothing was recorded: Retry is a new operation. No answer or a 5xx: Retry is the same one.
1322
+ if (r.status >= 400 && r.status < 500) t.opId = '';
1323
+ // The staged copy is gone or differs: Retry uploads the bytes again.
1324
+ var code = prepCode(r);
1325
+ if (code === 'media_hash_mismatch' || /^media_(not_found|expired)$/.test(code)) { t.mediaId = ''; t.sha256 = ''; }
1326
+ t.state = 'failed';
1327
+ 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);
1328
+ paintPrep();
1329
+ }).catch(function (err) {
1330
+ if (p.ups.indexOf(t) < 0 || (err && err.code === 'aborted')) return;
1331
+ t.state = 'failed';
1332
+ t.error = err && err.code === 'kept' ? err.message : err && err.status === 401 ? 'Signed out. Sign in, then retry.' : uploadErrorText(err);
1333
+ paintPrep();
1334
+ });
1335
+ }
1336
+ function removePrepFile(p, fileId) {
1337
+ if (!p.draft) return;
1338
+ prepQueue(p, function () {
1339
+ return prepWrite(p, 'DELETE', prepPath(p) + '/files/' + encodeURIComponent(fileId) + '?revision=' + encodeURIComponent(p.draft.revision));
1340
+ }).then(function (r) { if (r && !(r.status >= 200 && r.status < 300) && r.status !== 409) setPrepNote(p, 'Not removed. ' + prepErrorText(r), true); });
1341
+ }
1342
+
1343
+ // Prep me: the notes saved first, then .../run. The run in flight (its clientRequestId and its own params) is kept with
1344
+ // the draft until its outcome is known, so the next tap, a close or a reload resends the same request.
1345
+ function runPrep(p) {
1346
+ if (!p || !p.draft || p.running || prepUploading(p)) return;
1347
+ p.running = true; setPrepNote(p, '', false); paintPrep();
1348
+ var seen = p.conflicts;
1349
+ if (p.dirty) savePrepSteer(p);
1350
+ prepQueue(p, function () {
1351
+ // The notes changed elsewhere, or did not save: nothing runs on words the person has not seen.
1352
+ if (p.conflicts !== seen) return { status: -1, body: {} };
1353
+ if (p.dirty) return { status: -2, body: {} };
1354
+ if (!p.pending) {
1355
+ var cont = !!((p.draft.runs || []).length && p.draft.conversation && p.draft.conversation.sessionId);
1356
+ setPendingRun(p, { rid: uuid(), model: C.model || '', effort: C.effort || '', cont: cont });
1357
+ }
1358
+ return postRun(p, p.pending, true);
1359
+ }).then(function (r) { return settleRun(p, r, true); });
1360
+ }
1361
+ function runBody(p, run) {
1362
+ var body = { clientRequestId: run.rid, revision: p.draft.revision };
1363
+ if (run.model) body.model = run.model;
1364
+ if (run.effort) body.effort = run.effort;
1365
+ if (run.cont) body.continue = true;
1366
+ return body;
1367
+ }
1368
+ function postRun(p, run, retryLost) {
1369
+ var path = prepPath(p) + '/run';
1370
+ return prepWrite(p, 'POST', path, runBody(p, run)).then(function (r) { return r.status === 0 && retryLost ? prepWrite(p, 'POST', path, runBody(p, run)) : r; });
1371
+ }
1372
+ function settleRun(p, r, mayRedrive) {
1373
+ p.running = false;
1374
+ var code = prepCode(r), run = r.body && r.body.run;
1375
+ if (r.status === 202 || r.status === 200) { setPendingRun(p, null); return ended(p, run, r.body.job) || startPrepJob(p, run || {}, r.body.job || null); }
1376
+ if (r.status === 0) return recoverPrepRun(p, p.pending ? p.pending.rid : '');
1377
+ // One run at a time per draft. Still being submitted: send THAT run again, with its own params (the server
1378
+ // re-drives it). Already taken: show it, as its own Ask, instead of a second one.
1379
+ if (r.status === 409 && code === 'run_in_progress' && run) {
1380
+ if (run.state === 'submitting' && typeof run.clientRequestId === 'string' && mayRedrive) {
1381
+ setPendingRun(p, { rid: run.clientRequestId, model: run.model || '', effort: run.effort || '', cont: !!run.continue });
1382
+ p.running = true; paintPrep();
1383
+ return prepQueue(p, function () { return postRun(p, p.pending, true); }).then(function (r2) { return settleRun(p, r2, false); });
1384
+ }
1385
+ if (run.clientJobId) { setPendingRun(p, null); return startPrepJob(p, run, null); }
1386
+ }
1387
+ // The run failed on the Mac: say so plainly; the next tap is a fresh run.
1388
+ if (r.status === 409 && code === 'run_failed') {
1389
+ setPendingRun(p, null);
1390
+ var why = run && typeof run.error === 'string' && run.error ? run.error.replace(/_/g, ' ') : '';
1391
+ setPrepNote(p, 'This prep did not start on your Mac' + (why ? ' (' + why + ')' : '') + '. Tap Prep me to try again.', true);
1392
+ return paintPrep();
1393
+ }
1394
+ // No answer the Mac could vouch for (503, run_status_unknown): keep the run; the next tap is the same request.
1395
+ if (r.status >= 500) { setPrepNote(p, prepErrorText(r) + ' Tap Prep me again; it will not run twice.', true); return paintPrep(); }
1396
+ // Refused, so no run exists: the next tap is a new request. (-1 and -2: stopped here; the note already says why.)
1397
+ if (r.status > 0) setPendingRun(p, null);
1398
+ if (r.status !== -1 && r.status !== -2 && r.status !== 409) setPrepNote(p, prepErrorText(r), true);
1399
+ paintPrep();
1400
+ }
1401
+ // The run's answer was lost twice. v1.1: GET .../runs/<clientRequestId> answers {run, job} when the run was taken and
1402
+ // 404 run_not_accepted when it was not. A server without that route: the draft lists its runs, and the run's
1403
+ // clientJobId finds the job (/api/query-jobs/by-client).
1404
+ // 202 {run}: the Mac is still admitting it, so ask again shortly (a few times). 503 run_status_unknown: the Mac could
1405
+ // not tell; the next tap is the same request, so it cannot run twice.
1406
+ var PREP_RUN_WAITS = [1500, 1500, 3000, 3000, 5000];
1407
+ function recoverPrepRun(p, rid, tries) {
1408
+ tries = tries || 0;
1409
+ return prepCall(prepPath(p) + '/runs/' + encodeURIComponent(rid)).then(function (r) {
1410
+ 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); }
1411
+ if (r.status === 202 && tries < PREP_RUN_WAITS.length) {
1412
+ setPrepNote(p, 'Your Mac is starting this prep.', false); p.running = true; paintPrep();
1413
+ return new Promise(function (res) { setTimeout(res, PREP_RUN_WAITS[tries]); }).then(function () { p.running = false; return recoverPrepRun(p, rid, tries + 1); });
1414
+ }
1415
+ if (r.status === 202 || (r.status === 404 && prepCode(r) === 'run_not_accepted') || (r.status === 503 && prepCode(r) === 'run_status_unknown')) return notStarted(p);
1416
+ return recoverFromDraft(p, rid);
1417
+ });
1418
+ }
1419
+ // A run that already ended and whose job the Mac no longer keeps (outcome 'unavailable', job null): there is nothing
1420
+ // to poll. Its copied answer is shown when the Mac kept one; otherwise one line says so. The stored run is released
1421
+ // by the caller, so the next tap is a fresh run.
1422
+ function ended(p, run, job) {
1423
+ if (!run || job || !(run.outcome === 'unavailable' || run.settledAt)) return false;
1424
+ if (typeof run.answer === 'string' && run.answer) {
1425
+ 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: [] };
1426
+ C.asks.push(a);
1427
+ if (P === p) closePrep();
1428
+ paintFeed(true);
1429
+ return true;
1430
+ }
1431
+ setPrepNote(p, 'This prep finished earlier, but its answer is no longer kept. Tap Prep me to run it again.', true);
1432
+ paintPrep();
1433
+ return true;
1434
+ }
1435
+ function notStarted(p) {
1436
+ 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);
1437
+ paintPrep();
1438
+ }
1439
+ function recoverFromDraft(p, rid) {
1440
+ return prepCall(prepPath(p)).then(function (r) {
1441
+ if (r.status === 200 && r.body.draft) adoptPrep(p, r.body.draft, false);
1442
+ var run = r.status === 200 ? (((r.body.draft || {}).runs) || []).filter(function (x) { return x && x.clientRequestId === rid; })[0] : null;
1443
+ if (run && run.clientJobId) { setPendingRun(p, null); return startPrepJob(p, run, null); }
1444
+ notStarted(p);
1445
+ });
1446
+ }
1447
+ // v1.1: the job is an ordinary web Ask titled "Prep: <title>"; the chat shows the same words, and the focus under them.
1448
+ function prepQuery(p) {
1449
+ var steer = String((p.draft && p.draft.steer) || '').trim();
1450
+ return 'Prep: ' + p.title + (steer ? '\n' + clip(steer, 160) : '');
1451
+ }
1452
+ function startPrepJob(p, run, job) {
1453
+ var cid = (run && run.clientJobId) || (job && job.clientJobId) || '';
1454
+ var a = { cid: cid || uuid(), query: prepQuery(p), at: Date.now(), status: 'sending', partial: '', dropId: '', replyKey: '', prepDraftId: p.draft.id,
1455
+ atts: prepFiles(p.draft).filter(function (f) { return f.status === 'ready'; }).map(function (f) { return { name: f.name, kind: f.kind === 'image' ? 'photo' : 'doc' }; }) };
1456
+ C.asks.push(a);
1457
+ if (P === p) closePrep();
1458
+ paintFeed(true);
1459
+ if (job && job.jobId) { take(a, job); paintAsk(a); poll(a); return a; }
1460
+ 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; }
1461
+ api('/api/query-jobs/by-client/' + encodeURIComponent(cid) + '?generation=1').then(function (d) {
1462
+ if (!d || !d.job) throw new Error('the Mac did not find the prep answer');
1463
+ take(a, d.job); paintAsk(a); poll(a);
1464
+ }).catch(function (err) {
1465
+ a.status = 'failed';
1466
+ a.error = 'Not sent: ' + ((err && err.message) || 'the Mac did not answer') + '. Tap Prep me on the card again; it will not run twice.';
1467
+ paintFeed(false);
1468
+ });
1469
+ return a;
1470
+ }
1471
+
1472
+ // Archive this prep (DELETE /api/prep/drafts/:id, the server keeps it as archived): it frees one of the drafts the Mac
1473
+ // keeps, and the card's Prep me then starts a new one. Nothing a meeting already took is touched.
1474
+ function archivePrep(p) {
1475
+ if (!p.draft || p.running || p.archiving) return;
1476
+ p.archiving = true; setPrepNote(p, 'Archiving this prep…', false);
1477
+ clearTimeout(p.timer);
1478
+ prepQueue(p, function () {
1479
+ return prepCall(prepPath(p) + '?revision=' + encodeURIComponent(p.draft.revision), { method: 'DELETE' });
1480
+ }).then(function (r) {
1481
+ p.archiving = false;
1482
+ if (r.status >= 200 && r.status < 300) {
1483
+ forgetPrepRec(p.key);
1484
+ p.dirty = false; p.pending = null;
1485
+ if (P === p) closePrep();
1486
+ W.toast('Prep archived. Prep me on the card starts a new one.');
1487
+ return;
1488
+ }
1489
+ var conflict = r.status === 409 && prepCode(r) === 'revision_conflict';
1490
+ if (conflict && r.body.draft) return adoptPrep(p, r.body.draft, true);
1491
+ setPrepNote(p, 'Not archived. ' + prepErrorText(r), true);
1492
+ });
1493
+ }
1494
+
1495
+ // ── the Prep composer on the page ─────────────────────
1496
+ // It sits above the chat's composer, outside the feed, so a poll that redraws the feed never takes the words or the
1497
+ // caret. Built once per draft; after that only its parts are written.
1498
+ function prepSlot(create) {
1499
+ var chat = C.host && C.host.querySelector && C.host.querySelector('[data-cos-chat]');
1500
+ if (!chat) return null;
1501
+ var slot = chat.querySelector('[data-cos-prep]');
1502
+ if (!P) { if (slot) slot.remove(); return null; }
1503
+ if (!slot && create !== false) {
1504
+ slot = document.createElement('div');
1505
+ slot.className = 'cc-prep'; slot.setAttribute('data-cos-prep', '');
1506
+ chat.insertBefore(slot, chat.querySelector('[data-cos-form="chat"]'));
1507
+ }
1508
+ return slot;
1509
+ }
1510
+ function prepBox() { var slot = prepSlot(false); return slot && slot.querySelector('[data-cos-prep-steer]'); }
1511
+ function focusPrep() { var box = prepBox(); if (box && keyboardFirst()) box.focus(); }
1512
+ function prepHeadLine(p) {
1513
+ var when = prepWhen(p.startsAt);
1514
+ return (when ? 'Starts ' + when + '. ' : '') + 'Add what matters and any files, then Prep me. Add them to the meeting from its live view.';
1515
+ }
1516
+ function prepPanelHtml(p) {
1517
+ return '<section class="mt-panel cc-prep-panel" aria-label="' + esc('Prep for ' + p.title) + '">' +
1518
+ '<div class="mt-ask-head"><h3 class="mt-ask-title cc-prep-title">' + esc('Prep for ' + p.title) + '</h3>' +
1519
+ '<span class="cc-prep-head-acts"><button type="button" class="mt-link" data-cos="prep-archive">Archive this prep</button>' +
1520
+ '<button type="button" class="mt-link" data-cos="prep-close">Close</button></span></div>' +
1521
+ '<p class="mt-ask-line">' + esc(prepHeadLine(p)) + '</p>' +
1522
+ '<form class="mt-composer cc-prep-form" data-cos-prep-form autocomplete="off">' +
1523
+ '<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>' +
1524
+ '<div class="cc-prep-acts"><span class="mt-muted cc-prep-count" data-prep-count>' + esc(prepCountText(p)) + '</span>' +
1525
+ '<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>' +
1526
+ '<input type="file" class="cc-file" multiple accept="' + PREP_ACCEPT + '" data-cos-prep-file aria-label="Add files to this prep"></label>' +
1527
+ '<button type="button" class="cx-btn primary" data-cos="prep-run">' + esc(prepRunLabel(p)) + '</button></div>' +
1528
+ '<p class="' + (p.warn ? 'mt-amber-line' : 'mt-muted') + ' cc-prep-note" data-prep-note role="status"' + (p.note ? '' : ' hidden') + '>' + esc(p.note) +
1529
+ (p.signedOut ? ' <button type="button" class="link-btn cc-text-btn" data-cos="sign-in">Sign in again</button>' : '') + '</p>' +
1530
+ // The box and Prep me first, so both show as soon as it opens; the files list under them grows downward.
1531
+ '<div data-prep-files>' + prepFilesHtml(p) + '</div>' +
1532
+ '</section>';
1533
+ }
1534
+ function prepFileMeta(f) {
1535
+ if (f.status === 'ready') return [f.notice || '', Number(f.bytes) > 0 ? sizeLabel(Number(f.bytes)) : ''].filter(Boolean).join(' · ');
1536
+ return f.notice || PREP_FILE_STATE[f.status] || '';
1537
+ }
1538
+ function prepUpMeta(t) {
1539
+ if (t.state === 'reading') return t.kind === 'photo' ? 'Preparing the image' : 'Preparing';
1540
+ if (t.state === 'uploading') return 'Uploading ' + Math.round(t.progress * 100) + '%';
1541
+ if (t.state === 'adding') return t.kind === 'photo' ? 'Reading the text in the image' : 'Adding to the prep';
1542
+ return t.error || 'Did not upload';
1543
+ }
1544
+ function prepFilesHtml(p) {
1545
+ var files = prepFiles(p.draft), ups = p.ups;
1546
+ var title = p.loading ? 'Opening this prep…' : files.length ? 'Files · ' + files.length + (ups.length ? ' · adding ' + ups.filter(function (t) { return t.state !== 'failed'; }).length : '')
1547
+ : ups.length ? 'Adding files' : 'No files yet. Add file, paste or drop one here.';
1548
+ var rows = files.map(function (f) {
1549
+ var meta = prepFileMeta(f);
1550
+ 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>' : '') +
1551
+ '<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>';
1552
+ }).concat(ups.map(function (t) {
1553
+ return '<li class="mt-up ' + t.state + '" data-prep-up="' + esc(t.id) + '"><span class="mt-files-name">' + esc(t.name) + '</span>' +
1554
+ '<span class="mt-up-meta ' + (t.state === 'failed' ? 'mt-amber' : 'mt-muted') + '" data-up-meta>' + esc(prepUpMeta(t)) + '</span>' +
1555
+ '<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>' : '') +
1556
+ '<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>' +
1557
+ (t.state === 'uploading' || t.state === 'reading' ? '<span class="mt-up-bar" data-up-bar style="width:' + Math.round(t.progress * 100) + '%"></span>' : '') + '</li>';
1558
+ }));
1559
+ 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>' +
1560
+ (rows.length ? '<ul class="mt-files-list">' + rows.join('') + '</ul>' : '') + '</div>';
1561
+ }
1562
+ function prepCountText(p) {
1563
+ var n = p.steer.length, max = prepChars();
1564
+ return n >= max - 400 ? n.toLocaleString() + ' of ' + max.toLocaleString() + ' characters' : '';
1565
+ }
1566
+ function prepRunLabel(p) { return p.running ? 'Prepping…' : prepUploading(p) ? 'Uploading…' : 'Prep me'; }
1567
+ function setPrepNote(p, text, warn) {
1568
+ p.note = text || ''; p.warn = !!warn && !!text;
1569
+ p.signedOut = /signed out/i.test(p.note);
1570
+ if (P === p) paintPrepParts(p);
1571
+ }
1572
+ function paintPrep(rebuild) {
1573
+ var slot = prepSlot(!!P);
1574
+ if (!slot || !P) return;
1575
+ if (rebuild || slot.getAttribute('data-key') !== P.key) {
1576
+ slot.setAttribute('data-key', P.key);
1577
+ slot.innerHTML = prepPanelHtml(P);
1578
+ var box = slot.querySelector('[data-cos-prep-steer]'); if (box) W.autoGrow(box);
1579
+ return;
1580
+ }
1581
+ var files = slot.querySelector('[data-prep-files]'); if (files) files.innerHTML = prepFilesHtml(P);
1582
+ paintPrepParts(P);
1583
+ }
1584
+ // The button, the count and the note: never the box (the words and the caret stay where they are).
1585
+ function paintPrepParts(p) {
1586
+ var slot = P === p ? prepSlot(false) : null; if (!slot) return;
1587
+ var run = slot.querySelector('[data-cos="prep-run"]');
1588
+ if (run) { run.disabled = !p.draft || p.running || prepUploading(p); run.textContent = prepRunLabel(p); }
1589
+ var count = slot.querySelector('[data-prep-count]'); if (count) count.textContent = prepCountText(p);
1590
+ var pick = slot.querySelector('[data-cos-prep-file]'); if (pick) pick.disabled = !p.draft || prepFiles(p.draft).length + p.ups.length >= prepMax();
1591
+ var note = slot.querySelector('[data-prep-note]');
1592
+ if (note) {
1593
+ note.className = (p.warn ? 'mt-amber-line' : 'mt-muted') + ' cc-prep-note';
1594
+ note.innerHTML = esc(p.note) + (p.signedOut ? ' <button type="button" class="link-btn cc-text-btn" data-cos="sign-in">Sign in again</button>' : '');
1595
+ note.hidden = !p.note;
1596
+ }
1597
+ }
1598
+ function paintPrepProgress(t) {
1599
+ var slot = prepSlot(false); if (!slot) return;
1600
+ var row = slot.querySelector('[data-prep-up="' + t.id + '"]'); if (!row) return;
1601
+ var meta = row.querySelector('[data-up-meta]'); if (meta) meta.textContent = prepUpMeta(t);
1602
+ var bar = row.querySelector('[data-up-bar]'); if (bar) bar.style.width = Math.round(t.progress * 100) + '%';
1603
+ }
1604
+ function onPrepClick(t, what) {
1605
+ var p = P; if (!p) return;
1606
+ if (what === 'prep-close') return closePrep();
1607
+ if (what === 'prep-run') return runPrep(p);
1608
+ if (what === 'prep-archive') return archivePrep(p);
1609
+ if (what === 'prep-remove') return removePrepFile(p, t.getAttribute('data-file'));
1610
+ var up = p.ups.filter(function (x) { return x.id === t.getAttribute('data-up'); })[0];
1611
+ if (!up) return;
1612
+ if (what === 'prep-retry' && up.state === 'failed') return sendPrepFile(p, up);
1613
+ if (what === 'prep-cancel') {
1614
+ if (up.xhr) { try { up.xhr.abort(); } catch (_e) {} }
1615
+ p.ups = p.ups.filter(function (x) { return x !== up; });
1616
+ paintPrep();
1617
+ }
1618
+ }
1619
+ function onPrepDrag(e, slot) {
1620
+ var types = e.dataTransfer && e.dataTransfer.types;
1621
+ if (!(types && Array.prototype.indexOf.call(types, 'Files') >= 0)) return;
1622
+ e.preventDefault();
1623
+ if (e.type === 'dragover' || e.type === 'dragenter') { slot.classList.add('dragging'); return; }
1624
+ slot.classList.remove('dragging');
1625
+ if (e.type === 'drop') addPrepFiles(e.dataTransfer.files);
1626
+ }
1627
+
1022
1628
  // ── events (delegated from the Messages pane) ─────────
1023
1629
  function onClick(t) {
1024
1630
  var what = t.getAttribute('data-cos');
1631
+ if (/^prep-/.test(what || '')) return onPrepClick(t, what);
1025
1632
  if (what === 'ask') return doAsk(kOf(t));
1026
1633
  if (what === 'drop') return doDrop();
1027
1634
  if (what === 'all') return W.go('messages');
@@ -1060,6 +1667,7 @@
1060
1667
  doAsk(k);
1061
1668
  }
1062
1669
  function onInput(e) {
1670
+ if (e.target && e.target.hasAttribute && e.target.hasAttribute('data-cos-prep-steer')) return onPrepInput(e.target);
1063
1671
  if (isComposerInput(e.target)) { var k = kOf(e.target); k.draft = e.target.value; saveDraft(k); W.autoGrow(e.target); paintButtons(k); }
1064
1672
  }
1065
1673
  function onChangeEvent(e) {
@@ -1068,6 +1676,7 @@
1068
1676
  if (t.hasAttribute('data-cos-model')) { C.model = t.value; save(); syncMenus(); }
1069
1677
  else if (t.hasAttribute('data-cos-effort')) { C.effort = t.value; save(); syncMenus(); }
1070
1678
  else if (t.hasAttribute('data-cos-file')) { addFiles(kOf(t), t.files); t.value = ''; }
1679
+ else if (t.hasAttribute('data-cos-prep-file')) { addPrepFiles(t.files); t.value = ''; }
1071
1680
  }
1072
1681
  // Two composers can be on screen (the chat beside a reader is not, but a reload can leave either): keep both menus true.
1073
1682
  function syncMenus() {
@@ -1078,15 +1687,23 @@
1078
1687
  var f = e.target.closest && e.target.closest('[data-cos-steer]');
1079
1688
  if (f) { e.preventDefault(); steer(f.getAttribute('data-cos-steer'), (f.querySelector('input') || {}).value || ''); return true; }
1080
1689
  if (e.target.matches && e.target.matches('[data-cos-form]')) { e.preventDefault(); return true; }
1690
+ if (e.target.matches && e.target.matches('[data-cos-prep-form]')) { e.preventDefault(); return true; }
1081
1691
  return false;
1082
1692
  }
1083
1693
  // Paste a photo or a file into the box; drop files on the composer.
1084
1694
  function onPaste(e) {
1695
+ if (e.target && e.target.hasAttribute && e.target.hasAttribute('data-cos-prep-steer')) {
1696
+ var pf = e.clipboardData && e.clipboardData.files;
1697
+ if (pf && pf.length) { e.preventDefault(); addPrepFiles(pf); }
1698
+ return;
1699
+ }
1085
1700
  if (!isComposerInput(e.target)) return;
1086
1701
  var files = e.clipboardData && e.clipboardData.files;
1087
1702
  if (files && files.length) { e.preventDefault(); addFiles(kOf(e.target), files); }
1088
1703
  }
1089
1704
  function onDrag(e) {
1705
+ var prep = e.target && e.target.closest && e.target.closest('[data-cos-prep]');
1706
+ if (prep) return onPrepDrag(e, prep);
1090
1707
  var form = e.target && e.target.closest && e.target.closest('[data-cos-form]');
1091
1708
  if (!form) return;
1092
1709
  var types = e.dataTransfer && e.dataTransfer.types;
@@ -1113,6 +1730,24 @@
1113
1730
  setOnChange: function (fn) { C.onChange = fn; }, setOnAnswered: function (fn) { C.onAnswered = fn; },
1114
1731
  // Test and render hooks: what the chat holds right now.
1115
1732
  classify: classify, uploadMedia: uploadMedia, uploadErrorText: uploadErrorText, uuid: uuid,
1733
+ // 6.99.19: Prep. meetings.js reads the capability from here (one /api/health read for the page).
1734
+ prepCaps: prepCaps,
1735
+ // Today's Next up (app.js prepNext): Prep me for that meeting, as the chat's own Prep me does. With Prep on the Mac
1736
+ // the Prep composer opens (it paints when the chat mounts; the same meeting reopens the same draft); without it,
1737
+ // the plain Ask. Resolves to 'prep', 'ask' or '' (neither is on).
1738
+ prepFor: function (title, startsAt) {
1739
+ return ensureCaps().then(function () {
1740
+ var t = String(title || '').trim() || 'my next meeting';
1741
+ var item = { id: 'today-next:' + (startsAt || '') + ':' + t, title: t, prep: { title: t, startsAt: startsAt || null } };
1742
+ if (prepOn()) { openPrep(item); return 'prep'; }
1743
+ if (C.caps && C.caps.jobs) { submitAsk('Prep me for ' + t, ''); return 'ask'; }
1744
+ return '';
1745
+ });
1746
+ },
1747
+ // The Prep server id to name on a write: the newest from capabilities.prep or any Prep answer (meetings.js reports its own).
1748
+ prepInstance: function () { return PREP_INST; }, notePrepInstance: function (id) { if (typeof id === 'string' && id) PREP_INST = id; },
1749
+ _prep: function () { return P; }, _openPrep: openPrep, _prepHtml: function () { return P ? prepPanelHtml(P) : ''; }, _addPrepFiles: addPrepFiles,
1750
+ _runPrep: function () { runPrep(P); }, _sha256Js: sha256Js, _closePrep: closePrep, _onPrepInput: onPrepInput, _savePrepSteer: function () { return P ? savePrepSteer(P) : null; },
1116
1751
  _state: C, _composer: composer, _classify: classify, _modelsFrom: modelsFrom, _addFiles: addFiles, _itemHtml: itemHtml, _setCaps: setCaps, _threadLine: threadLine, _postTurn: postTurn
1117
1752
  };
1118
1753
  })();