browser-assistant 2.8.7 → 2.8.21

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.
@@ -94,10 +94,36 @@
94
94
  id: 'semantic-mcq',
95
95
  getQuestion(doc) {
96
96
  const visible = (element) => element.getClientRects().length > 0;
97
+ const preserveMatrices = (clone) => {
98
+ for (const math of clone.querySelectorAll('math')) {
99
+ const tables = math.querySelectorAll('mtable');
100
+ if (tables.length !== 1)
101
+ continue;
102
+ const matrix = tables[0];
103
+ const surrounding = math.cloneNode(true);
104
+ surrounding.querySelectorAll('mtable,annotation,annotation-xml').forEach(node => node.remove());
105
+ if (!/^[\s()[\]{}|‖]*$/.test(surrounding.textContent || ''))
106
+ continue;
107
+ const rows = [...matrix.children];
108
+ if (!rows.length || rows.some(row => !row.matches('mtr')))
109
+ continue;
110
+ const cells = rows.map(row => [...row.children].map(cell => {
111
+ if (!cell.matches('mtd'))
112
+ return '';
113
+ const value = (cell.textContent || '').trim().replace(/−/g, '-').replace(/^([+-])\s+/, '$1');
114
+ return /^[+-]?\d+(?:\.\d+)?$/.test(value) ? value : '';
115
+ }));
116
+ if (!cells[0].length || cells.some(row => row.length !== cells[0].length || row.some(value => !value)))
117
+ continue;
118
+ const renderer = math.closest('.katex,mjx-container') || math;
119
+ renderer.replaceWith(doc.createTextNode(` ${JSON.stringify(cells).replace(/"/g, '')} `));
120
+ }
121
+ };
97
122
  const textOf = (element) => {
98
123
  if (!element)
99
124
  return '';
100
125
  const clone = element.cloneNode(true);
126
+ preserveMatrices(clone);
101
127
  const editors = element.querySelectorAll('.ace_editor,.CodeMirror,.monaco-editor');
102
128
  [...clone.querySelectorAll('.ace_editor,.CodeMirror,.monaco-editor')].forEach((block, index) => {
103
129
  const source = typeof browserAssistantCachedMcqSnippet === 'function'
@@ -222,8 +248,8 @@
222
248
  // Coding MCQs often render the code separately from the prose
223
249
  // question. Preserve that visible context before asking the AI.
224
250
  const questionRegion = optionScope.closest('[data-question],article,section,main') || optionScope.parentElement;
225
- const promptRegion = questionRegion?.querySelector('#generic-question-wrapper-id,[data-question-text]')
226
- || doc.querySelector('#generic-question-wrapper-id');
251
+ const promptRegion = questionRegion?.querySelector('#generic-question-wrapper-id,#exam-question-wrapper-id,[data-question-text]')
252
+ || doc.querySelector('#generic-question-wrapper-id,#exam-question-wrapper-id');
227
253
  const sourceRegion = promptRegion || questionRegion;
228
254
  const snippets = [...(sourceRegion?.querySelectorAll('pre,code,.ace_editor,.CodeMirror,.monaco-editor') || [])]
229
255
  .filter(node => visible(node) && !node.parentElement?.closest('pre,code,.ace_editor,.CodeMirror,.monaco-editor'));
@@ -240,6 +266,7 @@
240
266
  // Preserve filenames, prose and ALL snippets together; the
241
267
  // last code block alone is not the question's full context.
242
268
  const clone = promptRegion.cloneNode(true);
269
+ preserveMatrices(clone);
243
270
  for (const block of clone.querySelectorAll('pre,.ace_editor,.CodeMirror,.monaco-editor')) {
244
271
  const plain = doc.createElement('pre');
245
272
  plain.textContent = '\n' + codeText(block) + '\n';
@@ -61,14 +61,122 @@ async function attachUpdatedPanels() {
61
61
  }
62
62
  }
63
63
  chrome.alarms.create('assistant-workspace-update', { periodInMinutes: 1 });
64
- chrome.alarms.onAlarm.addListener(alarm => { if (alarm.name === 'assistant-workspace-update')
65
- void checkWorkspaceUpdate(); });
64
+ chrome.alarms.onAlarm.addListener(alarm => {
65
+ if (alarm.name !== 'assistant-workspace-update')
66
+ return;
67
+ void checkWorkspaceUpdate();
68
+ void checkJournalReminder();
69
+ void updateJournalBadge();
70
+ });
66
71
  chrome.runtime.onInstalled.addListener(() => { void attachUpdatedPanels(); });
72
+ let journalBadgeRevision = 0;
73
+ function activeJournalTimers(data, now = Date.now()) {
74
+ const prefix = 'assistantJournalTimer:';
75
+ const timers = new Map();
76
+ const last = data.assistantJournalTimer;
77
+ if (typeof last?.path === 'string')
78
+ timers.set(last.path.replace(/\/$/, ''), last.deadline);
79
+ for (const [key, deadline] of Object.entries(data)) {
80
+ if (key.startsWith(prefix) && typeof deadline === 'number')
81
+ timers.set(key.slice(prefix.length), deadline);
82
+ }
83
+ return [...timers].filter(([path, deadline]) => /^\/modules\/reflective-journal\/[^/]+$/.test(path) &&
84
+ Number.isFinite(deadline) && deadline > now && !data[`assistantJournalConfirmed:${path}`])
85
+ .map(([path, deadline]) => ({ path, deadline })).sort((a, b) => a.deadline - b.deadline);
86
+ }
87
+ async function updateJournalBadge() {
88
+ const storage = chrome.storage?.local;
89
+ if (!storage || !chrome.action)
90
+ return;
91
+ const revision = ++journalBadgeRevision;
92
+ try {
93
+ const timers = activeJournalTimers(await storage.get());
94
+ const remaining = timers[0]?.deadline - Date.now();
95
+ if (revision !== journalBadgeRevision)
96
+ return;
97
+ const minutes = Math.ceil(remaining / 60000);
98
+ const active = Number.isFinite(minutes) && minutes > 0;
99
+ const text = active ? (remaining < 60000 ? '<1m' : minutes > 999 ? '999+' : `${minutes}m`) : '';
100
+ await Promise.all([
101
+ chrome.action.setBadgeText({ text }),
102
+ chrome.action.setBadgeBackgroundColor({ color: '#5b39db' }),
103
+ chrome.action.setTitle({ title: active ? `Browser Assistant · ${timers.length > 1 ? 'Next journal' : 'Journal'}: about ${minutes} min left` : 'Browser Assistant' }),
104
+ ]);
105
+ }
106
+ catch { /* Retry when the timer changes or on the next alarm. */ }
107
+ }
108
+ chrome.storage?.local.onChanged?.addListener(changes => {
109
+ if (Object.keys(changes).some(key => key.startsWith('assistantJournalTimer') || key.startsWith('assistantJournalConfirmed:'))) {
110
+ void updateJournalBadge();
111
+ }
112
+ });
113
+ void updateJournalBadge();
114
+ let checkingJournalReminder = false;
115
+ async function checkJournalReminder() {
116
+ if (checkingJournalReminder)
117
+ return;
118
+ checkingJournalReminder = true;
119
+ try {
120
+ const storage = chrome.storage.local;
121
+ const data = await storage.get();
122
+ const config = data.assistantJournalReminderConfig;
123
+ if (!config?.url || !config?.token)
124
+ return;
125
+ await Promise.all(activeJournalTimers(data).map(timer => sendJournalReminder(timer, config)));
126
+ }
127
+ catch { /* The minute alarm keeps timer checks independent of workspace updates. */ }
128
+ finally {
129
+ checkingJournalReminder = false;
130
+ }
131
+ }
132
+ async function sendJournalReminder(timer, config) {
133
+ try {
134
+ const storage = chrome.storage.local;
135
+ const remaining = timer.deadline - Date.now();
136
+ // Give the portal time to confirm an existing submission at 30:00.
137
+ if (remaining > (30 * 60 - 45) * 1000 || remaining <= 0)
138
+ return;
139
+ const stateKey = `assistantJournalReminder:${timer.path}`;
140
+ const confirmationKey = `assistantJournalConfirmed:${timer.path}`;
141
+ const previous = await storage.get([stateKey, confirmationKey]);
142
+ if (previous[stateKey] || previous[confirmationKey])
143
+ return;
144
+ // Reserve the one attempt before the network call. A timed-out webhook
145
+ // can still have sent a message, so replay would risk a duplicate.
146
+ await storage.set({ [stateKey]: 'sending' });
147
+ const latest = await storage.get();
148
+ const current = activeJournalTimers(latest).find(candidate => candidate.path === timer.path);
149
+ if (!current || Math.abs(current.deadline - timer.deadline) > 2000 ||
150
+ latest.assistantJournalReminderConfig?.url !== config.url || latest.assistantJournalReminderConfig.token !== config.token) {
151
+ await storage.remove(stateKey);
152
+ return;
153
+ }
154
+ let result = 'failed';
155
+ try {
156
+ const response = await fetch(config.url, {
157
+ method: 'POST', credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(15000),
158
+ headers: { 'Content-Type': 'application/json', 'X-Journal-Reminder-Token': config.token },
159
+ body: JSON.stringify({ event: 'journal_submission_unconfirmed' }),
160
+ });
161
+ if (response.ok)
162
+ result = 'sent';
163
+ }
164
+ finally {
165
+ await storage.set({ [stateKey]: result });
166
+ }
167
+ }
168
+ catch { /* One failed request does not interrupt the other journals. */ }
169
+ }
67
170
  chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
68
171
  if (!request || typeof request.action !== 'string') {
69
172
  sendResponse({ error: 'Invalid extension message' });
70
173
  return false;
71
174
  }
175
+ if (request.action === 'journalCountdowns' && sender.id === chrome.runtime.id) {
176
+ chrome.storage.local.get().then(data => sendResponse(activeJournalTimers(data)))
177
+ .catch(() => sendResponse([]));
178
+ return true;
179
+ }
72
180
  if (request.action === 'questionImage') {
73
181
  let url;
74
182
  try {
@@ -1,4 +1,63 @@
1
1
  // Browser Assistant Content Script v3.0 — High Accuracy Edition
2
+ function mountJournalCountdown(signal) {
3
+ const storage = chrome.storage.local;
4
+ const badge = document.createElement('div');
5
+ badge.id = 'assistant-journal-countdown-badge';
6
+ const clock = document.createElement('span');
7
+ clock.setAttribute('role', 'timer');
8
+ badge.attachShadow({ mode: 'closed' }).append(clock);
9
+ let timers = [];
10
+ let tick;
11
+ let revision = 0;
12
+ function renderCountdown() {
13
+ window.clearTimeout(tick);
14
+ if (signal.aborted)
15
+ return;
16
+ const now = Date.now();
17
+ const active = timers.filter(timer => timer.deadline > now);
18
+ const path = location.pathname.replace(/\/$/, '');
19
+ const aurora = location.origin === 'https://admissions.aurora.edu.in';
20
+ const visible = active.filter(timer => !aurora || (path.startsWith('/modules/reflective-journal/') && timer.path !== path));
21
+ if (!visible.length)
22
+ badge.remove();
23
+ else {
24
+ const times = visible.map(timer => {
25
+ const seconds = Math.ceil((timer.deadline - now) / 1000);
26
+ return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
27
+ });
28
+ clock.textContent = `${times.length === 1 ? 'Journal time left' : 'Journal times left'} ≈ ${times.join(' · ')}`;
29
+ if (!badge.isConnected)
30
+ document.body.appendChild(badge);
31
+ }
32
+ if (active.length && !document.hidden)
33
+ tick = window.setTimeout(renderCountdown, 1000);
34
+ }
35
+ async function refreshCountdown() {
36
+ const request = ++revision;
37
+ try {
38
+ const next = await chrome.runtime.sendMessage({ action: 'journalCountdowns' });
39
+ if (request !== revision || signal.aborted)
40
+ return;
41
+ timers = Array.isArray(next) ? next : [];
42
+ renderCountdown();
43
+ }
44
+ catch { /* Reloading the extension can invalidate the old tab context. */ }
45
+ }
46
+ const onStorageChange = (changes) => {
47
+ if (Object.keys(changes).some(key => key.startsWith('assistantJournalTimer') || key.startsWith('assistantJournalConfirmed:'))) {
48
+ void refreshCountdown();
49
+ }
50
+ };
51
+ storage.onChanged.addListener(onStorageChange);
52
+ document.addEventListener('visibilitychange', renderCountdown, { signal });
53
+ signal.addEventListener('abort', () => {
54
+ window.clearTimeout(tick);
55
+ storage.onChanged.removeListener(onStorageChange);
56
+ badge.remove();
57
+ }, { once: true });
58
+ void refreshCountdown();
59
+ return badge;
60
+ }
2
61
  (function () {
3
62
  // Abort on pages without a body (about:blank, PDFs, XML docs, etc.)
4
63
  if (!document.body)
@@ -22,6 +81,8 @@
22
81
  const root = document.createElement('div');
23
82
  root.id = 'assistant-root';
24
83
  document.body.appendChild(root);
84
+ const journalCountdownBadge = ['http:', 'https:'].includes(location.protocol)
85
+ ? mountJournalCountdown(lifecycle.signal) : null;
25
86
  // ── Move panel ───────────────────────────────────────────────────────────
26
87
  // Drag the header to put the panel anywhere on the current page. The saved
27
88
  // location is restored after a refresh on that site.
@@ -2077,10 +2138,13 @@
2077
2138
  setLoading("Capturing screen…");
2078
2139
  let timeoutId;
2079
2140
  let panelVisibility = '';
2141
+ const countdownVisibility = journalCountdownBadge?.style.visibility;
2080
2142
  try {
2081
- // Do not include our own floating panel in the captured screenshot.
2143
+ // Keep extension UI out of the captured screenshot.
2082
2144
  panelVisibility = root.style.visibility;
2083
2145
  root.style.visibility = 'hidden';
2146
+ if (journalCountdownBadge)
2147
+ journalCountdownBadge.style.visibility = 'hidden';
2084
2148
  const context = await prepareVisionQuestionContext();
2085
2149
  const visuals = window.BrowserAssistantQuestionVisuals;
2086
2150
  let images;
@@ -2098,6 +2162,8 @@
2098
2162
  images = [response.dataUrl];
2099
2163
  }
2100
2164
  root.style.visibility = panelVisibility;
2165
+ if (journalCountdownBadge)
2166
+ journalCountdownBadge.style.visibility = countdownVisibility;
2101
2167
  await assertVisionQuestionCurrent(context);
2102
2168
  setLoading("Analyzing and cross-checking…");
2103
2169
  const res = await apiRequest('/solve-vision', 'POST', {
@@ -2140,6 +2206,8 @@
2140
2206
  }
2141
2207
  finally {
2142
2208
  root.style.visibility = panelVisibility;
2209
+ if (journalCountdownBadge)
2210
+ journalCountdownBadge.style.visibility = countdownVisibility;
2143
2211
  // Text Scan can hand off to Vision without a visible error. In that
2144
2212
  // case, leave its in-progress state for the caller to clear.
2145
2213
  if (!scanWasAlreadyRunning) {
@@ -2509,6 +2577,7 @@
2509
2577
  <div><dt>Progress</dt><dd id="assistant-automation-progress">0 / —</dd></div>
2510
2578
  <div class="assistant-qa-wide"><dt>Current question</dt><dd id="assistant-automation-question">—</dd></div>
2511
2579
  </dl>
2580
+ <div class="assistant-qa-latest" id="assistant-journal-list-clock" hidden></div>
2512
2581
  <div class="assistant-qa-latest" id="assistant-automation-latest" aria-live="polite"></div>
2513
2582
  <div id="assistant-journal-slot"></div>
2514
2583
  <div class="assistant-automation-actions assistant-qa-actions">
@@ -1,3 +1,6 @@
1
+ function formatJournalTime(seconds) {
2
+ return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
3
+ }
1
4
  function browserAssistantJournal(global) {
2
5
  'use strict';
3
6
  const keys = ['experience', 'feelings', 'learning', 'application', 'conclusion'];
@@ -84,10 +87,10 @@ function browserAssistantJournal(global) {
84
87
  }
85
88
  if (explicit.size)
86
89
  return explicit.size === 1 ? [...explicit][0] : null;
87
- for (const node of doc.querySelectorAll('span,div,p,output,strong,b')) {
90
+ for (const node of doc.querySelectorAll('span,div,p,output,strong,b,time,label')) {
88
91
  if (excluded(node) || !node.getClientRects().length)
89
92
  continue;
90
- const value = labeled(node.textContent);
93
+ const value = labeled(node.innerText ?? node.textContent);
91
94
  if (value !== null)
92
95
  values.add(value);
93
96
  }
@@ -130,7 +133,7 @@ function browserAssistantJournal(global) {
130
133
  panel.id = 'assistant-journal';
131
134
  panel.dataset.build = journalBuild;
132
135
  panel.style.cssText = 'position:fixed;right:16px;top:160px;width:290px;z-index:2147483646;background:white;color:#17233d;padding:16px;border:2px solid #7638ff;border-radius:12px;box-shadow:0 4px 20px #0003;font:14px Arial';
133
- panel.innerHTML = '<strong>Reflective Journal</strong><p data-info></p><textarea aria-label="Your class experience and feelings" placeholder="Your actual class experience / feelings (optional)" rows="3" style="width:100%;box-sizing:border-box"></textarea><button type="button" style="margin-top:8px;padding:10px">Fill &amp; Submit Journal</button><p role="status" aria-live="polite"></p>';
136
+ panel.innerHTML = '<strong>Reflective Journal</strong><p data-info></p><p data-countdown style="font-weight:600;margin:8px 0"></p><textarea aria-label="Your class experience and feelings" placeholder="Your actual class experience / feelings (optional)" rows="3" style="width:100%;box-sizing:border-box"></textarea><button type="button" style="margin-top:8px;padding:10px">Fill &amp; Submit Journal</button><p role="status" aria-live="polite"></p>';
134
137
  document.body.appendChild(panel);
135
138
  const controls = document.createElement('div');
136
139
  controls.innerHTML = '<label>Subject <select data-subject aria-label="Journal subject"><option value="">Detect from page</option><option>Backend Development Fundamentals</option><option>Computer Organization and Architecture</option><option>Advanced Data Structures</option><option>Linear Algebra Essentials</option><option>Fundamentals of Artificial Intelligence</option></select></label><label>Module / Journal <select data-number aria-label="Journal number"><option value="">Detect from page</option>' + Array.from({ length: 10 }, (_, i) => `<option value="${i + 1}">${i + 1}</option>`).join('') + '</select></label>';
@@ -140,6 +143,93 @@ function browserAssistantJournal(global) {
140
143
  select.style.cssText = 'display:block;width:100%;padding:5px;color:#17233d;background:white;border:1px solid #abb3c3';
141
144
  panel.insertBefore(controls, panel.querySelector('textarea'));
142
145
  const status = panel.querySelector('[role=status]'), button = panel.querySelector('button');
146
+ const countdown = panel.querySelector('[data-countdown]');
147
+ const countdownKey = 'assistantJournalTimer';
148
+ const journalPath = location.pathname.replace(/\/$/, '');
149
+ const journalDeadlineKey = `${countdownKey}:${journalPath}`;
150
+ let deadline;
151
+ let lastPortalTime = null;
152
+ let observedInForeground = false;
153
+ let publishedDeadline;
154
+ let journalConfirmed = false;
155
+ async function loadCountdown() {
156
+ try {
157
+ const cached = sessionStorage.getItem(journalDeadlineKey);
158
+ if (cached && Number.isFinite(Number(cached))) {
159
+ deadline = Number(cached);
160
+ renderCountdown(null);
161
+ return;
162
+ }
163
+ }
164
+ catch { /* Fall back to extension storage. */ }
165
+ try {
166
+ const data = await global.chrome?.storage?.local.get([journalDeadlineKey, countdownKey]);
167
+ const latest = data?.[countdownKey];
168
+ const saved = data?.[journalDeadlineKey] ?? (latest?.path?.replace(/\/$/, '') === journalPath ? latest.deadline : undefined);
169
+ if (deadline === undefined && Number.isFinite(saved)) {
170
+ deadline = saved;
171
+ renderCountdown(null);
172
+ }
173
+ }
174
+ catch { /* The portal clock remains available if extension storage is unavailable. */ }
175
+ }
176
+ function renderCountdown(portalTime) {
177
+ const seconds = portalTime ?? (deadline === undefined ? null : Math.max(0, Math.ceil((deadline - Date.now()) / 1000)));
178
+ const label = portalTime === null ? 'Estimated time left' : 'Portal time left';
179
+ const restoringDraft = portalTime === null && /Unsaved draft detected\./i.test(document.body.innerText || pageText(document));
180
+ const clockText = seconds === null ? 'Waiting for the portal timer to appear' : `${label} ${formatJournalTime(seconds)}`;
181
+ const text = restoringDraft
182
+ ? `${clockText}. To reopen your draft, review the declaration and use the portal’s Accept Terms to Start button.`
183
+ : seconds === null ? `${clockText}.` : clockText;
184
+ if (countdown.textContent !== text)
185
+ countdown.textContent = text;
186
+ }
187
+ async function recordJournalConfirmation() {
188
+ if (!journalConfirmed && (controller.getState().completed === 1 || [...document.querySelectorAll('p,h1,h2,h3,[role=status],[role=alert]')].some(node => !node.closest('#assistant-root,#assistant-journal,[contenteditable]') && node.getClientRects().length &&
189
+ /^(?:journal (?:has been )?submitted successfully|journal submission successful)[.!]?$/i.test(node.textContent.trim())))) {
190
+ try {
191
+ await global.chrome?.storage?.local.set({ [`assistantJournalConfirmed:${journalPath}`]: true });
192
+ journalConfirmed = true;
193
+ }
194
+ catch { /* Retry on the next tick while the confirmation is available. */ }
195
+ }
196
+ }
197
+ async function updateCountdown() {
198
+ if (!document.contains(panel) || storageKey !== `assistantJournal:${location.pathname}`)
199
+ return;
200
+ const observed = remainingSeconds(document);
201
+ // The editor can briefly render zero before the active clock loads.
202
+ const portalTime = observed === 0 && lastPortalTime === null ? null : observed;
203
+ const visible = !document.hidden;
204
+ const returnedToJournal = visible && !observedInForeground;
205
+ observedInForeground = visible;
206
+ if (portalTime !== null && portalTime !== lastPortalTime) {
207
+ deadline = Date.now() + portalTime * 1000;
208
+ lastPortalTime = portalTime;
209
+ // Keep the observed deadline through an immediate same-tab reload,
210
+ // even if the extension storage write is still in flight.
211
+ try {
212
+ sessionStorage.setItem(journalDeadlineKey, String(deadline));
213
+ }
214
+ catch { }
215
+ }
216
+ renderCountdown(portalTime);
217
+ if (!visible || portalTime === null || deadline === undefined ||
218
+ (!returnedToJournal && publishedDeadline !== undefined && Math.abs(deadline - publishedDeadline) <= 2000))
219
+ return;
220
+ const pendingDeadline = deadline;
221
+ publishedDeadline = pendingDeadline;
222
+ try {
223
+ await global.chrome?.storage?.local.set({
224
+ [journalDeadlineKey]: pendingDeadline,
225
+ [countdownKey]: { path: journalPath, deadline: pendingDeadline },
226
+ });
227
+ }
228
+ catch {
229
+ if (publishedDeadline === pendingDeadline)
230
+ publishedDeadline = undefined;
231
+ }
232
+ }
143
233
  button.title = 'Fill & Submit Journal (Alt + Shift + J)';
144
234
  button.setAttribute('aria-keyshortcuts', 'Alt+Shift+J');
145
235
  const shortcutHint = document.createElement('p');
@@ -319,7 +409,7 @@ function browserAssistantJournal(global) {
319
409
  if (remaining === null || remaining > 30 * 60) {
320
410
  waitingForTimer = true;
321
411
  const time = remaining === null ? 'Waiting for the portal timer to become readable.'
322
- : `Current timer: ${String(Math.floor(remaining / 60)).padStart(2, '0')}:${String(remaining % 60).padStart(2, '0')}.`;
412
+ : `Current timer: ${formatJournalTime(remaining)}.`;
323
413
  status.textContent = `${count} words ready. Waiting to submit at 30:00 remaining. ${time}`;
324
414
  return;
325
415
  }
@@ -447,6 +537,14 @@ function browserAssistantJournal(global) {
447
537
  }
448
538
  });
449
539
  refreshInfo();
540
+ void loadCountdown();
541
+ void updateCountdown();
542
+ const clockObserver = global.MutationObserver ? new global.MutationObserver(records => {
543
+ if (records.some(({ target }) => !(target.nodeType === 1 ? target : target.parentElement)?.closest('#assistant-root,#assistant-journal'))) {
544
+ void updateCountdown();
545
+ }
546
+ }) : null;
547
+ clockObserver?.observe(document.body, { subtree: true, childList: true, characterData: true });
450
548
  controller.mount(document.getElementById('assistant-journal-slot'));
451
549
  document.dispatchEvent(new global.Event('assistant-journal-ready'));
452
550
  try {
@@ -460,27 +558,20 @@ function browserAssistantJournal(global) {
460
558
  // Poll only the journal page; field discovery never changes platform restrictions.
461
559
  const timer = setInterval(() => {
462
560
  if (global.BrowserAssistantJournal?.current !== controller && global.BrowserAssistantJournal) {
463
- clearInterval(timer);
464
- document.removeEventListener('keydown', shortcutListener, true);
561
+ stopWatching();
465
562
  return;
466
563
  }
467
- if (!document.contains(panel)) {
564
+ if (!document.contains(panel) || storageKey !== `assistantJournal:${location.pathname}`) {
468
565
  draft = null;
469
566
  global.BrowserAssistantJournalBusy = false;
470
- document.removeEventListener('keydown', shortcutListener, true);
471
- clearInterval(timer);
472
- return;
473
- }
474
- if (!/^\/modules\/reflective-journal\//.test(location.pathname) || storageKey !== `assistantJournal:${location.pathname}`) {
475
- draft = null;
476
- global.BrowserAssistantJournalBusy = false;
477
- document.removeEventListener('keydown', shortcutListener, true);
478
- clearInterval(timer);
567
+ stopWatching();
479
568
  panel.remove();
480
569
  return;
481
570
  }
482
571
  if (!info)
483
572
  refreshInfo();
573
+ void updateCountdown();
574
+ void recordJournalConfirmation();
484
575
  notify();
485
576
  if (draft && !submitting && !starting) {
486
577
  const run = generation;
@@ -499,6 +590,11 @@ function browserAssistantJournal(global) {
499
590
  });
500
591
  }
501
592
  }, 1000);
593
+ function stopWatching() {
594
+ clearInterval(timer);
595
+ clockObserver?.disconnect();
596
+ document.removeEventListener('keydown', shortcutListener, true);
597
+ }
502
598
  }
503
599
  if (typeof window !== 'undefined' && location.origin === 'https://admissions.aurora.edu.in') {
504
600
  window.BrowserAssistantJournal = window.BrowserAssistantJournal || {
@@ -552,7 +648,36 @@ if (typeof window !== 'undefined' && location.origin === 'https://admissions.aur
552
648
  browserAssistantJournal(typeof window === 'undefined' ? globalThis : window);
553
649
  if (typeof window !== 'undefined' && location.origin === 'https://admissions.aurora.edu.in' && !window.BrowserAssistantJournalBoot) {
554
650
  window.BrowserAssistantJournalBoot = true;
651
+ let lastDeadline;
652
+ const storage = window.chrome?.storage?.local;
653
+ let clockChanged = false;
654
+ function updateJournalListClock() {
655
+ const clock = document.getElementById('assistant-journal-list-clock');
656
+ if (!clock)
657
+ return;
658
+ clock.hidden = /^\/modules\/reflective-journal\//.test(location.pathname) || !Number.isFinite(lastDeadline);
659
+ if (!clock.hidden) {
660
+ const seconds = Math.max(0, Math.ceil((lastDeadline - Date.now()) / 1000));
661
+ clock.textContent = `Last journal · Estimated time left ${formatJournalTime(seconds)}`;
662
+ }
663
+ }
664
+ function loadJournalListClock(saved) {
665
+ lastDeadline = typeof saved === 'object' && saved !== null && 'deadline' in saved && typeof saved.deadline === 'number'
666
+ ? saved.deadline : undefined;
667
+ updateJournalListClock();
668
+ }
669
+ storage?.onChanged?.addListener(changes => {
670
+ if (!Object.hasOwn(changes, 'assistantJournalTimer'))
671
+ return;
672
+ clockChanged = true;
673
+ loadJournalListClock(changes.assistantJournalTimer.newValue);
674
+ });
675
+ void storage?.get('assistantJournalTimer').then(data => {
676
+ if (!clockChanged)
677
+ loadJournalListClock(data.assistantJournalTimer);
678
+ }).catch(() => { });
555
679
  setInterval(() => {
680
+ updateJournalListClock();
556
681
  if (/^\/modules\/reflective-journal\//.test(location.pathname) && !document.getElementById('assistant-journal'))
557
682
  browserAssistantJournal(window);
558
683
  }, 1000);
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Browser Assistant",
4
- "version": "2.8.7",
4
+ "version": "2.8.21",
5
5
  "description": "Scans webpages for MCQs and coding problems. Powered by Groq (cloud) and Ollama (local fallback).",
6
6
  "permissions": [
7
7
  "activeTab",
8
8
  "scripting",
9
9
  "tabs",
10
10
  "clipboardWrite",
11
+ "storage",
11
12
  "alarms"
12
13
  ],
13
14
  "background": {
@@ -39,6 +39,11 @@
39
39
  #server-status { color: #f59e0b; }
40
40
  #server-status.online { color: #10b981; }
41
41
  #server-status.offline { color: #ef4444; }
42
+ #journal-reminder { margin: 10px 0; font-size: 11px; }
43
+ #journal-reminder summary { cursor: pointer; }
44
+ #journal-reminder label { display: block; margin: 7px 0; }
45
+ #journal-reminder input { box-sizing: border-box; width: 100%; }
46
+ #journal-reminder-status { margin-top: 6px; }
42
47
  </style>
43
48
  </head>
44
49
  <body>
@@ -48,6 +53,17 @@
48
53
  </div>
49
54
  <p>Extension active. The floating panel appears on every page.</p>
50
55
  <div class="status">Server: <span id="server-status">checking...</span></div>
56
+ <p id="journal-countdown" hidden></p>
57
+ <details id="journal-reminder">
58
+ <summary>WhatsApp journal reminder</summary>
59
+ <form id="journal-reminder-form">
60
+ <label>n8n production webhook URL<input id="journal-webhook-url" type="url" required></label>
61
+ <label>Webhook token<input id="journal-webhook-token" type="password" required autocomplete="off"></label>
62
+ <button type="submit">Enable</button>
63
+ <button type="button" id="journal-reminder-disable">Disable</button>
64
+ </form>
65
+ <p id="journal-reminder-status" role="status">Checking settings…</p>
66
+ </details>
51
67
  <div class="shortcuts">
52
68
  <div class="shortcut-row"><span>Open panel</span> <kbd>`</kbd></div>
53
69
  <div class="shortcut-row"><span>Close panel</span> <kbd>]</kbd></div>
@@ -17,3 +17,79 @@ new Promise((resolve, reject) => {
17
17
  el.textContent = 'offline';
18
18
  el.className = 'offline';
19
19
  });
20
+ const journalCountdown = document.getElementById('journal-countdown');
21
+ let journalTimers = [];
22
+ function showJournalCountdown() {
23
+ const now = Date.now();
24
+ const times = journalTimers.filter(timer => timer.deadline > now).map(timer => {
25
+ const seconds = Math.ceil((timer.deadline - now) / 1000);
26
+ return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
27
+ });
28
+ journalCountdown.hidden = !times.length;
29
+ journalCountdown.textContent = times.length ? `Journal time left ≈ ${times.join(' · ')}` : '';
30
+ }
31
+ const journalTimerStorage = chrome.storage.local;
32
+ let journalTimerRevision = 0;
33
+ async function refreshPopupJournalTimers() {
34
+ const request = ++journalTimerRevision;
35
+ try {
36
+ const timers = await chrome.runtime.sendMessage({ action: 'journalCountdowns' });
37
+ if (request !== journalTimerRevision)
38
+ return;
39
+ journalTimers = Array.isArray(timers) ? timers : [];
40
+ showJournalCountdown();
41
+ }
42
+ catch { /* A closed popup no longer needs an update. */ }
43
+ }
44
+ journalTimerStorage.onChanged.addListener(changes => {
45
+ if (Object.keys(changes).some(key => key.startsWith('assistantJournalTimer') || key.startsWith('assistantJournalConfirmed:'))) {
46
+ void refreshPopupJournalTimers();
47
+ }
48
+ });
49
+ void refreshPopupJournalTimers();
50
+ setInterval(showJournalCountdown, 1000);
51
+ const reminderForm = document.querySelector('#journal-reminder-form');
52
+ if (reminderForm) {
53
+ const key = 'assistantJournalReminderConfig';
54
+ const urlInput = document.querySelector('#journal-webhook-url');
55
+ const tokenInput = document.querySelector('#journal-webhook-token');
56
+ const status = document.getElementById('journal-reminder-status');
57
+ const disable = document.querySelector('#journal-reminder-disable');
58
+ void journalTimerStorage.get(key).then(data => {
59
+ const config = data[key];
60
+ if (config)
61
+ urlInput.value = config.url;
62
+ status.textContent = config ? 'Reminder enabled.' : 'Reminder disabled.';
63
+ }).catch(() => { status.textContent = 'Could not load reminder settings.'; });
64
+ reminderForm.addEventListener('submit', async (event) => {
65
+ event.preventDefault();
66
+ try {
67
+ const url = new URL(urlInput.value);
68
+ const local = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
69
+ if (!(url.protocol === 'https:' || (local && url.protocol === 'http:')) || url.username || url.password ||
70
+ url.search || url.hash || !url.pathname.includes('/webhook/')) {
71
+ throw Error('Enter the n8n production webhook URL. Use HTTPS for remote n8n.');
72
+ }
73
+ const token = tokenInput.value.trim();
74
+ if (!/^[\x21-\x7e]{20,256}$/.test(token))
75
+ throw Error('Use a webhook token of at least 20 characters, with no spaces.');
76
+ await journalTimerStorage.set({ [key]: { url: url.href, token } });
77
+ tokenInput.value = '';
78
+ status.textContent = 'Reminder enabled.';
79
+ }
80
+ catch (error) {
81
+ status.textContent = error.message;
82
+ }
83
+ });
84
+ disable.addEventListener('click', async () => {
85
+ try {
86
+ await journalTimerStorage.remove(key);
87
+ urlInput.value = '';
88
+ tokenInput.value = '';
89
+ status.textContent = 'Reminder disabled.';
90
+ }
91
+ catch {
92
+ status.textContent = 'Could not disable reminder.';
93
+ }
94
+ });
95
+ }
@@ -1,5 +1,22 @@
1
1
  /* Browser Assistant – Styles v2.0 */
2
2
 
3
+ #assistant-journal-countdown-badge {
4
+ all: initial;
5
+ position: fixed;
6
+ bottom: 16px;
7
+ left: 16px;
8
+ z-index: 2147483647;
9
+ padding: 8px 12px;
10
+ border: 1px solid #a78bfa;
11
+ border-radius: 999px;
12
+ background: #20163b;
13
+ color: #fff;
14
+ font: 600 13px/1.3 system-ui, sans-serif;
15
+ font-variant-numeric: tabular-nums;
16
+ pointer-events: none;
17
+ user-select: none;
18
+ }
19
+
3
20
  #assistant-root {
4
21
  position: fixed;
5
22
  top: 20px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-assistant",
3
- "version": "2.8.7",
3
+ "version": "2.8.21",
4
4
  "description": "Chrome extension and local Python backend for exploring webpage questions and coding exercises",
5
5
  "license": "MIT",
6
6
  "repository": {
package/server/main.py CHANGED
@@ -24,7 +24,7 @@ from fastapi.exception_handlers import http_exception_handler
24
24
  from local_security import install_local_guards, redact, sanitize_data
25
25
  from vision_images import (compress_image_base64, unique_image_blocks, collect_image_evidence,
26
26
  cross_check_visual_answers, relative_percentage_question, RELATIVE_PERCENTAGE_INSTRUCTION,
27
- answer_permutation, cross_check_text_answers, solve_matrix_choice,
27
+ answer_permutation, cross_check_text_answers, solve_matrix_choice, solve_row_parallel_choice,
28
28
  VisionRateLimitError, VisionUnconfirmedError)
29
29
 
30
30
  logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
@@ -191,7 +191,7 @@ async def lifespan(_app: FastAPI):
191
191
  client.close()
192
192
 
193
193
 
194
- app = FastAPI(title="Browser Assistant API", version="2.8.7", lifespan=lifespan)
194
+ app = FastAPI(title="Browser Assistant API", version="2.8.21", lifespan=lifespan)
195
195
  install_local_guards(app)
196
196
 
197
197
  @app.exception_handler(HTTPException)
@@ -1191,10 +1191,14 @@ async def review_and_correct_code(
1191
1191
 
1192
1192
  # ── Routes ────────────────────────────────────────────────────────────────────
1193
1193
 
1194
- def calculated_matrix_result(text, language):
1194
+ def local_choice_result(text, language):
1195
1195
  item = solve_matrix_choice(text)
1196
+ provider = "local matrix calculation"
1197
+ if item is None:
1198
+ item = solve_row_parallel_choice(text)
1199
+ provider = "local tensor dimension check"
1196
1200
  if item is not None:
1197
- return {"results": json.dumps([item], ensure_ascii=False), "provider": "local matrix calculation [verified]",
1201
+ return {"results": json.dumps([item], ensure_ascii=False), "provider": f"{provider} [verified]",
1198
1202
  "language": language, "answer_verified": True, "code_reviewed": False}
1199
1203
 
1200
1204
 
@@ -1241,9 +1245,9 @@ REPAIR MODE:
1241
1245
  strict_code_review = require_code and (content.review_before_submit or not FAST_CODE_MODE or STRICT_REPAIR_REVIEW)
1242
1246
  verify_answers = not require_code and (content.verify_answer or not FAST_SCAN_MODE)
1243
1247
  if not require_code and content.practice_type in {'auto', 'mcq', 'math', 'aptitude'}:
1244
- matrix_result = calculated_matrix_result(trimmed_text, language)
1245
- if matrix_result is not None:
1246
- return matrix_result
1248
+ local_result = local_choice_result(trimmed_text, language)
1249
+ if local_result is not None:
1250
+ return local_result
1247
1251
 
1248
1252
  async def confirm_answers(cleaned, source_model=None):
1249
1253
  if not verify_answers or not any(item['type'] == 'mcq' for item in json.loads(cleaned)):
@@ -1409,9 +1413,9 @@ async def _solve_vision(content: VisionContent):
1409
1413
  raise HTTPException(422, f'Image {index}: {exc}') from exc
1410
1414
  is_code_scan = content.practice_type == "code" or (content.practice_type == "auto" and bool(content.language))
1411
1415
  if not is_code_scan and content.practice_type in {'auto', 'mcq', 'math', 'aptitude'}:
1412
- matrix_result = calculated_matrix_result(content.text, content.language or CODE_DEFAULT_LANGUAGE)
1413
- if matrix_result is not None:
1414
- return matrix_result
1416
+ local_result = local_choice_result(content.text, content.language or CODE_DEFAULT_LANGUAGE)
1417
+ if local_result is not None:
1418
+ return local_result
1415
1419
  image_blocks, image_positions = unique_image_blocks(compressed)
1416
1420
 
1417
1421
  clients = tuple(_groq_clients)
@@ -196,21 +196,47 @@ def numeric_matrix_key(value):
196
196
  return None
197
197
 
198
198
 
199
- def matrix_2x2(value):
199
+ def numeric_matrix(value):
200
200
  rows = matrix_cells(value)
201
- if not rows or len(rows) != 2 or any(len(row) != 2 for row in rows):
201
+ if not rows or any(len(row) != len(rows[0]) for row in rows):
202
202
  return None
203
203
  entries = []
204
- for cell in (cell for row in rows for cell in row):
205
- cell = re.sub(r'^([+-]?)\\(?:[dt]?frac)\{([+-]?\d{1,15})\}\{(\d{1,15})\}$',
206
- r'\1\2/\3', cell)
207
- if not re.fullmatch(rf'(?:{DECIMAL_VALUE}|[+-]?\d{{1,15}}/\d{{1,15}})', cell):
208
- return None
209
- try:
210
- entries.append(Fraction(cell))
211
- except ZeroDivisionError:
204
+ for row in rows:
205
+ numbers = []
206
+ for cell in row:
207
+ cell = re.sub(r'^([+-]?)\\(?:[dt]?frac)\{([+-]?\d{1,15})\}\{(\d{1,15})\}$', r'\1\2/\3', cell)
208
+ if not re.fullmatch(rf'(?:{DECIMAL_VALUE}|[+-]?\d{{1,15}}/\d{{1,15}})', cell):
209
+ return None
210
+ try:
211
+ numbers.append(Fraction(cell))
212
+ except ZeroDivisionError:
213
+ return None
214
+ entries.append(numbers)
215
+ return entries
216
+
217
+
218
+ def matrix_2x2(value):
219
+ rows = numeric_matrix(value)
220
+ if rows and len(rows) == 2 and len(rows[0]) == 2:
221
+ return tuple(rows[0] + rows[1])
222
+
223
+
224
+ def rref_pivot_count(matrix):
225
+ rows = numeric_matrix(matrix)
226
+ if not rows:
227
+ return None
228
+ previous = -1
229
+ count = 0
230
+ for index, row in enumerate(rows):
231
+ column = next((col for col, entry in enumerate(row) if entry), None)
232
+ if column is None:
233
+ # Any remaining rows must also be zero.
234
+ return None if any(any(later) for later in rows[index + 1:]) else count
235
+ if column <= previous or row[column] != 1 or any(other[column] for i, other in enumerate(rows) if i != index):
212
236
  return None
213
- return tuple(entries)
237
+ previous = column
238
+ count += 1
239
+ return count
214
240
 
215
241
 
216
242
  def parse_labeled_choices(option_text):
@@ -245,6 +271,10 @@ def solve_matrix_choice(text):
245
271
  r'([A-Z]) with shape \(\s*([a-z])\s*,\s*([a-z])\s*\),\s*'
246
272
  r'which condition must hold for the product (\1\4|\4\1) to be defined\?', prompt,
247
273
  )
274
+ pivots = re.fullmatch(
275
+ r'How many (?:pivot(?: position)?s) does the following (?:RREF|reduced row echelon form) matrix have\?\s*(.+)',
276
+ prompt, flags=re.I,
277
+ )
248
278
  if inverse:
249
279
  entries = matrix_2x2(inverse[2])
250
280
  if entries is None:
@@ -268,6 +298,16 @@ def solve_matrix_choice(text):
268
298
  return frozenset(equation.groups()) if equation else None
269
299
 
270
300
  reasoning = f'The left matrix has {left_cols} columns and the right matrix has {right_rows} rows. These must be equal.'
301
+ elif pivots:
302
+ expected = rref_pivot_count(pivots[1])
303
+ if expected is None:
304
+ return None
305
+
306
+ def parse_option(value):
307
+ count = re.fullmatch(r'\d{1,5}', unwrap_math(value))
308
+ return int(count[0]) if count else None
309
+
310
+ reasoning = f'Each nonzero row in reduced row echelon form has one leading 1. There are {expected} such rows.'
271
311
  else:
272
312
  return None
273
313
 
@@ -340,6 +380,28 @@ def displayed_single_choices(text):
340
380
  return parse_labeled_choices(option_text)
341
381
 
342
382
 
383
+ def solve_row_parallel_choice(text):
384
+ """Check the shared multiplication dimension in a row-parallel linear layer."""
385
+ choices = displayed_single_choices(text)
386
+ if not choices:
387
+ return None
388
+ question = re.split(r'\n(?:Question format: )?Select one answer\.', text.strip(), maxsplit=1, flags=re.I)[0]
389
+ prompt = ' '.join(question.split())
390
+ if not re.fullmatch(r'In row(?:[- ]tensor)? parallelism, why must the input also be split, not just the weights\?',
391
+ prompt, flags=re.I):
392
+ return None
393
+ matches = [(letter, option) for letter, option in choices.items() if re.fullmatch(
394
+ r'(?:so that )?the input (?:shape|feature dimension) matches the (?:corresponding )?weight (?:rows|shard)\.?', option, re.I)]
395
+ if len(matches) != 1:
396
+ return None
397
+ letter, option = matches[0]
398
+ return {
399
+ 'type': 'mcq', 'question': question, 'answer': f'Option {letter}: {option}',
400
+ 'reasoning': 'In Y = XW, rows of W index input features. Each GPU needs the corresponding columns of X '
401
+ 'so the inner dimensions match. The GPUs sum their partial products.',
402
+ }
403
+
404
+
343
405
  def bind_displayed_choice(value, choices, *, matrix_math):
344
406
  if type(value) not in (str, int, float):
345
407
  return None