@gotcos/glasses-server 6.99.29 → 7.0.7

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 (80) hide show
  1. package/.env.example +18 -0
  2. package/CHANGELOG.md +475 -0
  3. package/README.md +3 -1
  4. package/managed-runtime-contract.json +3 -0
  5. package/package.json +1 -1
  6. package/server/index.ts +29 -7
  7. package/server/lib/agent-session-output-images.ts +627 -49
  8. package/server/lib/agent-session-store.ts +24 -11
  9. package/server/lib/agent-session-tool-images.ts +224 -0
  10. package/server/lib/agent-session-turns.ts +19 -2
  11. package/server/lib/api-auth.ts +6 -0
  12. package/server/lib/ask-work-plan.ts +3 -1
  13. package/server/lib/calendar-source.ts +491 -0
  14. package/server/lib/codex-bridge.ts +4 -1
  15. package/server/lib/codex-run-ledger.ts +29 -0
  16. package/server/lib/context-builder.ts +2 -1
  17. package/server/lib/drop-p2-runtime.ts +1 -1
  18. package/server/lib/drop-store.ts +10 -4
  19. package/server/lib/learning-needs-you.ts +24 -7
  20. package/server/lib/live-events.ts +533 -0
  21. package/server/lib/local-path-redact.ts +272 -0
  22. package/server/lib/meeting-ask-orientation.ts +40 -6
  23. package/server/lib/meeting-ask-runner.ts +30 -10
  24. package/server/lib/morning-brief-runtime.ts +4 -2
  25. package/server/lib/native-mac-caller.ts +29 -0
  26. package/server/lib/ping-engine.ts +1 -1
  27. package/server/lib/ping-sources.ts +11 -6
  28. package/server/lib/preview-content.ts +172 -0
  29. package/server/lib/preview-error-code.ts +10 -0
  30. package/server/lib/preview-folders-setting.ts +133 -0
  31. package/server/lib/preview-log-throttle.ts +18 -0
  32. package/server/lib/preview-thumbs.ts +139 -0
  33. package/server/lib/secret-path-guard.ts +80 -0
  34. package/server/lib/session-files-setting.ts +88 -0
  35. package/server/lib/session-links.ts +280 -0
  36. package/server/lib/thread-turn-queue.ts +10 -0
  37. package/server/lib/today-brief.ts +119 -28
  38. package/server/lib/web-memories-bridge.ts +3 -1
  39. package/server/lib/work-activity.ts +56 -6
  40. package/server/lib/work-session-activity.ts +260 -0
  41. package/server/lib/your-move-count.ts +9 -3
  42. package/server/routes/agent-session-bindings.ts +13 -0
  43. package/server/routes/agent-session-stream.ts +16 -0
  44. package/server/routes/agent-sessions.ts +146 -11
  45. package/server/routes/ask-work-plan.ts +4 -1
  46. package/server/routes/claude-sessions.ts +5 -1
  47. package/server/routes/health.ts +35 -0
  48. package/server/routes/live-events.ts +329 -0
  49. package/server/routes/permission-broker.ts +69 -5
  50. package/server/routes/session-preview.ts +200 -0
  51. package/server/routes/thread-turn-queue.ts +207 -3
  52. package/server/routes/today-brief.ts +44 -9
  53. package/server/routes/welcome-context.ts +23 -3
  54. package/server/routes/work-board.ts +9 -2
  55. package/server/web-control/app.css +79 -9
  56. package/server/web-control/app.js +1543 -65
  57. package/server/web-control/control.css +37 -6
  58. package/server/web-control/index.html +8 -2
  59. package/server/web-control/memory-review.js +100 -0
  60. package/server/web-control/panes/cos-chat.css +51 -6
  61. package/server/web-control/panes/cos-chat.js +268 -16
  62. package/server/web-control/panes/meetings.css +23 -4
  63. package/server/web-control/panes/meetings.js +25 -9
  64. package/server/web-control/panes/memories.css +18 -3
  65. package/server/web-control/panes/memories.js +130 -50
  66. package/server/web-control/panes/messages.js +21 -2
  67. package/server/web-control/panes/preview.js +318 -0
  68. package/server/web-control/panes/questions.css +20 -4
  69. package/server/web-control/panes/questions.js +127 -26
  70. package/server/web-control/panes/reader-media.js +409 -23
  71. package/server/web-control/panes/sessions.css +50 -10
  72. package/server/web-control/panes/settings.js +96 -5
  73. package/server/web-control/panes/speakers.css +3 -5
  74. package/server/web-control/panes/speakers.js +7 -4
  75. package/server/web-control/panes/threads.js +3 -2
  76. package/server/web-control/panes/today-actions.css +49 -18
  77. package/server/web-control/panes/today-actions.js +217 -50
  78. package/server/web-control/panes/work.css +11 -1
  79. package/server/web-control/panes/work.js +10 -2
  80. package/server/web-control/shell.js +20 -14
@@ -19,9 +19,19 @@
19
19
  (function () {
20
20
  'use strict';
21
21
  var W = window.COSWeb;
22
+ // What counts as Needs review: one predicate, shared with the server's count (memory-review.js, loaded by index.html).
23
+ var R = window.COSMemoryReview || null;
22
24
  var esc = W.esc;
23
25
  var pane = W.$('pane-memories');
24
26
  if (!pane) return;
27
+ // 7.0.4 QA: a tab opened before the update has the old index.html, which never loaded memory-review.js, and fetches
28
+ // this pane fresh. Load the predicate here; until it arrives, review rows and counts read as not yet known.
29
+ if (!R) {
30
+ var reviewScript = document.createElement('script');
31
+ reviewScript.src = '/control/memory-review.js';
32
+ reviewScript.onload = function () { R = window.COSMemoryReview || null; if (R && !pane.classList.contains('hidden')) { try { paint(); } catch (_e) { /* the next paint draws it */ } } };
33
+ document.head.appendChild(reviewScript);
34
+ }
25
35
 
26
36
  // ── vocabulary (plain words for the store and event names Control's page shows) ──
27
37
  var KIND = { captured: 'Captured', proposed: 'Proposed', promotable: 'Suggested rule', saved: 'Saved', retrieved: 'Recalled',
@@ -31,7 +41,8 @@
31
41
  review_ledger: 'Your review decisions', git_versions: 'Skill versions', eval_scores: 'Run checks', reflect_log: 'Daily reflection',
32
42
  capture_ledger: 'Capture record', memory_trace: 'Recall record', retrieval_log: 'Recall record', used: 'Use record',
33
43
  memory: 'Saved memory', occurrence: 'When it happened', assistant_turn: 'An answer' };
34
- var GATE = { attribution: 'Who said it is unclear', low_confidence: 'Low confidence', prune_ready: 'Ready to quarantine', contradiction: 'Contradicts another memory' };
44
+ var GATE = { attribution: 'Who said it is unclear', rule_ready: 'Ready to become a rule', low_confidence: 'Ready to become a rule', prune_ready: 'Ready to quarantine',
45
+ contradiction: 'Contradicts another memory', scope: 'No area set', candidate: 'Waiting for your acceptance' };
35
46
  var VIEWS = [['recent', 'Recent learning'], ['applied', 'Applied this week'], ['memories', 'All memories'], ['review', 'To review'], ['knowledge', 'Knowledge']];
36
47
  // Control's cue review (MeetingAsk.swift CueReviewPane): shown beside the views once the server's cue history answers.
37
48
  var CUE_VIEWS = [['cues', 'Cues to rate'], ['rated', 'Rated cues']];
@@ -109,19 +120,14 @@
109
120
  // Copy stays in this browser: the same copy the rest of Control Web uses (plain-http fallback included).
110
121
  if (op === 'copy') return W.copyText(String(a.text || '')).then(function () { return {}; });
111
122
  var limit = READ_LIMIT[op] != null ? READ_LIMIT[op] : (isRead(op, a) ? 45000 : 0);
112
- return W.api('/api/web-bridge/memories', { method: 'POST', body: { id: ++seq, op: op, args: a }, timeoutMs: limit }).then(null, plainNetError).then(function (r) {
123
+ return W.api('/api/web-bridge/memories', { method: 'POST', body: { id: ++seq, op: op, args: a }, timeoutMs: limit }).then(function (r) {
113
124
  if (r && r.ok) return r.details || {};
114
125
  var raw = (r && r.message) || 'Your Mac did not answer that.';
115
126
  var err = new Error(PLAIN[raw] || raw); err.code = raw; throw err;
116
127
  });
117
128
  }
118
- // No HTTP status means the request never reached COS: the browser's own words ("Failed to fetch", "Load failed") say
119
- // nothing to a person, so say what happened and what to do. A timeout already says it in plain words (W.api).
120
- var UNREACHABLE = 'Could not reach COS on your Mac. Check that the Mac is awake and on Tailscale, then try again.';
121
- function plainNetError(err) {
122
- if (err && !err.status && err.code !== 'timeout') { var e = new Error(UNREACHABLE); e.code = 'unreachable'; throw e; }
123
- throw err;
124
- }
129
+ // A request that never reached COS is said in plain words by W.api itself (app.js, 6.99.30: code 'unreachable'),
130
+ // as a timeout is: nothing to translate here.
125
131
  function codeOf(e) { return String((e && (e.code || e.message)) || ''); }
126
132
 
127
133
  // ── state ────────────────────────────────────────────────
@@ -137,7 +143,7 @@
137
143
  graph: null, graphError: '', setup: null, setupError: '', setupLoading: false, setupBusy: '', progress: null,
138
144
  ingest: null, ingestLimit: 10, building: '', folderDraft: '',
139
145
  detail: {}, memDetail: {}, passages: {}, loading: {},
140
- decisions: {}, decisionTs: {}, decided: {},
146
+ decisions: {}, decisionTs: {}, decided: {}, history: {},
141
147
  armed: '', reviewBusy: '', reviewIntent: null, bulk: null,
142
148
  ask: { q: '', busy: false, answer: null, error: '', cards: [], cardsBusy: false, startedAt: 0, message: '', pending: null },
143
149
  focus: null, focusChosen: false, focusResolved: false, knowledgeFocus: null, neighbors: [], graphVisible: null,
@@ -147,7 +153,7 @@
147
153
  owner: null, ownerError: '', ownerBusy: false,
148
154
  policy: null, policyError: '', policyBusy: false, policyIntent: null, policyLoading: false,
149
155
  guard: null, guardError: '', guardBusy: '', guardRun: null, guardLines: 0, guardLoading: false,
150
- steward: { tab: 'rules', rows: [], cursor: null, busy: false, error: '', intent: null, outcomes: null, serial: 0 }
156
+ steward: { tab: 'rules', rows: [], cursor: null, busy: false, error: '', intent: null, outcomes: null, serial: 0, loaded: false }
151
157
  };
152
158
  function persist() { W.writeStore(STORE_KEY, { view: S.view, ktab: S.ktab }); }
153
159
  var serials = {};
@@ -390,7 +396,16 @@
390
396
  var o = outcomeText(e);
391
397
  if (o) { var r = typeof e.outcome === 'object' ? e.outcome.result : e.outcome; return { text: 'Checked: ' + o, tone: /^(pass|passed|success)$/.test(r) ? 'ok' : /^(fail|failed)$/.test(r) ? 'bad' : '' }; }
392
398
  if (e.needs) return { text: (GATE[e.needs.gate] || 'Needs you') + (e.needs.reason ? '. ' + e.needs.reason : ''), tone: 'warn' };
393
- if (isProposal(e) || isPattern(e)) return { text: decisionFor(e) === 'dismissed' ? 'Dismissed' : 'Needs your review', tone: decisionFor(e) === 'dismissed' ? '' : 'warn' };
399
+ if (e.learning) return { text: 'Learning. ' + (e.learning.reason || 'Not a rule yet'), tone: '' };
400
+ if (isProposal(e) || isPattern(e)) {
401
+ // Needs review only when the predicate says so (a suggested rule at its bar, in the needs list); a suggestion
402
+ // with no next step but Dismiss is listed calmly.
403
+ if (closedHere(e)) return { text: 'Dismissed', tone: '' };
404
+ if (needsLesson(e.lesson_id)) return { text: 'Needs your review', tone: 'warn' };
405
+ var lr = learningFor(e.lesson_id);
406
+ if (lr) return { text: 'Learning. ' + lr.learning.reason, tone: '' };
407
+ return { text: isProposal(e) ? 'Suggested by a scheduled task' : 'Watching. Not a rule yet', tone: '' };
408
+ }
394
409
  if (e.event_type === 'dismissed') return { text: 'Dismissed', tone: '' };
395
410
  if (e.event_type === 'retrieved') return { text: 'Recalled. Not yet shown to be used.', tone: '' };
396
411
  if (e.event_type === 'included') return { text: 'Included in context. Not yet shown to be used.', tone: '' };
@@ -415,38 +430,65 @@
415
430
  function plainReason(r) {
416
431
  var t = String(r || ''), m;
417
432
  if ((m = /^promotable at confidence ([\d.]+) \(< ([\d.]+)\)$/.exec(t))) return 'Confidence ' + m[1] + ', under the ' + m[2] + ' bar for a rule';
433
+ if ((m = /^promotable at confidence ([\d.]+) \(>= ([\d.]+)\)$/.exec(t))) return 'Confidence ' + m[1] + ', at or over the ' + m[2] + ' bar for a rule';
434
+ if (/^promotable at confidence unknown/.test(t)) return 'Confidence not known yet, so it cannot become a rule';
418
435
  if ((m = /^names (.+?) near '(.+?)' with no attribution field$/.exec(t))) return 'Mentions ' + m[1].replace(/\b\w/g, function (c) { return c.toUpperCase(); }) + ' near "' + m[2] + '" without saying who said it';
419
436
  return t;
420
437
  }
421
- function needsItems() {
438
+ function needsBlock() {
422
439
  var ny = (S.recent.info && S.recent.info.needs_you) || (S.status && S.status.needs_you) || null;
423
- return ny && Array.isArray(ny.items) ? ny.items : [];
440
+ return R && ny && Array.isArray(ny.items) ? R.normalizeBlock(ny) : null;
441
+ }
442
+ function needsItems() { return R ? R.openItems(needsBlock(), S.decisions) : []; }
443
+ function needsLesson(id) { return !!id && needsItems().some(function (it) { return it.lesson_id === id; }); }
444
+ // An item's latest decision closes it (accepted, dismissed, pruned): it leaves To review for history.
445
+ function closedHere(e) { return !!R && R.reviewState(null, null, decisionFor(e)) === R.HISTORY; }
446
+ function itemRow(it, extra) {
447
+ var fromList = S.review.rows.filter(function (r) { return r.event_id === it.event_id; })[0] ||
448
+ S.recent.rows.filter(function (r) { return r.event_id === it.event_id; })[0];
449
+ var mem = /^mem_/.test(str(it.lesson_id));
450
+ var base = fromList || { event_id: it.event_id, lesson_id: it.lesson_id, ts: it.ts, title: it.title, category: it.category,
451
+ store: it.store || (mem ? 'bot_memory' : null), event_type: mem ? 'captured' : 'promotable', scope: 'unknown', engine: 'unknown',
452
+ target: { kind: mem ? 'memory' : 'pattern', id: it.lesson_id }, source_refs: [], applies_to: [] };
453
+ return Object.assign({}, base, extra);
454
+ }
455
+ // Below-bar suggestions: listed under Learning with their confidence, never counted, no warning tone.
456
+ function learningRows() {
457
+ if (!R) return [];
458
+ return R.learningItems(needsBlock(), S.decisions).filter(function (it) { return it && it.event_id; }).map(function (it) {
459
+ return itemRow(it, { learning: { reason: plainReason(it.reason), confidence: it.confidence, bar: it.bar } });
460
+ });
424
461
  }
462
+ function learningFor(id) { return id ? learningRows().filter(function (r) { return r.lesson_id === id; })[0] || null : null; }
425
463
  function needsRows() {
426
- // A row decided here stays listed (its reader offers Restore); only the count leaves it out.
464
+ // Only what still needs review: a row decided here leaves the list for history (its reader stays open on it).
427
465
  return needsItems().map(function (it) {
428
- var fromList = S.review.rows.filter(function (r) { return r.event_id === it.event_id; })[0] ||
429
- S.recent.rows.filter(function (r) { return r.event_id === it.event_id; })[0];
430
- var mem = /^mem_/.test(str(it.lesson_id));
431
- var base = fromList || { event_id: it.event_id, lesson_id: it.lesson_id, ts: it.ts, title: it.title, category: it.category,
432
- store: it.store || (mem ? 'bot_memory' : null), event_type: mem ? 'captured' : 'promotable', scope: 'unknown', engine: 'unknown',
433
- target: { kind: mem ? 'memory' : 'pattern', id: it.lesson_id }, source_refs: [], applies_to: [] };
434
- return Object.assign({}, base, { needs: { gate: it.gate, reason: plainReason(it.reason) } });
466
+ return itemRow(it, { needs: { gate: it.gate, reason: plainReason(it.reason) } });
435
467
  });
436
468
  }
469
+ // The same count the server hands Today and the rail (memory-review.js count), less what was decided on this page.
437
470
  function needsCount() {
438
471
  var s = S.status || {}, ny = (S.recent.info && S.recent.info.needs_you) || s.needs_you;
439
- if (ny && Array.isArray(ny.items)) return ny.items.filter(function (it) { return !S.decided[it.lesson_id]; }).length;
440
- if (ny && ny.count != null) return ny.limit != null ? Math.min(Number(ny.count), Number(ny.limit)) : Number(ny.count);
472
+ var n = ny && R ? R.count(R.normalizeBlock(ny), S.decisions) : null;
473
+ if (n != null) return n;
441
474
  if (typeof s.learningToReview === 'number') return s.learningToReview;
442
475
  return null;
443
476
  }
477
+ // To review's other rows: proposed changes still open, and suggested rules only once the predicate counts them.
478
+ function reviewRestRows(seen) {
479
+ var learn = {};
480
+ learningRows().forEach(function (r) { learn[r.lesson_id] = 1; });
481
+ return S.review.rows.filter(function (e) { return !seen[e.event_id] && !closedHere(e) && !(isPattern(e) && learn[e.lesson_id]); });
482
+ }
444
483
  function findRow(id) {
445
- var all = needsRows().concat(S.review.rows, S.recent.rows, S.applied.rows);
484
+ var all = needsRows().concat(learningRows(), S.review.rows, S.recent.rows, S.applied.rows);
446
485
  for (var i = 0; i < all.length; i++) if (all[i].event_id === id) return all[i];
486
+ if (S.history[id]) return S.history[id];
447
487
  var d = S.detail[id];
448
488
  return d && !d._error && d.event_id ? d : null;
449
489
  }
490
+ // A row decided here leaves the lists; its reader keeps it so Restore stays one tap away.
491
+ function keepForReader(e) { if (e && e.event_id) S.history[e.event_id] = e; }
450
492
  function fullRow(e) { var d = S.detail[e.event_id]; return Object.assign({}, e, d && !d._error ? d : {}); }
451
493
  function rewardOn() { return !!(S.status && S.status.learningRewardEnabled === true); }
452
494
  function rankingSentence() { return rewardOn() ? '<p>This lesson now ranks ahead of unused memories like it in later recall.</p>' : ''; }
@@ -806,17 +848,20 @@
806
848
  (L.cursor ? moreBtn('more-recent', 'Load more', L.loading) : '') + coverageNote(L.coverage);
807
849
  }
808
850
  function reviewListHtml() {
809
- var L = S.review, sel = selectedId(), needs = needsRows(), seen = {};
851
+ var L = S.review, sel = selectedId(), needs = needsRows(), learning = learningRows(), seen = {};
810
852
  needs.forEach(function (e) { seen[e.event_id] = 1; });
811
- var rest = L.rows.filter(function (e) { return !seen[e.event_id]; });
853
+ learning.forEach(function (e) { seen[e.event_id] = 1; });
854
+ var rest = reviewRestRows(seen);
812
855
  var other = rest.filter(function (e) { return !isPruneReady(e); }), ready = rest.filter(isPruneReady);
813
856
  var out = '';
814
- var openNeeds = needsCount();
815
- if (needs.length) out += groupHead('Needs you now', openNeeds ? fmt(openNeeds) + ' open' : 'All decided') + '<div class="cx-list">' + needs.map(function (e) { return rowHtml(e, e.event_id === sel); }).join('') + '</div>';
816
- if (!L.loaded && L.loading) return out + groupHead('Proposed changes') + loadingBox('Reading the changes waiting for you');
817
- if (L.error && !L.rows.length) return out + groupHead('Proposed changes') + stateBox('The review list did not load.', L.error, 'refresh');
818
- if (!needs.length && !rest.length) return groupHead('To review') + stateBox('You are up to date.', 'No proposed changes are waiting for review.');
819
- if (other.length) out += groupHead('Proposed changes', L.total != null ? fmt(L.total) + ' in all' : '') + '<div class="cx-list">' + other.map(function (e) { return rowHtml(e, e.event_id === sel); }).join('') + '</div>';
857
+ if (needs.length) out += groupHead('Needs you now', fmt(needsCount() || needs.length) + ' open') + '<div class="cx-list">' + needs.map(function (e) { return rowHtml(e, e.event_id === sel); }).join('') + '</div>';
858
+ var learnHtml = learning.length ? groupHead('Learning', fmt(learning.length) + ' ' + plural(learning.length, 'suggestion', 'suggestions')) +
859
+ '<p class="mm-group-note">Suggested rules still gathering evidence. They cannot become a rule until their confidence reaches the bar, and move to Needs you now on their own when it does.</p>' +
860
+ '<div class="cx-list">' + learning.map(function (e) { return rowHtml(e, e.event_id === sel); }).join('') + '</div>' : '';
861
+ if (!L.loaded && L.loading) return out + groupHead('Proposed changes') + loadingBox('Reading the changes waiting for you') + learnHtml;
862
+ if (L.error && !L.rows.length) return out + groupHead('Proposed changes') + stateBox('The review list did not load.', L.error, 'refresh') + learnHtml;
863
+ if (!needs.length && !rest.length) return groupHead('To review') + stateBox('You are up to date.', 'Nothing is waiting for your decision.') + learnHtml;
864
+ if (other.length) out += groupHead('Proposed changes', fmt(other.length) + ' open') + '<div class="cx-list">' + other.map(function (e) { return rowHtml(e, e.event_id === sel); }).join('') + '</div>';
820
865
  if (ready.length) {
821
866
  var open = ready.filter(function (e) { return !decisionFor(e); }).length, b = S.bulk;
822
867
  out += groupHead('Ready to quarantine', open ? fmt(open) + ' open' : 'All decided') +
@@ -826,7 +871,7 @@
826
871
  '<div class="cx-list">' + ready.map(function (e) { return rowHtml(e, e.event_id === sel); }).join('') + '</div>';
827
872
  }
828
873
  if (L.cursor) out += moreBtn('more-review', 'Load more to review', L.loading);
829
- return out;
874
+ return out + learnHtml;
830
875
  }
831
876
  function memSearchHtml() {
832
877
  return '<form class="cx-search mm-search" data-mm-form="mem-search" role="search"><input class="field" type="search" id="mm-mem-q" placeholder="Search topics, ideas…" value="' + esc(S.mem.q) + '" autocomplete="off" enterkeyhint="search" aria-label="Search memories">' +
@@ -869,7 +914,7 @@
869
914
  if (!isWide() || S.view === 'cues' || S.view === 'rated') return '';
870
915
  if (S.view === 'knowledge') return S.ktab;
871
916
  if (S.view === 'memories') { var m = S.mem.hits != null ? S.mem.hits : S.mem.rows; return m[0] ? m[0].id : ''; }
872
- var rows = S.view === 'applied' ? S.applied.rows : S.view === 'review' ? needsRows().concat(S.review.rows) : S.recent.rows;
917
+ var rows = S.view === 'applied' ? S.applied.rows : S.view === 'review' ? needsRows().concat(reviewRestRows({}), learningRows()) : S.recent.rows;
873
918
  return rows[0] ? rows[0].event_id : '';
874
919
  }
875
920
 
@@ -879,8 +924,9 @@
879
924
  }
880
925
  function quote(text) { return '<blockquote class="mm-quote">' + esc(text) + '</blockquote>'; }
881
926
  function readerHead(title, facts, actions) {
927
+ var full = SPLIT_KIT.fullButton(SPLIT.key, SPLIT.controls);
882
928
  return '<div class="mm-reader-head"><h2 class="mm-title">' + esc(title) + '</h2>' + (facts ? '<p class="mm-sub">' + facts + '</p>' : '') +
883
- (actions ? '<div class="mm-actions">' + actions + '</div>' : '') + '</div>';
929
+ (actions || full ? '<div class="mm-actions">' + (actions || '') + full + '</div>' : '') + '</div>';
884
930
  }
885
931
  function eventReader(id) {
886
932
  var base = findRow(id);
@@ -899,7 +945,8 @@
899
945
  var facts = ['<span class="mm-kind">' + esc(o ? 'Checked: ' + o : kindLabel(e)) + '</span>', esc(storeName(e.store)), esc(known(e.scope)), esc(known(e.engine) !== known(e.scope) ? known(e.engine) : ''), esc(when(e.ts))].filter(function (x) { return x && x !== '<span class="mm-kind"></span>'; }).join(' · ');
900
946
  var actions = btn('open-sources', 'Open sources', { id: e.event_id }) + btn('explore', 'Explore in graph', { id: e.event_id }) + btn('copy-open', 'Copy context', { id: e.event_id, cls: 'primary' });
901
947
  var note = '';
902
- if (e.needs) note += '<div class="cx-notice warn">' + esc((GATE[e.needs.gate] || 'Needs you') + (e.needs.reason ? ': ' + e.needs.reason : '') + '.') + '</div>';
948
+ if (e.needs && !closedHere(e)) note += '<div class="cx-notice warn">' + esc((GATE[e.needs.gate] || 'Needs you') + (e.needs.reason ? ': ' + e.needs.reason : '') + '.') + '</div>';
949
+ else if (e.learning || learningFor(e.lesson_id)) note += '<div class="cx-notice">' + esc('Learning: ' + ((e.learning || learningFor(e.lesson_id).learning).reason || 'not a rule yet') + '. It moves to Needs you now on its own once its confidence reaches the bar.') + '</div>';
903
950
  if (full && full._error && !(e.store === 'bot_memory')) note += '<div class="cx-notice">' + esc(full._error) + '</div>';
904
951
  else if (!full && !(e.store === 'bot_memory' && e.lesson_id) && S.loading[e.event_id]) note += '<p class="cx-note">Reading the full record…</p>';
905
952
  if (e.detail && e.detail.truncated) note += '<div class="cx-notice">The record was shortened to fit. Your Mac holds more than shown here.</div>';
@@ -1074,7 +1121,15 @@
1074
1121
  function renderMarkdown(md) {
1075
1122
  var lines = esc(md || '').split('\n'), out = [], ul = null, table = null;
1076
1123
  function inline(s) { return s.replace(/`([^`]+)`/g, '<code>$1</code>').replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>'); }
1077
- function flush() { if (ul) { out.push('<ul>' + ul.join('') + '</ul>'); ul = null; } if (table) { out.push('<div class="mm-table"><table>' + table.join('') + '</table></div>'); table = null; } }
1124
+ function flush() { if (ul) { out.push('<ul>' + ul.join('') + '</ul>'); ul = null; } if (table) { out.push(tableHtml(table)); table = null; } }
1125
+ // Duo tables: the first row is the header; the shared column floors (app.js tableFit) set how far each column shrinks.
1126
+ function tableHtml(rows) {
1127
+ var fit = W.tableFit(rows);
1128
+ return '<div class="mm-table tf-wrap"><table class="' + fit.tableClass + ' tf-grid">' + rows.map(function (cells, n) {
1129
+ var tag = n ? 'td' : 'th';
1130
+ return '<tr>' + cells.map(function (c, k) { var w = fit.width(k, !n); return '<' + tag + (w ? ' style="' + w + '"' : '') + '>' + fit.cell(k, inline(c), !n) + '</' + tag + '>'; }).join('') + '</tr>';
1131
+ }).join('') + '</table></div>';
1132
+ }
1078
1133
  lines.forEach(function (raw) {
1079
1134
  var line = raw.replace(/\s+$/, '');
1080
1135
  if (!line.trim()) { flush(); return; }
@@ -1085,9 +1140,8 @@
1085
1140
  if (/^\s*\|.*\|\s*$/.test(line)) {
1086
1141
  if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) return;
1087
1142
  if (ul) flush();
1088
- var cells = line.trim().replace(/^\||\|$/g, '').split('|').map(function (c) { return inline(c.trim()); });
1089
- var tag = table ? 'td' : 'th'; table = table || [];
1090
- table.push('<tr>' + cells.map(function (c) { return '<' + tag + '>' + c + '</' + tag + '>'; }).join('') + '</tr>'); return;
1143
+ table = table || [];
1144
+ table.push(line.trim().replace(/^\||\|$/g, '').split('|').map(function (c) { return c.trim(); })); return;
1091
1145
  }
1092
1146
  flush(); out.push('<p>' + inline(line) + '</p>');
1093
1147
  });
@@ -1329,7 +1383,7 @@
1329
1383
  function closeModal() {
1330
1384
  var m = S.modal; if (!m) return;
1331
1385
  if (m.kind === 'merge' && S.merge && (S.merge.phase === 'preview' || S.merge.phase === 'loading')) { var pend = S.merge; S.merge = null; if (pend.resolve) pend.resolve(false); W.toast('Nothing was changed.'); }
1332
- if (m.kind === 'steward') S.steward.serial++;
1386
+ if (m.kind === 'steward') S.steward.serial++; // supersedes the read in flight; the next open starts its own (stewardLoad)
1333
1387
  S.modal = null; paintModal(); if (m.opener && m.opener.focus) { try { m.opener.focus({ preventScroll: true }); } catch (_e) {} }
1334
1388
  }
1335
1389
  function modalHtml() {
@@ -1435,16 +1489,28 @@
1435
1489
  '<ul class="mm-log">' + flagged.concat(review).slice(0, 60).map(function (x) { return '<li><b class="' + (x.verdict === 'prune' ? 'mm-bad' : '') + '">' + (x.verdict === 'prune' ? (x.applied ? 'Quarantined' : 'Would quarantine') : 'For you') + '</b> <span>' + esc(clip(x.excerpt || x.id, 160)) + '</span><span class="mm-muted">' + esc((x.reasons || []).join('; ')) + (x.by === 'model' ? ' (model)' : '') + '</span></li>'; }).join('') + '</ul>' : '') + '</section>';
1436
1490
  }
1437
1491
  // Stewardship (memories/memory-stewardship.js, ported): standing rules, current sources and outcome evidence.
1492
+ // A read never waits on an older one: each takes a new serial and only the newest may answer. Closing the sheet or
1493
+ // switching tabs supersedes the read in flight, and every read ends (an answer, an error, or STEWARD_READ_MS) so
1494
+ // "Working…" can never outlive it. 7.0.2 kept `busy` set when a read was superseded, and the next open or tab
1495
+ // switch returned early on it: Outcome evidence read "Working…" for good.
1496
+ var STEWARD_READ_MS = 30000;
1438
1497
  function stewardLoad(more) {
1439
- var T = S.steward; if (T.busy) return;
1440
- T.busy = true; T.error = ''; var serial = ++T.serial;
1498
+ var T = S.steward; if (T.intent) return;
1499
+ T.busy = true; T.error = ''; T.loaded = false; var serial = ++T.serial;
1441
1500
  var action = T.tab === 'rules' ? 'rule_page' : T.tab === 'sources' ? 'source_status' : 'trace_summary', body = { action: action };
1442
1501
  if (action === 'rule_page') { body.limit = 25; body.cursor = more ? T.cursor : null; }
1443
1502
  paintModal();
1444
- call('workspace.request', body).then(function (d) {
1503
+ var timer = null;
1504
+ var late = new Promise(function (_ok, fail) { timer = setTimeout(function () { fail(new Error('Your Mac did not answer in ' + (STEWARD_READ_MS / 1000) + ' seconds. Reload to try again.')); }, STEWARD_READ_MS); });
1505
+ Promise.race([call('workspace.request', body), late]).then(function (d) {
1445
1506
  if (serial !== T.serial) return;
1507
+ d = d || {};
1446
1508
  if (action === 'trace_summary') T.outcomes = d; else { T.rows = more ? T.rows.concat(d.items || []) : d.items || []; T.cursor = d.next_cursor || null; }
1447
- }, function (e) { if (serial !== T.serial) return; T.error = e.message; }).then(function () { if (serial === T.serial) T.busy = false; paintModal(); });
1509
+ }, function (e) { if (serial !== T.serial) return; T.error = e.message; }).then(function () {
1510
+ clearTimeout(timer);
1511
+ if (serial !== T.serial) return;
1512
+ T.busy = false; T.loaded = true; paintModal();
1513
+ });
1448
1514
  }
1449
1515
  function stewardMutate(body) {
1450
1516
  var T = S.steward; if (T.busy) return;
@@ -1484,8 +1550,9 @@
1484
1550
  body += T.rows.length ? T.rows.map(function (r) { return '<section class="cx-card mm-card"><h4>' + esc(r.title || r.id) + '</h4><p class="mm-muted">Revision ' + esc(r.revision) + ' · ' + esc(when(r.captured_at)) + ' · ' + esc(r.projection) + '</p>' + sbtn('steward-refresh', 'Refresh this document', r.id) + '</section>'; }).join('') :
1485
1551
  (!T.busy ? '<p>No granted documents right now. The owner grants documents on the Mac.</p>' : '');
1486
1552
  } else {
1487
- var d = T.outcomes;
1488
- body += d ? '<dl class="mm-grid"><dt>Observed runs</dt><dd>' + fmt(d.unique_runs || 0) + '</dd><dt>Checked runs that count</dt><dd>' + fmt(d.checked_eligible_runs || 0) + '</dd><dt>Not conclusive</dt><dd>' + fmt(d.inconclusive_runs || 0) + '</dd><dt>Days observed</dt><dd>' + fmt(d.observation_days || 0) + '</dd><dt>Evidence bar</dt><dd>' + (d.evidence_gate_met ? 'Met' : 'Not met yet') + (d.rate_limit_reason ? '. ' + esc(d.rate_limit_reason) : '') + '</dd></dl>' +
1553
+ var d = T.outcomes, none = d && !(Number(d.unique_runs) > 0) && !(Number(d.checked_eligible_runs) > 0) && !(Number(d.inconclusive_runs) > 0);
1554
+ if (!T.busy && !T.error && T.loaded && (!d || none)) body += '<p>No outcome evidence yet.</p>';
1555
+ body += d && !none ? '<dl class="mm-grid"><dt>Observed runs</dt><dd>' + fmt(d.unique_runs || 0) + '</dd><dt>Checked runs that count</dt><dd>' + fmt(d.checked_eligible_runs || 0) + '</dd><dt>Not conclusive</dt><dd>' + fmt(d.inconclusive_runs || 0) + '</dd><dt>Days observed</dt><dd>' + fmt(d.observation_days || 0) + '</dd><dt>Evidence bar</dt><dd>' + (d.evidence_gate_met ? 'Met' : 'Not met yet') + (d.rate_limit_reason ? '. ' + esc(d.rate_limit_reason) : '') + '</dd></dl>' +
1489
1556
  '<details><summary>Show the raw numbers</summary><pre class="mm-pre">' + esc(JSON.stringify(d, null, 2)) + '</pre></details>' : '';
1490
1557
  body += '<p class="mm-muted">Being recalled, being included in context, a model saying it applied something and an independently checked result are four different things. Too little evidence does not mean things improved.</p>';
1491
1558
  }
@@ -1494,7 +1561,7 @@
1494
1561
  function stewardAct(act, id) {
1495
1562
  var T = S.steward, dr = S.drafts;
1496
1563
  if (act === 'steward-retry') return stewardMutate(T.intent);
1497
- if (act === 'steward-tab') { if (T.intent) return; T.tab = id; T.rows = []; T.cursor = null; T.outcomes = null; return stewardLoad(false); }
1564
+ if (act === 'steward-tab') { if (T.intent) return; T.tab = id; T.rows = []; T.cursor = null; T.outcomes = null; T.loaded = false; return stewardLoad(false); }
1498
1565
  if (act === 'steward-reload') return stewardLoad(false);
1499
1566
  if (act === 'steward-more') return stewardLoad(true);
1500
1567
  if (act === 'steward-propose') {
@@ -1806,6 +1873,7 @@
1806
1873
  // Restore is Accept on the wire: the bridge returns a quarantined memory to recall before marking it accepted.
1807
1874
  call('memory.review', { id: id, decision: decision === 'restore' ? 'accept' : decision }).then(function (d) {
1808
1875
  var row = d.decision || {};
1876
+ S.review.rows.concat(needsRows(), S.recent.rows).forEach(function (r) { if (r.lesson_id === id) keepForReader(r); });
1809
1877
  S.decisions[id] = row.decision || (decision === 'prune' ? 'pruned' : 'accepted'); S.decisionTs[id] = new Date().toISOString(); S.decided[id] = 1;
1810
1878
  if (decision === 'prune') { S.mem.rows = S.mem.rows.filter(function (m) { return m.id !== id; }); }
1811
1879
  W.toast(decision === 'prune' ? 'Memory quarantined.' : decision === 'restore' ? 'Memory restored to recall.' : 'Memory accepted.');
@@ -1834,6 +1902,7 @@
1834
1902
  function decide(eventId, decision) {
1835
1903
  var e = findRow(eventId); if (!e) return;
1836
1904
  call('learning.decide', { id: e.lesson_id, decision: decision }).then(function () {
1905
+ keepForReader(e);
1837
1906
  S.decisions[e.lesson_id] = decision; if (decision === 'dismissed') S.decided[e.lesson_id] = 1; else delete S.decided[e.lesson_id];
1838
1907
  W.toast(decision === 'dismissed' ? 'Suggestion dismissed.' : 'Suggestion restored.');
1839
1908
  loadReview(false); afterWrite();
@@ -1977,8 +2046,15 @@
1977
2046
  }
1978
2047
 
1979
2048
  // ── painting ─────────────────────────────────────────────
2049
+ // Duo tables: the divider between the list and the reader from 760 px (app.js splitKit; the same limits as Sessions),
2050
+ // with its own remembered width, and Full width in a reader's header.
2051
+ var SPLIT = { key: 'cos_web_memories_split', label: 'Memories list', controls: 'mm-list-col', media: '(min-width: 760px)', listMin: 240, readerMin: 380, handleW: 18 };
2052
+ // A page without the kit (a test's stand-in W) draws no divider and keeps working.
2053
+ var SPLIT_KIT = W.splitKit || { handle: function () { return ''; }, mount: function () { return null; }, fullButton: function () { return ''; } };
2054
+ var SPLIT_HANDLE = SPLIT_KIT.handle({ id: 'mm-handle', controls: SPLIT.controls, label: SPLIT.label });
1980
2055
  var SKELETON = '<div class="cx mm" data-tint="memories" id="mm-root"><div id="mm-hero"></div><div class="mm-controls" id="mm-views"></div><p class="mm-counts" id="mm-counts"></p>' +
1981
- '<div class="split mm-split" id="mm-split"><div class="split-list mm-list" id="mm-list-col"><div id="mm-tools"></div><div id="mm-list"></div></div><div class="reader mm-reader" id="mm-reader"></div></div>' +
2056
+ '<div class="split mm-split" id="mm-split"><div class="split-list mm-list" id="mm-list-col"><div id="mm-tools"></div><div id="mm-list"></div></div>' +
2057
+ SPLIT_HANDLE + '<div class="reader mm-reader" id="mm-reader"></div></div>' +
1982
2058
  '<p class="mm-foot" id="mm-foot"></p></div><div id="mm-modal-host"></div>';
1983
2059
  function write(node, html) { if (!node || node.__mm === html) return false; node.innerHTML = html; node.__mm = html; return true; }
1984
2060
  var paintTimer = null;
@@ -1987,6 +2063,7 @@
1987
2063
  var r = W.parseRoute(); if (r.pane !== 'memories') return;
1988
2064
  if (!el('mm-root')) pane.innerHTML = SKELETON;
1989
2065
  var reading = readerArgs(r).length > 0, split = el('mm-split');
2066
+ SPLIT_KIT.mount(split, el('mm-handle'), SPLIT);
1990
2067
  pane.classList.toggle('reading', reading); if (split) split.classList.toggle('reading', reading);
1991
2068
  // The cue views are one column at every width, like Control's cue review (a card, not a list beside a reader).
1992
2069
  if (split) split.classList.toggle('solo', !reading && (S.view === 'cues' || S.view === 'rated'));
@@ -2012,6 +2089,9 @@
2012
2089
  var html = readerHtml(r);
2013
2090
  if (readerArgs(r).length && W.crumb && W.crumb() !== S.crumbText) W.setCrumb(S.crumbText);
2014
2091
  if (write(node, html) && el('mm-graph-host')) mountGraph();
2092
+ // Full width hides the list only while a reader with the button is open, so the list is never hidden behind a
2093
+ // placeholder with no way back.
2094
+ var split = el('mm-split'); if (split) split.classList.toggle('mm-fullable', !!(node.querySelector && node.querySelector('[data-split-full]')));
2015
2095
  document.body.classList.toggle('mm-graph-open', S.graphFull && !!el('mm-graph-host'));
2016
2096
  }
2017
2097
  function crumb(t) { S.crumbText = String(t || ''); }
@@ -401,15 +401,34 @@
401
401
  }).join('') + '</div>';
402
402
  }
403
403
  // Drop it on: the list column and the chat column. Built once; each column then paints only itself.
404
+ // Duo tables: the divider between the two columns from 860 px (app.js splitKit), with its own remembered width. Its
405
+ // 24 px column replaces the split's 24 px gap, so the default layout is unchanged. The list keeps 240 px like Sessions;
406
+ // the chat keeps 440, not 380, because its composer (text box, camera, file, Ask, Drop) squeezes the text box to about
407
+ // 90 px at 380 (headless render at 1000x703).
408
+ // A page without the kit (a test's stand-in W) draws no divider and keeps working.
409
+ var SPLIT_KIT = W.splitKit || { handle: function () { return ''; }, mount: function () { return null; } };
410
+ var SPLIT = { key: 'cos_web_messages_split', label: 'Message list', controls: 'msg-left', sized: '.msg-left', media: '(min-width: 860px)', listMin: 240, readerMin: 440, handleW: 24 };
404
411
  function splitHost() {
405
412
  var split = pane.querySelector('.msg-split');
406
413
  if (!split) {
407
- pane.innerHTML = '<div class="msg-split"><div class="msg-left" id="msg-left"></div><div class="msg-right" id="msg-right"></div></div>';
414
+ pane.innerHTML = '<div class="msg-split"><div class="msg-left" id="msg-left"></div>' +
415
+ SPLIT_KIT.handle({ id: 'msg-handle', controls: SPLIT.controls, label: SPLIT.label }) + '<div class="msg-right" id="msg-right"></div></div>';
408
416
  split = pane.querySelector('.msg-split');
409
417
  }
418
+ SPLIT_KIT.mount(split, $('msg-handle'), SPLIT);
410
419
  split.setAttribute('data-show', current[0] === 'cos' ? 'chat' : 'list');
420
+ chatRoute(current[0] === 'cos');
411
421
  return split;
412
422
  }
423
+ // 7.0.2 (A2, the Duo audit): the workspace's bottom padding (app.css, +96 px folded and +40 px from 600 px) is room for
424
+ // a bottom bar that no longer exists. Under the chat's sticky composer it was blank page: focusing the box scrolled to
425
+ // it, and after the keyboard closed it stayed as a band under Model and Effort. On the COS chat route the workspace
426
+ // carries ws-chat, and app.css drops that padding. Any other route takes the class off.
427
+ function chatRoute(on) {
428
+ var ws = $('workspace');
429
+ if (ws && ws.classList) ws.classList.toggle('ws-chat', !!on);
430
+ }
431
+ if (W.onRoute) W.onRoute(function (r) { if (!r || r.pane !== 'messages' || r.args[0] !== 'cos') chatRoute(false); });
413
432
  function renderList() {
414
433
  var focus = document.activeElement && document.activeElement.id === 'msg-q';
415
434
  var caret = focus ? document.activeElement.selectionStart : null;
@@ -452,7 +471,7 @@
452
471
  var a = atts(t);
453
472
  return '<div class="cx-card you"><div class="cx-card-label">YOU</div><div class="cx-card-body" data-hl>' + esc(String(t.query || '').trim() || '(empty query)').replace(/\n/g, '<br>') + '</div></div>' +
454
473
  stripHtml(a.filter(isUserAtt), true, msgKey) +
455
- '<div class="cx-card"><div class="cx-card-label">COS</div><div class="cx-card-body md" data-hl>' + W.md(String(t.text || '')) + '</div></div>' +
474
+ '<div class="cx-card"><div class="cx-card-label">COS</div><div class="cx-card-body md" data-hl>' + W.md(W.structureLensAnswer(String(t.text || ''))) + '</div></div>' +
456
475
  stripHtml(a.filter(function (x) { return !isUserAtt(x); }), false, msgKey);
457
476
  }
458
477
  function copyTurnText(t) {