@gotcos/glasses-server 6.99.33 → 7.0.18

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 (133) hide show
  1. package/.env.example +37 -0
  2. package/CHANGELOG.md +587 -0
  3. package/README.md +3 -1
  4. package/docs/web-key-verifier-contract.md +378 -0
  5. package/managed-runtime-contract.json +12 -1
  6. package/package.json +10 -3
  7. package/server/bootstrap.ts +10 -0
  8. package/server/index.ts +155 -30
  9. package/server/lib/agent-session-output-images.ts +627 -49
  10. package/server/lib/agent-session-recent.ts +321 -0
  11. package/server/lib/agent-session-store.ts +27 -14
  12. package/server/lib/agent-session-tool-images.ts +224 -0
  13. package/server/lib/agent-session-turns.ts +19 -2
  14. package/server/lib/api-auth.ts +43 -1
  15. package/server/lib/ask-work-plan.ts +3 -1
  16. package/server/lib/calendar-meetings.ts +70 -0
  17. package/server/lib/calendar-source.ts +536 -0
  18. package/server/lib/claude-hooks-installer.ts +111 -12
  19. package/server/lib/codex-bridge.ts +4 -1
  20. package/server/lib/codex-run-ledger.ts +29 -0
  21. package/server/lib/context-builder.ts +2 -1
  22. package/server/lib/conversation.ts +56 -28
  23. package/server/lib/cors-reject-log.ts +51 -0
  24. package/server/lib/disk-io-meter.ts +202 -0
  25. package/server/lib/drop-p2-runtime.ts +1 -1
  26. package/server/lib/drop-service.ts +47 -12
  27. package/server/lib/drop-store.ts +31 -5
  28. package/server/lib/event-loop-stall.ts +62 -0
  29. package/server/lib/full-hold-engine.ts +114 -0
  30. package/server/lib/full-hold.ts +124 -0
  31. package/server/lib/g2-turn-provenance.ts +128 -9
  32. package/server/lib/learning-needs-you.ts +24 -7
  33. package/server/lib/listener-startup.ts +18 -0
  34. package/server/lib/live-events.ts +533 -0
  35. package/server/lib/local-path-redact.ts +272 -0
  36. package/server/lib/meeting-ask-orientation.ts +40 -6
  37. package/server/lib/meeting-ask-runner.ts +30 -10
  38. package/server/lib/meeting-batch-transcribe.ts +32 -5
  39. package/server/lib/morning-brief-runtime.ts +4 -2
  40. package/server/lib/native-mac-caller.ts +29 -0
  41. package/server/lib/permission-broker.ts +443 -11
  42. package/server/lib/ping-engine.ts +1 -1
  43. package/server/lib/ping-sources.ts +11 -6
  44. package/server/lib/preview-content.ts +172 -0
  45. package/server/lib/preview-error-code.ts +10 -0
  46. package/server/lib/preview-folders-setting.ts +133 -0
  47. package/server/lib/preview-log-throttle.ts +18 -0
  48. package/server/lib/preview-thumbs.ts +139 -0
  49. package/server/lib/secret-path-guard.ts +83 -0
  50. package/server/lib/session-cache-writer.ts +8 -1
  51. package/server/lib/session-files-setting.ts +88 -0
  52. package/server/lib/session-hook-events.ts +23 -1
  53. package/server/lib/session-hooks-runtime.ts +34 -0
  54. package/server/lib/session-links.ts +280 -0
  55. package/server/lib/session-log.ts +313 -4
  56. package/server/lib/session-state-derive.ts +36 -0
  57. package/server/lib/session-stream-events.ts +25 -1
  58. package/server/lib/thread-turn-queue.ts +10 -0
  59. package/server/lib/today-brief.ts +640 -85
  60. package/server/lib/turn-end-ask.ts +320 -0
  61. package/server/lib/turn-footer.ts +3 -3
  62. package/server/lib/web-control-integrity.ts +160 -0
  63. package/server/lib/web-control-offnet-page.ts +58 -0
  64. package/server/lib/web-control-static.ts +53 -9
  65. package/server/lib/web-key-authority.ts +372 -0
  66. package/server/lib/web-key-envelope.ts +98 -0
  67. package/server/lib/web-key-handoff.ts +135 -0
  68. package/server/lib/web-key-origin.ts +21 -0
  69. package/server/lib/web-key-passkey.ts +155 -0
  70. package/server/lib/web-key-registry.ts +825 -0
  71. package/server/lib/web-key-signer-app.ts +152 -0
  72. package/server/lib/web-memories-bridge.ts +3 -1
  73. package/server/lib/web-session-auth.ts +133 -15
  74. package/server/lib/whisper-local.ts +93 -7
  75. package/server/lib/whisper-preview.ts +5 -3
  76. package/server/lib/work-activity.ts +56 -6
  77. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  78. package/server/lib/work-review-runtime.ts +17 -4
  79. package/server/lib/work-session-activity.ts +260 -0
  80. package/server/lib/your-move-count.ts +9 -3
  81. package/server/routes/agent-session-bindings.ts +29 -1
  82. package/server/routes/agent-session-stream.ts +23 -0
  83. package/server/routes/agent-sessions.ts +146 -11
  84. package/server/routes/ask-work-plan.ts +4 -1
  85. package/server/routes/claude-sessions.ts +5 -1
  86. package/server/routes/drops.ts +7 -2
  87. package/server/routes/g2-turns.ts +35 -0
  88. package/server/routes/health.ts +46 -1
  89. package/server/routes/live-events.ts +334 -0
  90. package/server/routes/permission-broker.ts +95 -5
  91. package/server/routes/query-jobs.ts +3 -0
  92. package/server/routes/session-preview.ts +200 -0
  93. package/server/routes/sessions.ts +15 -24
  94. package/server/routes/thread-turn-queue.ts +223 -12
  95. package/server/routes/today-brief.ts +134 -44
  96. package/server/routes/web-control.ts +37 -4
  97. package/server/routes/web-key.ts +284 -0
  98. package/server/routes/welcome-context.ts +28 -4
  99. package/server/routes/work-board.ts +9 -2
  100. package/server/routes/work-orchestrator.ts +12 -7
  101. package/server/scripts/write-web-control-manifest.mjs +43 -0
  102. package/server/web-control/.manifest.json +44 -0
  103. package/server/web-control/app.css +76 -1
  104. package/server/web-control/app.js +2247 -98
  105. package/server/web-control/apple-touch-icon.png +0 -0
  106. package/server/web-control/control.css +25 -0
  107. package/server/web-control/index.html +21 -3
  108. package/server/web-control/memory-review.js +100 -0
  109. package/server/web-control/panes/cos-chat.css +94 -9
  110. package/server/web-control/panes/cos-chat.js +628 -44
  111. package/server/web-control/panes/meetings.css +18 -2
  112. package/server/web-control/panes/meetings.js +24 -8
  113. package/server/web-control/panes/memories.css +17 -2
  114. package/server/web-control/panes/memories.js +127 -42
  115. package/server/web-control/panes/messages.js +21 -2
  116. package/server/web-control/panes/preview.js +318 -0
  117. package/server/web-control/panes/questions.js +162 -21
  118. package/server/web-control/panes/reader-media.js +409 -23
  119. package/server/web-control/panes/sessions.css +32 -3
  120. package/server/web-control/panes/settings.js +97 -6
  121. package/server/web-control/panes/today-actions.css +46 -0
  122. package/server/web-control/panes/today-actions.js +119 -17
  123. package/server/web-control/panes/work.css +13 -0
  124. package/server/web-control/panes/work.js +188 -37
  125. package/server/web-control/shell.js +17 -14
  126. package/server/web-signer/.manifest.json +11 -0
  127. package/server/web-signer/index.html +74 -0
  128. package/server/web-signer/signer-proof.js +348 -0
  129. package/server/web-signer/signer-store.js +39 -0
  130. package/server/web-signer/signer.css +23 -0
  131. package/server/web-signer/signer.js +341 -0
  132. package/shared/web-approval-packets.golden.json +124 -0
  133. package/shared/web-turn-envelope.ts +271 -0
@@ -161,9 +161,12 @@
161
161
  .md-check { display: inline-block; width: 12px; height: 12px; border: 1.5px solid var(--sub); border-radius: 3px; margin-right: 7px; vertical-align: -1px; }
162
162
  .md-check.on { background: var(--green); border-color: var(--green); }
163
163
  .md-table-wrap { overflow-x: auto; margin: 0 0 1em; -webkit-overflow-scrolling: touch; }
164
- .md-table { border-collapse: collapse; font-size: 14px; border: 1px solid var(--panel-border); border-radius: 6px; min-width: 60%; }
164
+ /* Duo tables: separated borders so the held first column (control.css table.tf) keeps its line; the same look. */
165
+ .mt-md { --tf-bg: var(--page); }
166
+ .mt-answer .mt-md { --tf-bg: var(--qopen); }
167
+ .md-table { border-collapse: separate; border-spacing: 0; font-size: 14px; border: 1px solid var(--panel-border); border-radius: 6px; min-width: 60%; }
165
168
  .md-table th, .md-table td { padding: 6px 12px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--hairline); }
166
- .md-table th { font-weight: 600; background: var(--qopen); }
169
+ .md-table th, .md-table.tf thead tr > :first-child { font-weight: 600; background: var(--qopen); }
167
170
  .md-table tr:last-child td { border-bottom: 0; }
168
171
  .md-table .md-key { color: var(--sub); white-space: nowrap; }
169
172
  .md-code { overflow-x: auto; font: 400 12.5px/1.45 var(--mono); background: var(--qopen); border: 1px solid var(--panel-border); padding: 10px; border-radius: 8px; margin: 0 0 1em; }
@@ -358,3 +361,16 @@
358
361
  .mt-lgrid { grid-template-columns: minmax(0, 1fr) minmax(280px, min(380px, 38%)); align-items: start; column-gap: 24px; }
359
362
  .mt-lgrid > .mt-side-panel { position: sticky; top: calc(62px + var(--safe-top, 0px)); max-height: calc(100vh - 80px - var(--safe-top, 0px)); max-height: calc(100dvh - 80px - var(--safe-top, 0px)); overflow: auto; }
360
363
  }
364
+
365
+ /* Duo tables: the divider between the notes or live transcript and the Ask panel (app.js splitKit, styled in app.css;
366
+ meetings.js SPLIT). From 860 px only, where both show. Its 24 px column is the old gap, so the default layout is
367
+ unchanged; a dragged width sizes the Ask panel, clamped so it keeps 280 px and the notes 380 px. */
368
+ @media (min-width: 860px) {
369
+ .mt-detail .mt-dbody.split-side { grid-template-columns: minmax(0, 1fr) 24px minmax(280px, min(380px, 38%)); column-gap: 0; }
370
+ .mt-detail .mt-dbody.split-side.split-sized { grid-template-columns: minmax(0, 1fr) 24px clamp(280px, var(--split-side-share, 30%), calc(100% - 404px)); }
371
+ .mt-detail .mt-dbody.split-side > .split-handle { display: flex; grid-column: 2; grid-row: 1 / span 20; top: calc(62px + var(--safe-top, 0px)); height: calc(100dvh - 80px - var(--safe-top, 0px)); }
372
+ .mt-detail .mt-dbody.split-side > :not(.mt-doc):not(.split-handle) { grid-column: 3; }
373
+ .mt-lgrid.split-side { grid-template-columns: minmax(0, 1fr) 24px minmax(280px, min(380px, 38%)); column-gap: 0; }
374
+ .mt-lgrid.split-side.split-sized { grid-template-columns: minmax(0, 1fr) 24px clamp(280px, var(--split-side-share, 30%), calc(100% - 404px)); }
375
+ .mt-lgrid.split-side > .split-handle { display: flex; top: calc(62px + var(--safe-top, 0px)); height: calc(100dvh - 80px - var(--safe-top, 0px)); }
376
+ }
@@ -391,13 +391,18 @@
391
391
  case 'table': {
392
392
  var kv = b.header.length === 2 && b.rows.length && b.rows.every(function (r) { return r.length === 2 && r[0].indexOf('**') === 0; });
393
393
  var cols = Math.max(b.header.length, b.rows.reduce(function (m, r) { return Math.max(m, r.length); }, 0));
394
- function row(r, tag, key) {
394
+ // Duo tables: the shared column floors (app.js tableFit), measured on the rows that show.
395
+ var fit = W.tableFit(kv ? b.rows : [b.header].concat(b.rows));
396
+ function row(r, tag, key, first) {
395
397
  var h = '<tr>';
396
- for (var c = 0; c < cols; c++) h += '<' + tag + (key && c === 0 ? ' class="md-key"' : '') + '>' + inline(r[c] || '') + '</' + tag + '>';
398
+ for (var c = 0; c < cols; c++) {
399
+ var w = fit.width(c, first);
400
+ h += '<' + tag + (key && c === 0 ? ' class="md-key"' : '') + (w ? ' style="' + w + '"' : '') + '>' + fit.cell(c, inline(r[c] || ''), first) + '</' + tag + '>';
401
+ }
397
402
  return h + '</tr>';
398
403
  }
399
- return '<div class="md-table-wrap"><table class="md-table' + (kv ? ' kv' : '') + '">' + (kv ? '' : '<thead>' + row(b.header, 'th') + '</thead>') +
400
- '<tbody>' + b.rows.map(function (r) { return row(r, 'td', kv); }).join('') + '</tbody></table></div>';
404
+ return '<div class="md-table-wrap tf-wrap"><table class="md-table ' + fit.tableClass + (kv ? ' kv' : '') + '">' + (kv ? '' : '<thead>' + row(b.header, 'th', false, true) + '</thead>') +
405
+ '<tbody>' + b.rows.map(function (r, n) { return row(r, 'td', kv, kv && n === 0); }).join('') + '</tbody></table></div>';
401
406
  }
402
407
  case 'details': {
403
408
  var sp = b.blocks.filter(function (x) { return x.t === 'speakers'; })[0];
@@ -1245,7 +1250,9 @@
1245
1250
  else if (!d) body = '<p class="cx-note mt-wait">Loading meeting…</p>' + W.skeletonRows(5);
1246
1251
  else {
1247
1252
  var sessions = detailSessions();
1248
- body = detailBarHtml(row, d) + '<div class="mt-dbody">' +
1253
+ // Duo tables: the line between the notes and the Ask panel drags where both show (860 px and up).
1254
+ var divider = sessions.length ? SPLIT_KIT.handle({ id: 'mt-dhandle', controls: 'mt-ask-' + safeId(sessions[0]), label: SPLIT.label }) : '';
1255
+ body = detailBarHtml(row, d) + '<div class="mt-dbody' + (divider ? ' split-side' : '') + '" id="mt-dbody">' + divider +
1249
1256
  (row.sourceSessionId && sessions.length ? '<p class="mt-note-line">Ask history covers the whole original capture, including its other split pieces.</p>' : '') +
1250
1257
  sessions.map(function (sid, i) {
1251
1258
  A(sid).ctx = { saved: true, meeting: row, captureLabel: 'Original recording ' + (i + 1) + '. Questions use its saved cutoff; each recording keeps its own history.' };
@@ -1255,7 +1262,14 @@
1255
1262
  '<article class="mt-doc">' + documentHtml(d) + '</article></div>';
1256
1263
  }
1257
1264
  pane.innerHTML = '<div class="cx mt mt-detail" data-tint="meetings">' + head + body + '</div>';
1258
- }
1265
+ SPLIT_KIT.mount($('mt-dbody'), $('mt-dhandle'), SPLIT);
1266
+ }
1267
+ // Duo tables: the divider between a meeting's notes or live transcript and its Ask panel (app.js splitKit, 860 px and
1268
+ // up), one width for both views. The Ask panel is the column on the right, so it grows as the line moves left; it keeps
1269
+ // 280 px and the notes 380 px, past a 24 px divider that is the old gap.
1270
+ // A page without the kit (a test's stand-in W) draws no divider and keeps working.
1271
+ var SPLIT_KIT = W.splitKit || { handle: function () { return ''; }, mount: function () { return null; } };
1272
+ var SPLIT = { key: 'cos_web_meetings_split', label: 'Ask panel', side: 'end', sized: '.mt-side-panel', cssVar: '--split-side-share', media: '(min-width: 860px)', listMin: 280, readerMin: 380, handleW: 24 };
1259
1273
  // ── Prep in a saved meeting (6.99.19) ─────────────────
1260
1274
  // GET /api/prep/by-session/:sessionId for each of the meeting's recordings: what the prep focused on and its files,
1261
1275
  // each opening from the Prep store. Nothing shows when there is no prep, or on a server without Prep.
@@ -2001,12 +2015,14 @@
2001
2015
  // 6.99.14: the transcript scrolls inside its own box, as Control's does, so a new line never moves the page: the
2002
2016
  // title, the recording bar, the files and the Ask panel stay where the reader left them. Everything that changes
2003
2017
  // height while it records (the stop line, the files, the confirm card) sits under the box, never above it.
2004
- pane.innerHTML = '<div class="cx mt mt-livev" data-tint="meetings"><div id="mt-lhead"></div><div class="mt-lgrid"><div class="mt-lmain" id="mt-lmain">' +
2018
+ var divider = SPLIT_KIT.handle({ id: 'mt-lhandle', controls: 'mt-ask-' + safeId(sid), label: SPLIT.label });
2019
+ pane.innerHTML = '<div class="cx mt mt-livev" data-tint="meetings"><div id="mt-lhead"></div><div class="mt-lgrid' + (divider ? ' split-side' : '') + '" id="mt-lgrid"><div class="mt-lmain" id="mt-lmain">' +
2005
2020
  '<div class="mt-tbox"><div id="mt-turns" class="mt-turns" tabindex="0" role="log" aria-live="polite" aria-label="Live transcript"></div>' +
2006
2021
  '<button type="button" class="cx-btn quiet mt-jump" id="mt-jump" data-mt="jump" hidden>Jump to live</button></div>' +
2007
2022
  '<div id="mt-lbar" class="mt-lbarslot"></div><div id="mt-lfiles"></div><div id="mt-lprep"></div><div id="mt-lfoot"></div>' +
2008
- '<input type="file" id="mt-lpick" class="mt-pick" multiple accept="' + esc(LIVE_ACCEPT) + '" tabindex="-1" aria-hidden="true"></div>' +
2023
+ '<input type="file" id="mt-lpick" class="mt-pick" multiple accept="' + esc(LIVE_ACCEPT) + '" tabindex="-1" aria-hidden="true"></div>' + divider +
2009
2024
  '<aside class="mt-side-panel" id="mt-ask-' + safeId(sid) + '" data-sid="' + esc(sid) + '">' + sidePanelHtml(sid) + '</aside></div></div>';
2025
+ SPLIT_KIT.mount($('mt-lgrid'), $('mt-lhandle'), SPLIT);
2010
2026
  S.headHtml = S.footHtml = S.barHtml = S.filesHtml = S.prepHtml = null;
2011
2027
  paintLiveHead(); paintTurns(true); paintLiveFoot();
2012
2028
  clearInterval(S.liveTimer); clearInterval(S.clockTimer);
@@ -28,6 +28,18 @@
28
28
  .mm-warn { color: var(--amber); font-size: 13px; }
29
29
  .mm-split { margin-top: 2px; }
30
30
  .mm-split, .mm-list, .mm-reader { min-width: 0; }
31
+ /* Duo tables: the divider between the list and the reader (app.js splitKit, styled in app.css), from 760 px only. Its
32
+ 18 px column replaces the split's 18 px gap, so the default layout is unchanged; a dragged width is a share of the
33
+ split, clamped so the list keeps 240 px and the reader 380 px (memories.js SPLIT), like Sessions. */
34
+ @media (min-width: 760px) {
35
+ .split.mm-split { grid-template-columns: minmax(260px, 340px) 18px minmax(0, 1fr); column-gap: 0; }
36
+ .split.mm-split.split-sized { grid-template-columns: clamp(240px, var(--split-list-share, 30%), calc(100% - 398px)) 18px minmax(0, 1fr); }
37
+ .split.mm-split > .split-handle { display: flex; }
38
+ .mm-reader .split-full-btn { display: inline-flex; }
39
+ /* Full width: the reader takes the split; the list and the divider come back with Show list. */
40
+ .split.mm-split.split-full.mm-fullable:not(.solo) { grid-template-columns: minmax(0, 1fr); }
41
+ .split.mm-split.split-full.mm-fullable:not(.solo) > .split-list, .split.mm-split.split-full.mm-fullable:not(.solo) > .split-handle { display: none; }
42
+ }
31
43
 
32
44
  /* A reader on a narrow screen is a pushed page: the header, views and counts step aside. */
33
45
  @media (max-width: 759px) {
@@ -134,8 +146,10 @@
134
146
  .mm-answer ul { margin: 0 0 8px; padding-left: 1.2em; }
135
147
  .mm-answer code { font: 400 13px/1.4 var(--mono); background: var(--panel); padding: 0 3px; border-radius: 4px; }
136
148
  .mm-table { overflow-x: auto; max-width: 100%; }
137
- .mm-answer table { border-collapse: collapse; font-size: 13px; }
138
- .mm-answer th, .mm-answer td { border: 1px solid var(--panel-border); padding: 4px 8px; text-align: left; vertical-align: top; }
149
+ /* Duo tables: grid lines and the held first column are control.css (table.tf-grid), in this pane's border colour. */
150
+ .mm-answer { --tf-bg: var(--qopen); --tf-line: var(--panel-border); }
151
+ .mm-answer table { font-size: 13px; }
152
+ .mm-answer th, .mm-answer td { padding: 4px 8px; text-align: left; vertical-align: top; }
139
153
  .mm-refs ol { margin: 0; padding-left: 1.4em; font-size: 13px; line-height: 1.45; }
140
154
  .mm-refs li { margin-bottom: 4px; overflow-wrap: anywhere; }
141
155
  .mm-ref { color: var(--accent); font-variant-numeric: tabular-nums; }
@@ -259,6 +273,7 @@ body.mm-modal-open { overflow: hidden; }
259
273
  .split.mm-split.solo { display: block; }
260
274
  .split.mm-split.solo .split-list { position: static; max-height: none; overflow: visible; max-width: 820px; }
261
275
  .split.mm-split.solo .reader { display: none; }
276
+ .split.mm-split.solo > .split-handle { display: none; }
262
277
  .mm-cue-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin: 6px 2px 12px; }
263
278
  .mm-cue-head p { margin: 3px 0 0; }
264
279
  .mm-cue-title { margin: 0; font: 500 24px/1.15 var(--display); color: var(--ink); letter-spacing: -0.01em; }
@@ -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']];
@@ -132,7 +143,7 @@
132
143
  graph: null, graphError: '', setup: null, setupError: '', setupLoading: false, setupBusy: '', progress: null,
133
144
  ingest: null, ingestLimit: 10, building: '', folderDraft: '',
134
145
  detail: {}, memDetail: {}, passages: {}, loading: {},
135
- decisions: {}, decisionTs: {}, decided: {},
146
+ decisions: {}, decisionTs: {}, decided: {}, history: {},
136
147
  armed: '', reviewBusy: '', reviewIntent: null, bulk: null,
137
148
  ask: { q: '', busy: false, answer: null, error: '', cards: [], cardsBusy: false, startedAt: 0, message: '', pending: null },
138
149
  focus: null, focusChosen: false, focusResolved: false, knowledgeFocus: null, neighbors: [], graphVisible: null,
@@ -142,7 +153,7 @@
142
153
  owner: null, ownerError: '', ownerBusy: false,
143
154
  policy: null, policyError: '', policyBusy: false, policyIntent: null, policyLoading: false,
144
155
  guard: null, guardError: '', guardBusy: '', guardRun: null, guardLines: 0, guardLoading: false,
145
- 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 }
146
157
  };
147
158
  function persist() { W.writeStore(STORE_KEY, { view: S.view, ktab: S.ktab }); }
148
159
  var serials = {};
@@ -385,7 +396,16 @@
385
396
  var o = outcomeText(e);
386
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' : '' }; }
387
398
  if (e.needs) return { text: (GATE[e.needs.gate] || 'Needs you') + (e.needs.reason ? '. ' + e.needs.reason : ''), tone: 'warn' };
388
- 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
+ }
389
409
  if (e.event_type === 'dismissed') return { text: 'Dismissed', tone: '' };
390
410
  if (e.event_type === 'retrieved') return { text: 'Recalled. Not yet shown to be used.', tone: '' };
391
411
  if (e.event_type === 'included') return { text: 'Included in context. Not yet shown to be used.', tone: '' };
@@ -410,38 +430,65 @@
410
430
  function plainReason(r) {
411
431
  var t = String(r || ''), m;
412
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';
413
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';
414
436
  return t;
415
437
  }
416
- function needsItems() {
438
+ function needsBlock() {
417
439
  var ny = (S.recent.info && S.recent.info.needs_you) || (S.status && S.status.needs_you) || null;
418
- 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
+ });
419
461
  }
462
+ function learningFor(id) { return id ? learningRows().filter(function (r) { return r.lesson_id === id; })[0] || null : null; }
420
463
  function needsRows() {
421
- // 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).
422
465
  return needsItems().map(function (it) {
423
- var fromList = S.review.rows.filter(function (r) { return r.event_id === it.event_id; })[0] ||
424
- S.recent.rows.filter(function (r) { return r.event_id === it.event_id; })[0];
425
- var mem = /^mem_/.test(str(it.lesson_id));
426
- var base = fromList || { event_id: it.event_id, lesson_id: it.lesson_id, ts: it.ts, title: it.title, category: it.category,
427
- store: it.store || (mem ? 'bot_memory' : null), event_type: mem ? 'captured' : 'promotable', scope: 'unknown', engine: 'unknown',
428
- target: { kind: mem ? 'memory' : 'pattern', id: it.lesson_id }, source_refs: [], applies_to: [] };
429
- return Object.assign({}, base, { needs: { gate: it.gate, reason: plainReason(it.reason) } });
466
+ return itemRow(it, { needs: { gate: it.gate, reason: plainReason(it.reason) } });
430
467
  });
431
468
  }
469
+ // The same count the server hands Today and the rail (memory-review.js count), less what was decided on this page.
432
470
  function needsCount() {
433
471
  var s = S.status || {}, ny = (S.recent.info && S.recent.info.needs_you) || s.needs_you;
434
- if (ny && Array.isArray(ny.items)) return ny.items.filter(function (it) { return !S.decided[it.lesson_id]; }).length;
435
- 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;
436
474
  if (typeof s.learningToReview === 'number') return s.learningToReview;
437
475
  return null;
438
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
+ }
439
483
  function findRow(id) {
440
- 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);
441
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];
442
487
  var d = S.detail[id];
443
488
  return d && !d._error && d.event_id ? d : null;
444
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; }
445
492
  function fullRow(e) { var d = S.detail[e.event_id]; return Object.assign({}, e, d && !d._error ? d : {}); }
446
493
  function rewardOn() { return !!(S.status && S.status.learningRewardEnabled === true); }
447
494
  function rankingSentence() { return rewardOn() ? '<p>This lesson now ranks ahead of unused memories like it in later recall.</p>' : ''; }
@@ -801,17 +848,20 @@
801
848
  (L.cursor ? moreBtn('more-recent', 'Load more', L.loading) : '') + coverageNote(L.coverage);
802
849
  }
803
850
  function reviewListHtml() {
804
- var L = S.review, sel = selectedId(), needs = needsRows(), seen = {};
851
+ var L = S.review, sel = selectedId(), needs = needsRows(), learning = learningRows(), seen = {};
805
852
  needs.forEach(function (e) { seen[e.event_id] = 1; });
806
- 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);
807
855
  var other = rest.filter(function (e) { return !isPruneReady(e); }), ready = rest.filter(isPruneReady);
808
856
  var out = '';
809
- var openNeeds = needsCount();
810
- 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>';
811
- if (!L.loaded && L.loading) return out + groupHead('Proposed changes') + loadingBox('Reading the changes waiting for you');
812
- if (L.error && !L.rows.length) return out + groupHead('Proposed changes') + stateBox('The review list did not load.', L.error, 'refresh');
813
- if (!needs.length && !rest.length) return groupHead('To review') + stateBox('You are up to date.', 'No proposed changes are waiting for review.');
814
- 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>';
815
865
  if (ready.length) {
816
866
  var open = ready.filter(function (e) { return !decisionFor(e); }).length, b = S.bulk;
817
867
  out += groupHead('Ready to quarantine', open ? fmt(open) + ' open' : 'All decided') +
@@ -821,7 +871,7 @@
821
871
  '<div class="cx-list">' + ready.map(function (e) { return rowHtml(e, e.event_id === sel); }).join('') + '</div>';
822
872
  }
823
873
  if (L.cursor) out += moreBtn('more-review', 'Load more to review', L.loading);
824
- return out;
874
+ return out + learnHtml;
825
875
  }
826
876
  function memSearchHtml() {
827
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">' +
@@ -864,7 +914,7 @@
864
914
  if (!isWide() || S.view === 'cues' || S.view === 'rated') return '';
865
915
  if (S.view === 'knowledge') return S.ktab;
866
916
  if (S.view === 'memories') { var m = S.mem.hits != null ? S.mem.hits : S.mem.rows; return m[0] ? m[0].id : ''; }
867
- 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;
868
918
  return rows[0] ? rows[0].event_id : '';
869
919
  }
870
920
 
@@ -874,8 +924,9 @@
874
924
  }
875
925
  function quote(text) { return '<blockquote class="mm-quote">' + esc(text) + '</blockquote>'; }
876
926
  function readerHead(title, facts, actions) {
927
+ var full = SPLIT_KIT.fullButton(SPLIT.key, SPLIT.controls);
877
928
  return '<div class="mm-reader-head"><h2 class="mm-title">' + esc(title) + '</h2>' + (facts ? '<p class="mm-sub">' + facts + '</p>' : '') +
878
- (actions ? '<div class="mm-actions">' + actions + '</div>' : '') + '</div>';
929
+ (actions || full ? '<div class="mm-actions">' + (actions || '') + full + '</div>' : '') + '</div>';
879
930
  }
880
931
  function eventReader(id) {
881
932
  var base = findRow(id);
@@ -894,7 +945,8 @@
894
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(' · ');
895
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' });
896
947
  var note = '';
897
- 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>';
898
950
  if (full && full._error && !(e.store === 'bot_memory')) note += '<div class="cx-notice">' + esc(full._error) + '</div>';
899
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>';
900
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>';
@@ -1069,7 +1121,15 @@
1069
1121
  function renderMarkdown(md) {
1070
1122
  var lines = esc(md || '').split('\n'), out = [], ul = null, table = null;
1071
1123
  function inline(s) { return s.replace(/`([^`]+)`/g, '<code>$1</code>').replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>'); }
1072
- 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
+ }
1073
1133
  lines.forEach(function (raw) {
1074
1134
  var line = raw.replace(/\s+$/, '');
1075
1135
  if (!line.trim()) { flush(); return; }
@@ -1080,9 +1140,8 @@
1080
1140
  if (/^\s*\|.*\|\s*$/.test(line)) {
1081
1141
  if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) return;
1082
1142
  if (ul) flush();
1083
- var cells = line.trim().replace(/^\||\|$/g, '').split('|').map(function (c) { return inline(c.trim()); });
1084
- var tag = table ? 'td' : 'th'; table = table || [];
1085
- 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;
1086
1145
  }
1087
1146
  flush(); out.push('<p>' + inline(line) + '</p>');
1088
1147
  });
@@ -1324,7 +1383,7 @@
1324
1383
  function closeModal() {
1325
1384
  var m = S.modal; if (!m) return;
1326
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.'); }
1327
- 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)
1328
1387
  S.modal = null; paintModal(); if (m.opener && m.opener.focus) { try { m.opener.focus({ preventScroll: true }); } catch (_e) {} }
1329
1388
  }
1330
1389
  function modalHtml() {
@@ -1430,16 +1489,28 @@
1430
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>';
1431
1490
  }
1432
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;
1433
1497
  function stewardLoad(more) {
1434
- var T = S.steward; if (T.busy) return;
1435
- 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;
1436
1500
  var action = T.tab === 'rules' ? 'rule_page' : T.tab === 'sources' ? 'source_status' : 'trace_summary', body = { action: action };
1437
1501
  if (action === 'rule_page') { body.limit = 25; body.cursor = more ? T.cursor : null; }
1438
1502
  paintModal();
1439
- 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) {
1440
1506
  if (serial !== T.serial) return;
1507
+ d = d || {};
1441
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; }
1442
- }, 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
+ });
1443
1514
  }
1444
1515
  function stewardMutate(body) {
1445
1516
  var T = S.steward; if (T.busy) return;
@@ -1479,8 +1550,9 @@
1479
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('') :
1480
1551
  (!T.busy ? '<p>No granted documents right now. The owner grants documents on the Mac.</p>' : '');
1481
1552
  } else {
1482
- var d = T.outcomes;
1483
- 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>' +
1484
1556
  '<details><summary>Show the raw numbers</summary><pre class="mm-pre">' + esc(JSON.stringify(d, null, 2)) + '</pre></details>' : '';
1485
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>';
1486
1558
  }
@@ -1489,7 +1561,7 @@
1489
1561
  function stewardAct(act, id) {
1490
1562
  var T = S.steward, dr = S.drafts;
1491
1563
  if (act === 'steward-retry') return stewardMutate(T.intent);
1492
- 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); }
1493
1565
  if (act === 'steward-reload') return stewardLoad(false);
1494
1566
  if (act === 'steward-more') return stewardLoad(true);
1495
1567
  if (act === 'steward-propose') {
@@ -1801,6 +1873,7 @@
1801
1873
  // Restore is Accept on the wire: the bridge returns a quarantined memory to recall before marking it accepted.
1802
1874
  call('memory.review', { id: id, decision: decision === 'restore' ? 'accept' : decision }).then(function (d) {
1803
1875
  var row = d.decision || {};
1876
+ S.review.rows.concat(needsRows(), S.recent.rows).forEach(function (r) { if (r.lesson_id === id) keepForReader(r); });
1804
1877
  S.decisions[id] = row.decision || (decision === 'prune' ? 'pruned' : 'accepted'); S.decisionTs[id] = new Date().toISOString(); S.decided[id] = 1;
1805
1878
  if (decision === 'prune') { S.mem.rows = S.mem.rows.filter(function (m) { return m.id !== id; }); }
1806
1879
  W.toast(decision === 'prune' ? 'Memory quarantined.' : decision === 'restore' ? 'Memory restored to recall.' : 'Memory accepted.');
@@ -1829,6 +1902,7 @@
1829
1902
  function decide(eventId, decision) {
1830
1903
  var e = findRow(eventId); if (!e) return;
1831
1904
  call('learning.decide', { id: e.lesson_id, decision: decision }).then(function () {
1905
+ keepForReader(e);
1832
1906
  S.decisions[e.lesson_id] = decision; if (decision === 'dismissed') S.decided[e.lesson_id] = 1; else delete S.decided[e.lesson_id];
1833
1907
  W.toast(decision === 'dismissed' ? 'Suggestion dismissed.' : 'Suggestion restored.');
1834
1908
  loadReview(false); afterWrite();
@@ -1972,8 +2046,15 @@
1972
2046
  }
1973
2047
 
1974
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 });
1975
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>' +
1976
- '<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>' +
1977
2058
  '<p class="mm-foot" id="mm-foot"></p></div><div id="mm-modal-host"></div>';
1978
2059
  function write(node, html) { if (!node || node.__mm === html) return false; node.innerHTML = html; node.__mm = html; return true; }
1979
2060
  var paintTimer = null;
@@ -1982,6 +2063,7 @@
1982
2063
  var r = W.parseRoute(); if (r.pane !== 'memories') return;
1983
2064
  if (!el('mm-root')) pane.innerHTML = SKELETON;
1984
2065
  var reading = readerArgs(r).length > 0, split = el('mm-split');
2066
+ SPLIT_KIT.mount(split, el('mm-handle'), SPLIT);
1985
2067
  pane.classList.toggle('reading', reading); if (split) split.classList.toggle('reading', reading);
1986
2068
  // The cue views are one column at every width, like Control's cue review (a card, not a list beside a reader).
1987
2069
  if (split) split.classList.toggle('solo', !reading && (S.view === 'cues' || S.view === 'rated'));
@@ -2007,6 +2089,9 @@
2007
2089
  var html = readerHtml(r);
2008
2090
  if (readerArgs(r).length && W.crumb && W.crumb() !== S.crumbText) W.setCrumb(S.crumbText);
2009
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]')));
2010
2095
  document.body.classList.toggle('mm-graph-open', S.graphFull && !!el('mm-graph-host'));
2011
2096
  }
2012
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) {