total-recall-brain 3.28.2 → 3.30.0

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 (132) hide show
  1. package/bin/total-recall.mjs +24 -1
  2. package/docs/ARCHITECTURE.md +26 -18
  3. package/extension/background.js +259 -84
  4. package/extension/content-script.css +7 -13
  5. package/extension/content-script.js +252 -477
  6. package/extension/icons/icon-128.png +0 -0
  7. package/extension/icons/icon-16.png +0 -0
  8. package/extension/icons/icon-32.png +0 -0
  9. package/extension/icons/icon-48.png +0 -0
  10. package/extension/icons/mark.svg +6 -0
  11. package/extension/lib/brain-client.js +160 -73
  12. package/extension/lib/tokens.css +187 -0
  13. package/extension/lib/ui-helpers.js +229 -0
  14. package/extension/lib/ui-helpers.spec.mjs +94 -0
  15. package/extension/manifest.json +20 -5
  16. package/extension/options/options.css +64 -218
  17. package/extension/options/options.html +91 -43
  18. package/extension/options/options.js +208 -85
  19. package/extension/sidepanel/sidepanel.css +268 -970
  20. package/extension/sidepanel/sidepanel.html +200 -166
  21. package/extension/sidepanel/sidepanel.js +788 -942
  22. package/frontend/dist/assets/index-DGMNZi_1.js +5742 -0
  23. package/frontend/dist/index.html +1 -1
  24. package/frontend/src/api/plugins.ts +124 -92
  25. package/frontend/src/components/ResearchAgendaTab.spec.tsx +11 -0
  26. package/frontend/src/components/ResearchAgendaTab.tsx +19 -14
  27. package/frontend/src/pages/PluginsPage.spec.tsx +87 -16
  28. package/frontend/src/pages/PluginsPage.tsx +454 -1218
  29. package/package.json +2 -1
  30. package/plugins/code-quality/README.md +25 -0
  31. package/plugins/code-quality/cli.mjs +25 -0
  32. package/plugins/code-quality/plugin.json +17 -0
  33. package/plugins/git-sentinel/README.md +27 -0
  34. package/{scaffold/.agent/plugins → plugins}/git-sentinel/cli.mjs +8 -5
  35. package/plugins/git-sentinel/plugin.json +18 -0
  36. package/plugins/system-monitor/README.md +26 -0
  37. package/{scaffold/.agent/plugins → plugins}/system-monitor/cli.mjs +5 -4
  38. package/{scaffold/.agent/plugins → plugins}/system-monitor/generator.mjs +4 -5
  39. package/plugins/system-monitor/plugin.json +29 -0
  40. package/scaffold/.agent/skills/meta-harness/SKILL.md +4 -4
  41. package/scaffold/.agent/skills/plugins/SKILL.md +53 -0
  42. package/scaffold/.agent/skills/total-recall/scripts/brain-state.json +1 -0
  43. package/src/cli/agent.mjs +1 -1
  44. package/src/cli/app/index.mjs +493 -0
  45. package/src/cli/brain.mjs +33 -3
  46. package/src/cli/command.mjs +80 -41
  47. package/src/cli/harness.mjs +1 -1
  48. package/src/cli/init.mjs +3 -1
  49. package/src/cli/mesh.mjs +118 -12
  50. package/src/cli/plugin/create.mjs +144 -25
  51. package/src/cli/plugin/index.mjs +83 -53
  52. package/src/cli/plugin/install.mjs +10 -127
  53. package/src/cli/plugin/list.mjs +44 -44
  54. package/src/cli/plugin/remove.mjs +23 -30
  55. package/src/cli/plugin/scaffold-cmd.mjs +30 -0
  56. package/src/cli/plugin/search.mjs +108 -23
  57. package/src/cli/rebuild.mjs +37 -1
  58. package/src/cli/recall.mjs +17 -2
  59. package/src/cli/research.mjs +4 -6
  60. package/src/cli/share.mjs +2 -0
  61. package/src/core/app-deploy/apply.mjs +386 -0
  62. package/src/core/app-deploy/design-tokens.mjs +217 -0
  63. package/src/core/app-deploy/plan.mjs +249 -0
  64. package/src/core/app-deploy/resolve.mjs +295 -0
  65. package/src/core/app-deploy/source.mjs +337 -0
  66. package/src/core/app-deploy/standalone.mjs +248 -0
  67. package/src/core/app-deploy/upgrade.mjs +256 -0
  68. package/src/core/app-deploy/verify.mjs +213 -0
  69. package/src/core/bound-hosts.mjs +1 -1
  70. package/src/core/brain-registry.mjs +146 -0
  71. package/src/core/brain-state.json +1 -0
  72. package/src/core/daemon-loop.mjs +30 -26
  73. package/src/core/dream.mjs +5 -17
  74. package/src/core/fact-seeker.mjs +53 -368
  75. package/src/core/lan-discovery.mjs +3 -3
  76. package/src/core/mesh-access-sync.mjs +134 -0
  77. package/src/core/mesh-access.mjs +18 -3
  78. package/src/core/mesh-discover.mjs +173 -0
  79. package/src/core/mesh.mjs +131 -32
  80. package/src/core/optimizer.mjs +4 -41
  81. package/src/core/plugin-bundle.mjs +167 -0
  82. package/src/core/plugin-context.mjs +8 -13
  83. package/src/core/plugin-loader.mjs +272 -68
  84. package/src/core/plugin-peers.mjs +163 -0
  85. package/src/core/plugin-public.mjs +85 -0
  86. package/src/core/plugin-runner-child.mjs +24 -0
  87. package/src/core/plugin-runner.mjs +102 -0
  88. package/src/core/plugin-store.mjs +384 -0
  89. package/src/core/plugin-tasks.mjs +212 -0
  90. package/src/core/post-mortem.mjs +40 -9
  91. package/src/core/research-gate.mjs +182 -0
  92. package/src/core/research-queue.mjs +47 -7
  93. package/src/core/research-surface.mjs +211 -0
  94. package/src/core/research.mjs +25 -32
  95. package/src/core/scheduler.mjs +67 -153
  96. package/src/core/schema.mjs +129 -0
  97. package/src/core/search.mjs +12 -1
  98. package/src/core/secret-integration-research.mjs +12 -8
  99. package/src/core/secrets-store.mjs +32 -2
  100. package/src/core/session-watcher.mjs +6 -0
  101. package/src/core/surface.mjs +64 -3
  102. package/src/core/task-executors.mjs +8 -45
  103. package/src/core/vault-cache.mjs +70 -4
  104. package/src/server/api.mjs +21 -0
  105. package/src/server/rest.mjs +2 -0
  106. package/src/server/route-manifest.json +21 -5
  107. package/src/server/routes/collab.mjs +38 -11
  108. package/src/server/routes/extension.mjs +3 -2
  109. package/src/server/routes/memory.mjs +24 -2
  110. package/src/server/routes/mesh.mjs +7 -7
  111. package/src/server/routes/plugins-mesh.mjs +49 -0
  112. package/src/server/routes/plugins.mjs +91 -426
  113. package/src/server/routes/research.mjs +5 -2
  114. package/src/server/routes/share.mjs +2 -0
  115. package/src/server/tools.mjs +29 -2
  116. package/extension/icons/README.md +0 -27
  117. package/extension/lib/preconfigured.js +0 -5
  118. package/extension/popup/popup.css +0 -227
  119. package/extension/popup/popup.html +0 -50
  120. package/extension/popup/popup.js +0 -175
  121. package/frontend/dist/assets/index-U7o-qU99.js +0 -5742
  122. package/scaffold/.agent/plugins/.gitkeep +0 -0
  123. package/scaffold/.agent/plugins/code-quality/README.md +0 -16
  124. package/scaffold/.agent/plugins/code-quality/cli.mjs +0 -15
  125. package/scaffold/.agent/plugins/code-quality/plugin.json +0 -34
  126. package/scaffold/.agent/plugins/git-sentinel/README.md +0 -16
  127. package/scaffold/.agent/plugins/git-sentinel/plugin.json +0 -34
  128. package/scaffold/.agent/plugins/meta-harness/README.md +0 -16
  129. package/scaffold/.agent/plugins/meta-harness/cli.mjs +0 -34
  130. package/scaffold/.agent/plugins/meta-harness/plugin.json +0 -34
  131. package/scaffold/.agent/plugins/system-monitor/README.md +0 -16
  132. package/scaffold/.agent/plugins/system-monitor/plugin.json +0 -38
@@ -1,1106 +1,952 @@
1
- // Total Recall — Side Panel Logic
1
+ // Total Recall — side panel
2
2
 
3
3
  (function () {
4
4
  'use strict';
5
5
 
6
- // ---- DOM refs ----
7
- const dot = document.getElementById('connection-dot');
8
- const activeBrainBadge = document.getElementById('active-brain-badge');
9
- const searchInput = document.getElementById('search-input');
10
- const memoriesList = document.getElementById('memories-list');
11
- const btnRemember = document.getElementById('btn-remember');
12
- const btnResearch = document.getElementById('btn-research');
13
- const btnNote = document.getElementById('btn-note');
14
- const noteArea = document.getElementById('note-area');
15
- const noteInput = document.getElementById('note-input');
16
- const btnSaveNote = document.getElementById('btn-save-note');
17
- const btnCancelNote = document.getElementById('btn-cancel-note');
18
- const researchList = document.getElementById('research-list');
19
- const btnRefreshResearch = document.getElementById('btn-refresh-research');
20
-
21
- // Chat tab DOM refs
22
- const chatMessages = document.getElementById('chat-messages');
23
- const chatInput = document.getElementById('chat-input');
24
- const btnSendChat = document.getElementById('btn-send-chat');
25
- const chatGroundingToggle = document.getElementById('chat-grounding-toggle');
26
- const btnSuggestSummarize = document.getElementById('btn-suggest-summarize');
27
- const btnSuggestRelated = document.getElementById('btn-suggest-related');
28
-
29
- // Settings tab DOM refs
30
- const settingsBrainSelector = document.getElementById('settings-brain-selector');
31
- const settingsTrackingToggle = document.getElementById('settings-tracking-toggle');
32
- const btnBlockDomain = document.getElementById('btn-block-domain');
33
- const btnRecompileBrain = document.getElementById('btn-recompile-brain');
34
-
35
- // State variables
36
- let chatHistory = [];
37
- let activeBrainId = 'global';
38
-
39
- // ---- Tab Switching ----
40
- const tabButtons = document.querySelectorAll('.tab-btn');
41
- const tabPanels = document.querySelectorAll('.tab-panel');
42
-
43
- tabButtons.forEach(btn => {
44
- btn.addEventListener('click', () => {
45
- const targetTab = btn.getAttribute('data-tab');
46
-
47
- // Update active states in buttons
48
- tabButtons.forEach(b => b.classList.remove('active'));
49
- btn.classList.add('active');
50
-
51
- // Update active panel
52
- tabPanels.forEach(panel => {
53
- if (panel.id === `tab-${targetTab}`) {
54
- panel.classList.add('active');
55
- } else {
56
- panel.classList.remove('active');
57
- }
58
- });
6
+ const Brain = self.BrainClient;
7
+ const UI = self.TRUi;
8
+ const $ = (id) => document.getElementById(id);
59
9
 
60
- // On-demand panel loading triggers
61
- if (targetTab === 'memories') {
62
- if (!searchInput.value.trim()) {
63
- loadRecentCaptures();
64
- }
65
- } else if (targetTab === 'research') {
66
- fetchResearch();
67
- } else if (targetTab === 'settings') {
68
- fetchBrains();
69
- }
70
- });
71
- });
10
+ const TABS = ['recall', 'chat', 'research'];
11
+ const CHAT_HISTORY_LIMIT = 40;
12
+ const CHAT_CONTEXT_TURNS = 12;
13
+ const PAGE_TEXT_FOR_CHAT = 6000;
72
14
 
73
- // ---- Connection check ----
74
- async function checkConnection() {
75
- try {
76
- await self.BrainClient.healthCheck();
77
- dot.className = 'dot connected';
78
- } catch {
79
- dot.className = 'dot disconnected';
80
- }
81
- }
15
+ const state = {
16
+ tab: 'recall',
17
+ page: null, // chrome.tabs.Tab for the active tab
18
+ activeBrainId: 'global',
19
+ brains: [],
20
+ connection: 'checking',
21
+ chat: [],
22
+ chatBusy: false,
23
+ research: { items: [], counts: {} },
24
+ settings: { pageRecall: false, showPill: true, blocklist: [] },
25
+ health: null,
26
+ };
82
27
 
83
- // ---- Debounced search ----
84
- let searchTimer = null;
28
+ // ---------------------------------------------------------------------------
29
+ // Small utilities
30
+ // ---------------------------------------------------------------------------
85
31
 
86
- function debounceSearch(query) {
87
- clearTimeout(searchTimer);
88
- if (!query.trim()) {
89
- loadRecentCaptures();
90
- return;
91
- }
92
- searchTimer = setTimeout(() => {
93
- performSearch(query.trim());
94
- }, 300);
32
+ function svg(name, cls = 'icon sm') {
33
+ const el = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
34
+ el.setAttribute('class', cls);
35
+ el.setAttribute('aria-hidden', 'true');
36
+ const use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
37
+ use.setAttribute('href', `#i-${name}`);
38
+ el.appendChild(use);
39
+ return el;
95
40
  }
96
41
 
97
- async function performSearch(query) {
98
- memoriesList.innerHTML = '<div class="memories-empty">Searching…</div>';
99
- try {
100
- const response = await new Promise((resolve, reject) => {
101
- chrome.runtime.sendMessage({ type: 'QUERY_BRAIN', query, topK: 10, brainId: activeBrainId }, (res) => {
102
- if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message));
103
- resolve(res);
104
- });
105
- });
106
- renderMemories(response.memories || [], false);
107
- } catch (err) {
108
- memoriesList.innerHTML = `<div class="memories-empty">Error: ${err.message}</div>`;
109
- }
42
+ function el(tag, className, text) {
43
+ const node = document.createElement(tag);
44
+ if (className) node.className = className;
45
+ if (text !== undefined && text !== null) node.textContent = text;
46
+ return node;
110
47
  }
111
48
 
112
- // ---- Recent Captures ----
113
- async function loadRecentCaptures() {
114
- memoriesList.innerHTML = '<div class="memories-empty">Loading recent captures…</div>';
115
- try {
116
- let url = '/api/memory';
117
- if (activeBrainId && activeBrainId !== 'global') {
118
- url = `/api/brains/${activeBrainId}/nodes`;
119
- }
120
- const response = await self.BrainClient.brainFetch(url);
121
- const nodes = Array.isArray(response) ? response : (response.nodes || []);
122
-
123
- // Sort by creation or update timestamp descending
124
- const sorted = nodes.sort((a, b) => new Date(b.created || b.created_at || 0) - new Date(a.created || a.created_at || 0));
125
- const recent = sorted.slice(0, 5);
126
-
127
- if (!recent.length) {
128
- memoriesList.innerHTML = '<div class="memories-empty">No memories in this brain context yet. Capture pages to see them here!</div>';
129
- return;
130
- }
131
- renderMemories(recent, true);
132
- } catch (err) {
133
- memoriesList.innerHTML = `<div class="memories-empty">Could not load recent captures: ${err.message}</div>`;
134
- }
49
+ function setBusy(button, busy) {
50
+ button.disabled = busy;
51
+ button.classList.toggle('is-busy', busy);
135
52
  }
136
53
 
137
- // ---- Render memories ----
138
- function renderMemories(memories, isRecent = false) {
139
- if (!memories.length) {
140
- memoriesList.innerHTML = '<div class="memories-empty">No memories found</div>';
141
- return;
142
- }
143
- const headerHtml = isRecent ? '<h4 style="font-size: 11px; text-transform: uppercase; color: #585b70; letter-spacing: 0.5px; margin: 4px 0 10px 0;">Recent Captures</h4>' : '';
144
-
145
- memoriesList.innerHTML = headerHtml + memories.map((m) => {
146
- const title = m.title || m.slug || 'Untitled';
147
- const excerpt = m.content || m.excerpt || m.body || '';
148
- const category = m.category || 'memory';
149
- const time = m.created || m.created_at ? formatTime(m.created || m.created_at) : '';
150
- return `
151
- <div class="memory-card" data-slug="${m.slug}">
152
- <div class="card-header">
153
- <span class="card-title">${escapeHtml(title)}</span>
154
- <span class="card-badge">${escapeHtml(category)}</span>
155
- </div>
156
- <div class="card-excerpt">${escapeHtml(excerpt)}</div>
157
- ${time ? `<div class="card-time">${time}</div>` : ''}
158
- </div>
159
- `;
160
- }).join('');
161
-
162
- // Bind cards to open remote memory explorer
163
- document.querySelectorAll('.memory-card').forEach(card => {
164
- card.addEventListener('click', async () => {
165
- const slug = card.getAttribute('data-slug');
166
- const config = await self.BrainClient.getConfig();
167
- chrome.tabs.create({ url: `${config.brainUrl}/memory?slug=${slug}` });
168
- });
169
- });
54
+ let toastTimer = null;
55
+ function toast(message, isError = false) {
56
+ const t = $('toast');
57
+ t.textContent = message;
58
+ t.classList.toggle('error', isError);
59
+ t.hidden = false;
60
+ t.style.animation = 'none';
61
+ void t.offsetWidth;
62
+ t.style.animation = '';
63
+ clearTimeout(toastTimer);
64
+ toastTimer = setTimeout(() => { t.hidden = true; }, isError ? 4200 : 2600);
170
65
  }
171
66
 
172
- // ---- Quick actions ----
173
- async function getCurrentTab() {
174
- const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
175
- return tab;
67
+ function describeError(err) {
68
+ return (err && err.message) || 'Something went wrong.';
176
69
  }
177
70
 
178
- btnRemember.addEventListener('click', async () => {
179
- if (btnRemember.disabled) return;
180
- const originalText = btnRemember.innerHTML;
181
- btnRemember.innerHTML = '⏳ Remembering...';
182
- btnRemember.disabled = true;
183
- btnRemember.classList.add('loading');
184
- try {
185
- const tab = await getCurrentTab();
186
- if (!tab || !tab.url) {
187
- throw new Error('No active webpage found to capture. Make sure you are on a webpage tab.');
188
- }
189
- if (tab.url.startsWith('chrome://') || tab.url.startsWith('about:') || tab.url.startsWith('chrome-extension://')) {
190
- throw new Error('Cannot capture system pages.');
191
- }
192
- await sendShare({
193
- url: tab.url,
194
- title: tab.title || 'Untitled Page',
195
- action: 'remember',
196
- source: 'chrome-extension-sidepanel'
197
- });
198
- showToast('Page remembered!');
199
- loadRecentCaptures();
200
- } catch (err) {
201
- showToast('Error: ' + err.message, true);
202
- } finally {
203
- btnRemember.innerHTML = originalText;
204
- btnRemember.disabled = false;
205
- btnRemember.classList.remove('loading');
206
- }
207
- });
71
+ function isAuthOrOffline(err) {
72
+ return err && ['offline', 'unauthorized', 'unconfigured', 'timeout'].includes(err.code);
73
+ }
208
74
 
209
- btnResearch.addEventListener('click', async () => {
210
- if (btnResearch.disabled) return;
211
- const originalText = btnResearch.innerHTML;
212
- btnResearch.innerHTML = '⏳ Researching...';
213
- btnResearch.disabled = true;
214
- btnResearch.classList.add('loading');
215
- try {
216
- const tab = await getCurrentTab();
217
- if (!tab || !tab.url) {
218
- throw new Error('No active webpage found to capture. Make sure you are on a webpage tab.');
219
- }
220
- if (tab.url.startsWith('chrome://') || tab.url.startsWith('about:') || tab.url.startsWith('chrome-extension://')) {
221
- throw new Error('Cannot capture system pages.');
222
- }
223
- await sendShare({
224
- url: tab.url,
225
- title: tab.title || 'Untitled Page',
226
- action: 'research',
227
- source: 'chrome-extension-sidepanel'
228
- });
229
- showToast('Research queued!');
230
- } catch (err) {
231
- showToast('Error: ' + err.message, true);
232
- } finally {
233
- btnResearch.innerHTML = originalText;
234
- btnResearch.disabled = false;
235
- btnResearch.classList.remove('loading');
236
- }
237
- });
75
+ async function openMemory(slug) {
76
+ const url = await Brain.dashboardUrl(`/memory?slug=${encodeURIComponent(slug)}`);
77
+ chrome.tabs.create({ url });
78
+ }
238
79
 
239
- // ---- Quick Note ----
240
- btnNote.addEventListener('click', () => {
241
- noteArea.classList.toggle('hidden');
242
- if (!noteArea.classList.contains('hidden')) {
243
- noteInput.focus();
244
- }
245
- });
80
+ function skeleton(container, rows = 3) {
81
+ container.replaceChildren(...Array.from({ length: rows }, () => el('div', 'skeleton skeleton-row')));
82
+ }
246
83
 
247
- btnCancelNote.addEventListener('click', () => {
248
- noteArea.classList.add('hidden');
249
- noteInput.value = '';
250
- });
84
+ // ---------------------------------------------------------------------------
85
+ // Connection
86
+ // ---------------------------------------------------------------------------
251
87
 
252
- btnSaveNote.addEventListener('click', async () => {
253
- const text = noteInput.value.trim();
254
- if (!text || btnSaveNote.disabled) return;
255
- const originalText = btnSaveNote.innerHTML;
256
- btnSaveNote.innerHTML = '⏳ Saving...';
257
- btnSaveNote.disabled = true;
258
- btnSaveNote.classList.add('loading');
259
- try {
260
- let tab = null;
261
- try {
262
- tab = await getCurrentTab();
263
- } catch {
264
- // ignore
265
- }
266
- const hasValidUrl = tab && tab.url && !tab.url.startsWith('chrome://') && !tab.url.startsWith('about:') && !tab.url.startsWith('chrome-extension://');
267
- await sendShare({
268
- url: hasValidUrl ? tab.url : undefined,
269
- title: hasValidUrl ? `Note: ${text.slice(0, 50)}` : undefined,
270
- excerpt: text,
271
- action: 'remember',
272
- category: 'facts',
273
- source: 'chrome-extension-sidepanel'
274
- });
275
- noteInput.value = '';
276
- noteArea.classList.add('hidden');
277
- showToast('Note saved!');
278
- loadRecentCaptures();
279
- } catch (err) {
280
- showToast('Error: ' + err.message, true);
281
- } finally {
282
- btnSaveNote.innerHTML = originalText;
283
- btnSaveNote.disabled = false;
284
- btnSaveNote.classList.remove('loading');
285
- }
286
- });
88
+ const GATE_COPY = {
89
+ offline: ['Brain unreachable', 'Total Recall isn’t answering at the configured address. Make sure it’s running, or change the address in connection settings.'],
90
+ timeout: ['Brain unreachable', 'The brain took too long to answer. It may be busy; retry in a moment.'],
91
+ unconfigured: ['Connect your brain', 'Add an access token so the extension can read and write your memories.'],
92
+ unauthorized: ['Access token rejected', 'The brain didn’t accept the saved token. It may have been revoked or expired; paste a new one in connection settings.'],
93
+ };
287
94
 
288
- // ---- Share helper ----
289
- function sendShare(data) {
290
- return new Promise((resolve, reject) => {
291
- chrome.runtime.sendMessage({ type: 'SHARE', data }, (res) => {
292
- if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message));
293
- if (res && res.success) return resolve(res);
294
- reject(new Error(res?.error || 'Unknown error'));
295
- });
296
- });
95
+ function setStatusDot(kind, title) {
96
+ const dot = $('status-dot');
97
+ dot.className = `status-dot ${kind}`;
98
+ $('brain-chip').title = title || 'Active brain — change in settings';
297
99
  }
298
100
 
299
- // ==================== INTERACTIVE AI CHAT ====================
300
-
301
- function addChatBubble(sender, text, isSystem = false) {
302
- const bubble = document.createElement('div');
303
- if (isSystem) {
304
- bubble.className = 'chat-bubble system';
305
- } else {
306
- bubble.className = `chat-bubble ${sender === 'user' ? 'user' : 'assistant'}`;
307
- }
308
- bubble.textContent = text;
309
- chatMessages.appendChild(bubble);
310
- chatMessages.scrollTop = chatMessages.scrollHeight;
101
+ function showGate(err) {
102
+ const [title, message] = GATE_COPY[err.code] || GATE_COPY.offline;
103
+ $('gate-title').textContent = title;
104
+ $('gate-message').textContent = message;
105
+ $('gate').hidden = false;
106
+ $('app').classList.add('is-gated');
107
+ setStatusDot('down', `${title}. ${err.message || ''}`.trim());
311
108
  }
312
109
 
313
- async function getActivePageContext() {
314
- try {
315
- const tab = await getCurrentTab();
316
- if (!tab || !tab.id) return null;
317
-
318
- // Ask the content script on the tab to give us the selection + main inner text
319
- const pageInfo = await new Promise((resolve) => {
320
- chrome.tabs.sendMessage(tab.id, { type: 'GET_PAGE_TEXT' }, (response) => {
321
- if (chrome.runtime.lastError || !response) {
322
- resolve({ url: tab.url, title: tab.title, selection: '', pageText: '' });
323
- } else {
324
- resolve(response);
325
- }
326
- });
327
- });
328
- return pageInfo;
329
- } catch {
330
- return null;
331
- }
110
+ function hideGate() {
111
+ $('gate').hidden = true;
112
+ $('app').classList.remove('is-gated');
332
113
  }
333
114
 
334
- async function sendChatMessage(rawText) {
335
- const text = rawText.trim();
336
- if (!text || btnSendChat.disabled) return;
337
-
338
- addChatBubble('user', text);
339
- chatInput.value = '';
340
-
341
- // Disable inputs during processing
342
- btnSendChat.disabled = true;
343
- chatInput.disabled = true;
344
-
345
- // Append user message to history
346
- chatHistory.push({ role: 'user', content: text });
347
-
348
- // Show a small thinking loader
349
- const thinkingBubble = document.createElement('div');
350
- thinkingBubble.className = 'chat-bubble assistant thinking';
351
- thinkingBubble.textContent = '⏳ Thinking...';
352
- chatMessages.appendChild(thinkingBubble);
353
- chatMessages.scrollTop = chatMessages.scrollHeight;
354
-
115
+ async function checkConnection({ quiet = false } = {}) {
116
+ if (!quiet) setStatusDot('checking');
355
117
  try {
356
- let finalMessages = [...chatHistory];
357
- const isGroundingEnabled = chatGroundingToggle.checked;
358
-
359
- if (isGroundingEnabled) {
360
- const context = await getActivePageContext();
361
- if (context) {
362
- const selectionPart = context.selection ? `\n[User Selected text:\n"${context.selection}"]` : '';
363
- const bodyTextPart = context.pageText ? `\n[Webpage Inner Text:\n${context.pageText.slice(0, 4000)}]` : ''; // Limit to 4k chars context
364
-
365
- const systemContextMessage = {
366
- role: 'system',
367
- content: `You are a sovereign web assistant for Total Recall. The user is actively viewing a web page:
368
- URL: ${context.url}
369
- Title: ${context.title}${selectionPart}${bodyTextPart}
370
- Provide helpful analysis grounded on both the user's brain memory and this page content.`
371
- };
372
- // Prepend system instruction for the completions endpoint
373
- finalMessages.unshift(systemContextMessage);
374
- }
375
- }
376
-
377
- const completions = await self.BrainClient.chat(finalMessages, {
378
- brainId: activeBrainId,
379
- model: 'gemini'
380
- });
381
-
382
- // Remove thinking loader
383
- thinkingBubble.remove();
384
-
385
- const reply = completions.choices?.[0]?.message?.content || '(empty response)';
386
- addChatBubble('assistant', reply);
387
-
388
- // Append assistant answer to local history
389
- chatHistory.push({ role: 'assistant', content: reply });
118
+ // /health is public; an authenticated read proves the token works too.
119
+ const [health] = await Promise.all([Brain.health(), Brain.request('/api/memory?limit=1')]);
120
+ state.health = health;
121
+ const wasGated = state.connection !== 'ok';
122
+ state.connection = 'ok';
123
+ hideGate();
124
+ setStatusDot(health && health.daemon && health.daemon !== 'running' ? 'warn' : 'ok',
125
+ `Connected · brain v${(health && health.version) || '?'}${health && health.daemon !== 'running' ? ' · daemon stopped' : ''}`);
126
+ renderSheetFooter();
127
+ return wasGated;
390
128
  } catch (err) {
391
- thinkingBubble.remove();
392
- addChatBubble('system', `Error: ${err.message}`, true);
393
- } finally {
394
- btnSendChat.disabled = false;
395
- chatInput.disabled = false;
396
- chatInput.focus();
129
+ if (isAuthOrOffline(err)) {
130
+ state.connection = err.code;
131
+ showGate(err);
132
+ } else {
133
+ // Reachable but something narrower failed (e.g. scope); keep the UI usable.
134
+ state.connection = 'ok';
135
+ hideGate();
136
+ setStatusDot('warn', err.message);
137
+ }
138
+ return false;
139
+ }
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Tabs
144
+ // ---------------------------------------------------------------------------
145
+
146
+ function switchTab(name, { focus = false } = {}) {
147
+ if (!TABS.includes(name)) return;
148
+ state.tab = name;
149
+ TABS.forEach((t, i) => {
150
+ const btn = $(`tab-btn-${t}`);
151
+ const active = t === name;
152
+ btn.classList.toggle('is-active', active);
153
+ btn.setAttribute('aria-selected', String(active));
154
+ btn.tabIndex = active ? 0 : -1;
155
+ const pane = $(`tab-${t}`);
156
+ pane.hidden = !active;
157
+ pane.classList.toggle('is-active', active);
158
+ if (active) document.querySelector('.tab-indicator').style.transform = `translateX(${i * 100}%)`;
159
+ });
160
+ if (focus) $(`tab-btn-${name}`).focus();
161
+ if (name === 'research') loadResearch();
162
+ if (name === 'chat') {
163
+ scrollChatToEnd();
164
+ setTimeout(() => $('chat-input').focus(), 50);
397
165
  }
166
+ chrome.storage.session.set({ lastTab: name }).catch(() => {});
398
167
  }
399
168
 
400
- btnSendChat.addEventListener('click', () => sendChatMessage(chatInput.value));
401
-
402
- chatInput.addEventListener('keydown', (e) => {
403
- if (e.key === 'Enter' && !e.shiftKey) {
404
- e.preventDefault();
405
- sendChatMessage(chatInput.value);
406
- }
407
- });
408
-
409
- // Suggestion click listeners
410
- btnSuggestSummarize.addEventListener('click', () => {
411
- chatGroundingToggle.checked = true;
412
- sendChatMessage('Please summarize the current webpage and highlight the key takeaways.');
413
- });
414
-
415
- btnSuggestRelated.addEventListener('click', () => {
416
- chatGroundingToggle.checked = true;
417
- sendChatMessage('Are there any related memories, rules, or invariants in my brain for this webpage context?');
169
+ document.querySelectorAll('.tab').forEach((btn) => {
170
+ btn.addEventListener('click', () => switchTab(btn.dataset.tab));
171
+ btn.addEventListener('keydown', (e) => {
172
+ const i = TABS.indexOf(btn.dataset.tab);
173
+ if (e.key === 'ArrowRight') switchTab(TABS[(i + 1) % TABS.length], { focus: true });
174
+ if (e.key === 'ArrowLeft') switchTab(TABS[(i + TABS.length - 1) % TABS.length], { focus: true });
175
+ });
418
176
  });
419
177
 
178
+ // ---------------------------------------------------------------------------
179
+ // Recall: current page
180
+ // ---------------------------------------------------------------------------
420
181
 
421
- // ==================== BACKGROUND RESEARCH ====================
182
+ let pageToken = 0;
422
183
 
423
- async function fetchResearch() {
184
+ async function refreshPage() {
185
+ const token = ++pageToken;
186
+ let tab = null;
424
187
  try {
425
- const data = await self.BrainClient.brainFetch('/api/research');
426
- const projects = data.projects || data.items || data.results || [];
427
- if (!projects.length) {
428
- researchList.innerHTML = '<div class="research-empty">No research projects found</div>';
429
- return;
188
+ [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
189
+ } catch { /* ignore */ }
190
+ if (token !== pageToken) return;
191
+ state.page = tab || null;
192
+ renderPageCard();
193
+ if (state.connection !== 'ok') return;
194
+ loadPageSavedState(token);
195
+ loadRelated(token);
196
+ }
197
+
198
+ function renderPageCard() {
199
+ const tab = state.page;
200
+ const capturable = tab && UI.isCapturableUrl(tab.url);
201
+ $('page-title').textContent = (tab && tab.title) || (tab ? tab.url : 'No active tab');
202
+ $('page-host').textContent = capturable ? UI.hostnameOf(tab.url) : (tab && tab.url ? tab.url.split(':')[0] + ' page' : '');
203
+ $('page-saved').hidden = true;
204
+ $('btn-remember').disabled = !capturable;
205
+ $('btn-research-page').disabled = !capturable;
206
+ $('page-disabled').hidden = !!capturable;
207
+
208
+ const fav = $('page-favicon');
209
+ fav.replaceChildren(svg('globe'));
210
+ if (tab && tab.favIconUrl && /^(https?:|data:image\/)/.test(tab.favIconUrl)) {
211
+ const img = new Image();
212
+ img.alt = '';
213
+ img.referrerPolicy = 'no-referrer';
214
+ img.onload = () => fav.replaceChildren(img);
215
+ img.src = tab.favIconUrl;
216
+ }
217
+
218
+ renderBlockButton();
219
+ }
220
+
221
+ async function loadPageSavedState(token) {
222
+ const tab = state.page;
223
+ if (!tab || !UI.isCapturableUrl(tab.url)) return;
224
+ try {
225
+ const matches = await Brain.findByUrl(tab.url);
226
+ if (token !== pageToken) return;
227
+ const newest = matches
228
+ .map((n) => Date.parse(n.created) || 0)
229
+ .sort((a, b) => b - a)[0];
230
+ if (matches.length) {
231
+ $('page-saved-label').textContent = newest ? `Saved ${UI.timeAgo(newest)}` : 'Saved';
232
+ $('page-saved').hidden = false;
430
233
  }
431
-
432
- // Filter out completed ones, show in_progress and pending at the top
433
- const sorted = projects.sort((a, b) => {
434
- if (a.status === 'in_progress' && b.status !== 'in_progress') return -1;
435
- if (a.status !== 'in_progress' && b.status === 'in_progress') return 1;
436
- return new Date(b.created_at || 0) - new Date(a.created_at || 0);
437
- });
234
+ } catch { /* non-critical */ }
235
+ }
438
236
 
439
- researchList.innerHTML = sorted.map((p) => {
440
- const isRunning = p.status === 'in_progress' || p.status === 'pending';
441
- const cancelBtnHtml = isRunning
442
- ? `<button class="steer-btn btn-cancel-res" data-id="${p.id}" title="Cancel Research">✕</button>`
443
- : '';
444
- const spinHtml = isRunning ? '<div class="spinner"></div>' : '📌';
445
- return `
446
- <div class="research-item">
447
- ${spinHtml}
448
- <span class="topic" title="${escapeHtml(p.topic || p.title || 'Untitled')}">${escapeHtml(p.topic || p.title || 'Untitled')}</span>
449
- <span class="status-badge" style="background: ${getStatusBg(p.status)}; color: ${getStatusColor(p.status)};">${escapeHtml(p.status || 'unknown')}</span>
450
- ${cancelBtnHtml}
451
- </div>
452
- `;
453
- }).join('');
454
-
455
- // Bind Cancel research triggers
456
- document.querySelectorAll('.btn-cancel-res').forEach(btn => {
457
- btn.addEventListener('click', async (e) => {
458
- e.stopPropagation();
459
- const id = btn.getAttribute('data-id');
460
- if (btn.disabled) return;
461
- btn.disabled = true;
462
- try {
463
- await self.BrainClient.brainFetch(`/api/research/${id}`, { method: 'DELETE' });
464
- showToast('Research cancelled.');
465
- fetchResearch();
466
- } catch (err) {
467
- showToast('Failed to cancel: ' + err.message, true);
468
- btn.disabled = false;
469
- }
470
- });
471
- });
237
+ async function loadRelated(token) {
238
+ const tab = state.page;
239
+ const section = $('related-section');
240
+ if (!tab || !UI.isCapturableUrl(tab.url) || !tab.title) {
241
+ section.hidden = true;
242
+ return;
243
+ }
244
+ try {
245
+ const results = await Brain.search(tab.title, { topK: 10 });
246
+ if (token !== pageToken) return;
247
+ const related = UI.filterRelated(results, { excludeUrl: tab.url, limit: 4 });
248
+ section.hidden = related.length === 0;
249
+ renderRows($('related-list'), related, { showSimilarity: true });
472
250
  } catch {
473
- researchList.innerHTML = '<div class="research-empty">Could not load research feed</div>';
251
+ if (token === pageToken) section.hidden = true;
474
252
  }
475
253
  }
476
254
 
477
- btnRefreshResearch.addEventListener('click', fetchResearch);
255
+ // ---------------------------------------------------------------------------
256
+ // Recall: rows, search, recent
257
+ // ---------------------------------------------------------------------------
258
+
259
+ function toRow(node) {
260
+ return {
261
+ slug: node.slug,
262
+ title: node.title || node.slug,
263
+ excerpt: node.excerpt !== undefined ? node.excerpt : UI.clip(UI.stripMarkdown(node.content || node.body || ''), 200),
264
+ category: node.category,
265
+ created: node.created,
266
+ similarity: node.similarity,
267
+ };
268
+ }
478
269
 
479
- function getStatusBg(status) {
480
- switch (status) {
481
- case 'in_progress': return 'rgba(137, 180, 250, 0.15)';
482
- case 'done': return 'rgba(166, 227, 161, 0.15)';
483
- case 'failed': return 'rgba(243, 139, 168, 0.15)';
484
- default: return 'rgba(108, 112, 134, 0.15)';
270
+ function renderRows(container, items, { showSimilarity = false, empty } = {}) {
271
+ if (!items.length) {
272
+ container.replaceChildren(empty ? el('div', 'empty', empty) : document.createDocumentFragment());
273
+ return;
485
274
  }
275
+ container.replaceChildren(...items.map((item) => {
276
+ const row = el('button', 'row');
277
+ row.type = 'button';
278
+ row.title = 'Open in dashboard';
279
+ row.append(el('span', 'row-title', item.title));
280
+ if (item.excerpt) row.append(el('span', 'row-excerpt', item.excerpt));
281
+ const meta = el('span', 'row-meta');
282
+ if (item.category) meta.append(el('span', 'cat', String(item.category).replace(/[-_]/g, ' ')));
283
+ const time = item.created ? UI.timeAgo(item.created) : '';
284
+ if (time) { meta.append(el('span', 'sep')); meta.append(el('span', '', time)); }
285
+ if (showSimilarity && typeof item.similarity === 'number') {
286
+ meta.append(el('span', 'sep'));
287
+ meta.append(el('span', '', `${Math.round(item.similarity * 100)}% similar`));
288
+ }
289
+ row.append(meta);
290
+ row.addEventListener('click', () => openMemory(item.slug));
291
+ return row;
292
+ }));
486
293
  }
487
294
 
488
- function getStatusColor(status) {
489
- switch (status) {
490
- case 'in_progress': return '#89b4fa';
491
- case 'done': return '#a6e3a1';
492
- case 'failed': return '#f38ba8';
493
- default: return '#bac2de';
295
+ async function loadRecent() {
296
+ const list = $('recent-list');
297
+ if (!list.childElementCount) skeleton(list, 3);
298
+ try {
299
+ const nodes = await Brain.listRecent(6);
300
+ renderRows(list, nodes.map(toRow), { empty: 'Nothing here yet. Remember a page or save a note and it shows up here.' });
301
+ } catch (err) {
302
+ if (isAuthOrOffline(err)) return checkConnection();
303
+ list.replaceChildren(el('div', 'error-note', `Couldn’t load recent memories: ${describeError(err)}`));
494
304
  }
495
305
  }
496
306
 
307
+ let searchTimer = null;
308
+ let searchToken = 0;
497
309
 
498
- // ==================== SETTINGS & ACTIONS ====================
310
+ function onSearchInput() {
311
+ const query = $('search-input').value.trim();
312
+ clearTimeout(searchTimer);
313
+ if (!query) {
314
+ searchToken++;
315
+ $('search-results').hidden = true;
316
+ $('browse').hidden = false;
317
+ return;
318
+ }
319
+ $('search-results').hidden = false;
320
+ $('browse').hidden = true;
321
+ $('search-heading').textContent = 'Searching…';
322
+ skeleton($('search-list'), 3);
323
+ searchTimer = setTimeout(() => runSearch(query), 250);
324
+ }
499
325
 
500
- async function fetchBrains() {
326
+ async function runSearch(query) {
327
+ const token = ++searchToken;
501
328
  try {
502
- const data = await self.BrainClient.brainFetch('/api/brains');
503
- const brains = data.brains || [];
504
-
505
- settingsBrainSelector.innerHTML = '<option value="global">Global Brain Layer</option>' +
506
- brains.filter(b => b.id !== 'global').map(b => `
507
- <option value="${b.id}">${escapeHtml(b.name || b.id)} (Project)</option>
508
- `).join('');
509
-
510
- // Restore active dropdown selection
511
- settingsBrainSelector.value = activeBrainId;
512
- } catch {
513
- // Fallback if brains call fails
514
- settingsBrainSelector.innerHTML = '<option value="global">Global Brain Layer</option>';
329
+ const results = await Brain.search(query, { topK: 12 });
330
+ if (token !== searchToken) return;
331
+ const rows = results.filter((r) => r.type !== 'session' && r.slug).map(toRow);
332
+ $('search-heading').textContent = rows.length ? `${rows.length} result${rows.length === 1 ? '' : 's'}` : 'Results';
333
+ renderRows($('search-list'), rows, { showSimilarity: true, empty: `No memories match “${query}”.` });
334
+ } catch (err) {
335
+ if (token !== searchToken) return;
336
+ $('search-heading').textContent = 'Results';
337
+ $('search-list').replaceChildren(el('div', 'error-note', describeError(err)));
515
338
  }
516
339
  }
517
340
 
518
- settingsBrainSelector.addEventListener('change', async (e) => {
519
- const val = e.target.value;
520
- activeBrainId = val;
521
- await chrome.storage.sync.set({ activeBrainId: val });
522
-
523
- // Update badge in header
524
- activeBrainBadge.textContent = val === 'global' ? 'Global' : 'Project';
525
- activeBrainBadge.style.color = val === 'global' ? '#cba6f7' : '#89b4fa';
526
-
527
- showToast(`Active brain switched to ${val === 'global' ? 'Global' : 'Project'}`);
528
-
529
- // Clear chat history on brain layer swap to prevent context leakage
530
- chatHistory = [];
531
- chatMessages.innerHTML = `
532
- <div class="chat-bubble assistant">
533
- Switched active brain context to: <b>${val === 'global' ? 'Global' : 'Project'}</b>. Conversation history has been reset.
534
- </div>
535
- `;
536
-
537
- // Refresh memory feed
538
- if (!searchInput.value.trim()) {
539
- loadRecentCaptures();
540
- } else {
541
- performSearch(searchInput.value);
542
- }
341
+ $('search-input').addEventListener('input', onSearchInput);
342
+ $('search-input').addEventListener('keydown', (e) => {
343
+ if (e.key === 'Escape') { $('search-input').value = ''; onSearchInput(); }
543
344
  });
345
+ $('btn-refresh-recent').addEventListener('click', loadRecent);
544
346
 
545
- async function loadSettingsState() {
546
- const state = await chrome.storage.sync.get({
547
- activeBrainId: 'global',
548
- passiveTracking: false
347
+ // ---------------------------------------------------------------------------
348
+ // Capture
349
+ // ---------------------------------------------------------------------------
350
+
351
+ function sendCapture(options) {
352
+ return new Promise((resolve, reject) => {
353
+ chrome.runtime.sendMessage({ type: 'CAPTURE', tabId: state.page && state.page.id, options }, (res) => {
354
+ if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message));
355
+ if (res && res.success) return resolve(res.result);
356
+ const err = new Error((res && res.error) || 'Capture failed');
357
+ err.code = res && res.code;
358
+ reject(err);
359
+ });
549
360
  });
550
-
551
- activeBrainId = state.activeBrainId;
552
- activeBrainBadge.textContent = activeBrainId === 'global' ? 'Global' : 'Project';
553
- activeBrainBadge.style.color = activeBrainId === 'global' ? '#cba6f7' : '#89b4fa';
554
-
555
- settingsTrackingToggle.checked = state.passiveTracking;
556
361
  }
557
362
 
558
- settingsTrackingToggle.addEventListener('change', async () => {
559
- const passiveTracking = settingsTrackingToggle.checked;
560
- await chrome.storage.sync.set({ passiveTracking });
561
- showToast(`Passive tracking ${passiveTracking ? 'enabled' : 'disabled'}`);
562
- });
363
+ async function afterCapture(action) {
364
+ if (action === 'research') {
365
+ loadResearch();
366
+ return;
367
+ }
368
+ const tab = state.page;
369
+ if (tab && UI.isCapturableUrl(tab.url)) {
370
+ $('page-saved-label').textContent = 'Saved just now';
371
+ $('page-saved').hidden = false;
372
+ }
373
+ loadRecent();
374
+ }
563
375
 
564
- // Block Current Domain
565
- btnBlockDomain.addEventListener('click', async () => {
376
+ $('btn-remember').addEventListener('click', async () => {
377
+ const btn = $('btn-remember');
378
+ setBusy(btn, true);
566
379
  try {
567
- const tab = await getCurrentTab();
568
- if (!tab || !tab.url) return;
569
- const urlObj = new URL(tab.url);
570
- const host = urlObj.hostname;
571
-
572
- if (!host) {
573
- showToast('Cannot block empty domain', true);
574
- return;
575
- }
576
-
577
- const { blocklist } = await chrome.storage.sync.get({ blocklist: [] });
578
- if (blocklist.includes(host)) {
579
- showToast(`${host} is already blocked`);
580
- return;
581
- }
582
-
583
- blocklist.push(host);
584
- await chrome.storage.sync.set({ blocklist });
585
- showToast(`Blocked domain: ${host}`);
380
+ await sendCapture({ action: 'remember' });
381
+ toast('Saved to your brain');
382
+ afterCapture('remember');
586
383
  } catch (err) {
587
- showToast('Block error: ' + err.message, true);
384
+ toast(describeError(err), true);
385
+ } finally {
386
+ setBusy(btn, false);
588
387
  }
589
388
  });
590
389
 
591
- // Recompile Brain index shims
592
- btnRecompileBrain.addEventListener('click', async () => {
593
- if (btnRecompileBrain.disabled) return;
594
- const originalText = btnRecompileBrain.innerHTML;
595
- btnRecompileBrain.innerHTML = '⏳ Compiling...';
596
- btnRecompileBrain.disabled = true;
597
- btnRecompileBrain.classList.add('loading');
390
+ $('btn-research-page').addEventListener('click', async () => {
391
+ const btn = $('btn-research-page');
392
+ setBusy(btn, true);
598
393
  try {
599
- await self.BrainClient.brainFetch('/api/vault/compile', { method: 'POST' });
600
- showToast('Brain indexes recompiled!');
394
+ await sendCapture({ action: 'research' });
395
+ toast('Research queued');
396
+ afterCapture('research');
601
397
  } catch (err) {
602
- showToast('Compilation error: ' + err.message, true);
398
+ toast(describeError(err), true);
603
399
  } finally {
604
- btnRecompileBrain.innerHTML = originalText;
605
- btnRecompileBrain.disabled = false;
606
- btnRecompileBrain.classList.remove('loading');
400
+ setBusy(btn, false);
607
401
  }
608
402
  });
609
403
 
404
+ function toggleNote(open) {
405
+ const area = $('note-area');
406
+ area.hidden = !open;
407
+ $('btn-note').setAttribute('aria-expanded', String(open));
408
+ if (open) $('note-input').focus();
409
+ }
410
+
411
+ $('btn-note').addEventListener('click', () => toggleNote($('note-area').hidden));
412
+ $('btn-cancel-note').addEventListener('click', () => { $('note-input').value = ''; toggleNote(false); });
610
413
 
611
- // ---- Ingestion Invariant Sync ----
612
- // Autonomously capture correction in permanent memory
613
- async function recordRememberInvariants() {
414
+ async function saveNote() {
415
+ const note = $('note-input').value.trim();
416
+ const btn = $('btn-save-note');
417
+ if (!note || btn.disabled) return;
418
+ setBusy(btn, true);
614
419
  try {
615
- // Perform automated memory check locally using REST API to prevent duplicate records
616
- const response = await self.BrainClient.search("do not call it copilot", 1);
617
- const match = response.results && response.results[0];
618
- if (match && match.slug === 'never-use-copilot') return;
619
-
620
- // Upsert memory node autonomously using SSSS REST schema endpoints
621
- await self.BrainClient.share({
622
- slug: 'never-use-copilot',
623
- category: 'invariants',
624
- title: "Never refer to the chat interface or AI companion as 'Co-Pilot'. Always refer to it simply as 'Chat'.",
625
- status: 'active',
626
- importance: 5,
627
- modality: 'must_not',
628
- priority: 'absolute',
629
- action: 'remember',
630
- source: 'chrome-extension-sidepanel'
631
- });
632
- } catch {
633
- // Suppress silently if remote brain is offline during first-time handshake
420
+ const tab = state.page;
421
+ if (tab && UI.isCapturableUrl(tab.url)) {
422
+ await sendCapture({ action: 'remember', note, selection: '' });
423
+ } else {
424
+ await Brain.share({ excerpt: note, title: UI.clip(note.split('\n')[0], 80), action: 'remember', source: 'chrome-extension', tags: ['note'] });
425
+ }
426
+ $('note-input').value = '';
427
+ toggleNote(false);
428
+ toast('Note saved');
429
+ afterCapture('remember');
430
+ } catch (err) {
431
+ toast(describeError(err), true);
432
+ } finally {
433
+ setBusy(btn, false);
634
434
  }
635
435
  }
636
436
 
437
+ $('btn-save-note').addEventListener('click', saveNote);
438
+ $('note-input').addEventListener('keydown', (e) => {
439
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); saveNote(); }
440
+ if (e.key === 'Escape') toggleNote(false);
441
+ });
637
442
 
638
- // ---- Toast ----
639
- function showToast(message, isError = false) {
640
- let toast = document.querySelector('.toast');
641
- if (!toast) {
642
- toast = document.createElement('div');
643
- toast.className = 'toast';
644
- document.body.appendChild(toast);
645
- }
646
- toast.textContent = message;
647
- toast.classList.toggle('error', isError);
648
- // Force reflow to restart animation
649
- toast.classList.remove('show');
650
- void toast.offsetWidth;
651
- toast.classList.add('show');
652
- setTimeout(() => toast.classList.remove('show'), 2500);
653
- }
443
+ // ---------------------------------------------------------------------------
444
+ // Chat
445
+ // ---------------------------------------------------------------------------
654
446
 
655
- // ---- Helpers ----
656
- function escapeHtml(str) {
657
- if (!str) return '';
658
- const div = document.createElement('div');
659
- div.textContent = str;
660
- return div.innerHTML;
661
- }
447
+ const chatKey = () => `chat:${state.activeBrainId}`;
662
448
 
663
- function formatTime(dateStr) {
449
+ async function loadChat() {
664
450
  try {
665
- const d = new Date(dateStr);
666
- const now = new Date();
667
- const diff = now - d;
668
- if (diff < 60000) return 'just now';
669
- if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
670
- if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
671
- return d.toLocaleDateString();
451
+ const data = await chrome.storage.session.get(chatKey());
452
+ state.chat = data[chatKey()] || [];
672
453
  } catch {
673
- return '';
454
+ state.chat = [];
674
455
  }
456
+ renderChat();
457
+ }
458
+
459
+ function persistChat() {
460
+ chrome.storage.session.set({ [chatKey()]: state.chat.slice(-CHAT_HISTORY_LIMIT) }).catch(() => {});
675
461
  }
676
462
 
677
- // ==================== COLLABORATION MODULE ====================
678
- const collabAuthView = document.getElementById('collab-auth-view');
679
- const collabMainView = document.getElementById('collab-main-view');
680
- const collabUsernameInput = document.getElementById('collab-username');
681
- const collabPasswordInput = document.getElementById('collab-password');
682
- const btnCollabLogin = document.getElementById('btn-collab-login');
683
- const btnCollabRegister = document.getElementById('btn-collab-register');
684
-
685
- const collabUserDisplay = document.getElementById('collab-user-display');
686
- const btnCollabLogout = document.getElementById('btn-collab-logout');
687
- const collabGroupSelect = document.getElementById('collab-group-select');
688
-
689
- const btnCollabShowJoin = document.getElementById('btn-collab-show-join');
690
- const btnCollabShowCreate = document.getElementById('btn-collab-show-create');
691
- const collabJoinGroupRow = document.getElementById('collab-join-group-row');
692
- const collabJoinCode = document.getElementById('collab-join-code');
693
- const btnCollabSubmitJoin = document.getElementById('btn-collab-submit-join');
694
-
695
- const collabCreateGroupRow = document.getElementById('collab-create-group-row');
696
- const collabCreateName = document.getElementById('collab-create-name');
697
- const btnCollabSubmitCreate = document.getElementById('btn-collab-submit-create');
698
-
699
- const collabAnnotationsList = document.getElementById('collab-annotations-list');
700
- const collabNoteInput = document.getElementById('collab-note-input');
701
- const btnCollabSaveNote = document.getElementById('btn-collab-save-note');
702
-
703
- const collabPresenceCount = document.getElementById('collab-presence-count');
704
- const collabChatMessages = document.getElementById('collab-chat-messages');
705
- const collabChatInput = document.getElementById('collab-chat-input');
706
- const btnCollabSendChat = document.getElementById('btn-collab-send-chat');
707
-
708
- let collabToken = '';
709
- let collabUsername = '';
710
- let collabActiveUrl = '';
711
- let collabSocket = null;
712
- let collabGroups = [];
713
- let collabSelectedGroup = null;
714
-
715
- async function initCollab() {
716
- const data = await chrome.storage.local.get(['collabToken', 'collabUsername']);
717
- if (data.collabToken && data.collabUsername) {
718
- collabToken = data.collabToken;
719
- collabUsername = data.collabUsername;
720
- showCollabMain();
463
+ function scrollChatToEnd() {
464
+ const s = $('chat-scroll');
465
+ requestAnimationFrame(() => { s.scrollTop = s.scrollHeight; });
466
+ }
467
+
468
+ function renderMessage(m) {
469
+ const wrap = el('div', `msg ${m.role === 'user' ? 'user' : m.error ? 'assistant error' : 'assistant'}`);
470
+ const bubble = el('div', 'bubble');
471
+ if (m.role === 'user') {
472
+ bubble.textContent = m.content;
473
+ wrap.append(bubble);
474
+ if (m.context && m.context.title) {
475
+ const ctx = el('span', 'msg-context');
476
+ ctx.append(svg('globe'), el('span', '', UI.clip(m.context.title, 60)));
477
+ wrap.append(ctx);
478
+ }
479
+ } else if (m.error) {
480
+ bubble.textContent = m.content;
481
+ wrap.append(bubble);
721
482
  } else {
722
- showCollabAuth();
483
+ // renderMarkdown escapes all input before adding its whitelisted tags.
484
+ bubble.innerHTML = UI.renderMarkdown(m.content);
485
+ wrap.append(bubble);
486
+ if (m.model) wrap.append(el('span', 'msg-meta', m.model));
723
487
  }
488
+ return wrap;
724
489
  }
725
490
 
726
- function showCollabAuth() {
727
- collabAuthView.classList.remove('hidden');
728
- collabMainView.classList.add('hidden');
729
- closeCollabSocket();
491
+ function renderChat() {
492
+ const list = $('chat-messages');
493
+ list.replaceChildren(...state.chat.map(renderMessage));
494
+ if (state.chatBusy) {
495
+ const typing = el('div', 'msg assistant');
496
+ const dots = el('div', 'typing');
497
+ dots.append(el('span'), el('span'), el('span'));
498
+ typing.append(dots);
499
+ list.append(typing);
500
+ }
501
+ $('chat-empty').hidden = state.chat.length > 0 || state.chatBusy;
502
+ $('btn-clear-chat').hidden = state.chat.length === 0;
503
+ updateSendState();
504
+ scrollChatToEnd();
730
505
  }
731
506
 
732
- async function showCollabMain() {
733
- collabAuthView.classList.add('hidden');
734
- collabMainView.classList.remove('hidden');
735
- collabUserDisplay.textContent = collabUsername;
736
- await fetchCollabGroups();
737
- updateCollabActiveUrl();
507
+ function updateSendState() {
508
+ $('btn-send-chat').disabled = state.chatBusy || !$('chat-input').value.trim();
509
+ const capturable = state.page && UI.isCapturableUrl(state.page.url);
510
+ $('chat-grounding').disabled = !capturable;
738
511
  }
739
512
 
740
- async function fetchCollabGroups() {
741
- try {
742
- const config = await self.BrainClient.getConfig();
743
- const res = await fetch(`${config.brainUrl}/api/collab/groups`, {
744
- headers: { 'Authorization': `Bearer ${collabToken}` }
513
+ function getPageContext(tabId) {
514
+ return new Promise((resolve) => {
515
+ chrome.tabs.sendMessage(tabId, { type: 'GET_PAGE_CONTEXT' }, (res) => {
516
+ if (chrome.runtime.lastError) return resolve(null);
517
+ resolve(res || null);
745
518
  });
746
- if (res.ok) {
747
- collabGroups = await res.json();
748
- renderCollabGroupsDropdown();
749
- } else if (res.status === 401) {
750
- logoutCollab();
751
- }
752
- } catch (err) {
753
- console.error('Failed to fetch collab groups:', err);
754
- }
519
+ });
755
520
  }
756
521
 
757
- function renderCollabGroupsDropdown() {
758
- collabGroupSelect.innerHTML = collabGroups.map(g =>
759
- `<option value="${g.code}">${escapeHtml(g.name)} (${g.code})</option>`
760
- ).join('');
761
- if (collabGroups.length > 0) {
762
- const activeCode = collabGroupSelect.value;
763
- collabSelectedGroup = collabGroups.find(g => g.code === activeCode) || collabGroups[0];
764
- } else {
765
- collabSelectedGroup = null;
766
- }
767
- }
522
+ async function sendChat(text, { grounded = $('chat-grounding').checked, extraContext = '' } = {}) {
523
+ const content = text.trim();
524
+ if (!content || state.chatBusy) return;
525
+ const tab = state.page;
526
+ const useGrounding = grounded && tab && UI.isCapturableUrl(tab.url);
527
+
528
+ state.chat.push({ role: 'user', content, context: useGrounding ? { title: tab.title, url: tab.url } : null });
529
+ state.chatBusy = true;
530
+ $('chat-input').value = '';
531
+ autosize();
532
+ renderChat();
533
+ persistChat();
768
534
 
769
- async function updateCollabActiveUrl() {
770
535
  try {
771
- const tab = await getCurrentTab();
772
- if (tab && tab.url && !tab.url.startsWith('chrome://') && !tab.url.startsWith('about:') && !tab.url.startsWith('chrome-extension://')) {
773
- const cleanUrl = tab.url.split('#')[0];
774
- if (cleanUrl !== collabActiveUrl) {
775
- collabActiveUrl = cleanUrl;
776
- if (collabToken) {
777
- await fetchCollabAnnotations();
778
- connectCollabSocket();
779
- }
536
+ const messages = [];
537
+ if (useGrounding || extraContext) {
538
+ const ctx = useGrounding ? await getPageContext(tab.id) : null;
539
+ const lines = ['The user is asking from the Total Recall browser extension. Ground your answer in their memories first.'];
540
+ if (useGrounding) {
541
+ lines.push(`They are viewing: ${(ctx && ctx.title) || tab.title}\nURL: ${tab.url}`);
542
+ if (ctx && ctx.description) lines.push(`Description: ${ctx.description}`);
543
+ if (ctx && ctx.selection) lines.push(`Selected text:\n"""${UI.clip(ctx.selection, 2000)}"""`);
544
+ if (ctx && ctx.text) lines.push(`Page text (truncated):\n"""${UI.clip(ctx.text, PAGE_TEXT_FOR_CHAT)}"""`);
780
545
  }
781
- } else {
782
- collabActiveUrl = '';
783
- collabAnnotationsList.innerHTML = '<div class="memories-empty">No active web tab</div>';
784
- closeCollabSocket();
546
+ if (extraContext) lines.push(extraContext);
547
+ messages.push({ role: 'system', content: lines.join('\n\n') });
785
548
  }
786
- } catch (err) {
787
- console.error(err);
788
- }
789
- }
790
-
791
- async function fetchCollabAnnotations() {
792
- if (!collabActiveUrl || !collabToken) return;
793
- try {
794
- const config = await self.BrainClient.getConfig();
795
- const res = await fetch(`${config.brainUrl}/api/collab/annotations?url=${encodeURIComponent(collabActiveUrl)}`, {
796
- headers: { 'Authorization': `Bearer ${collabToken}` }
797
- });
798
- if (res.ok) {
799
- const annotations = await res.json();
800
- renderCollabAnnotations(annotations);
549
+ for (const m of state.chat.slice(-CHAT_CONTEXT_TURNS)) {
550
+ if (!m.error) messages.push({ role: m.role, content: m.content });
801
551
  }
552
+ const reply = await Brain.chat(messages);
553
+ state.chat.push({ role: 'assistant', content: reply.content || '(The brain returned an empty answer.)', model: reply.model });
802
554
  } catch (err) {
803
- console.error('Failed to fetch annotations:', err);
555
+ state.chat.push({ role: 'assistant', error: true, content: describeError(err) });
556
+ if (isAuthOrOffline(err)) checkConnection();
557
+ } finally {
558
+ state.chatBusy = false;
559
+ renderChat();
560
+ persistChat();
804
561
  }
805
562
  }
806
563
 
807
- function renderCollabAnnotations(list) {
808
- if (!list.length) {
809
- collabAnnotationsList.innerHTML = '<div class="memories-empty">No notes on this page</div>';
810
- return;
811
- }
812
- collabAnnotationsList.innerHTML = list.map(a => `
813
- <div class="collab-annotation-item">
814
- <div class="author">👤 ${escapeHtml(a.author)}</div>
815
- ${a.excerpt ? `<div class="excerpt">${escapeHtml(a.excerpt)}</div>` : ''}
816
- <div class="text">${escapeHtml(a.text)}</div>
817
- <div class="time">${formatTime(a.created_at)}</div>
818
- </div>
819
- `).join('');
564
+ function autosize() {
565
+ const ta = $('chat-input');
566
+ ta.style.height = 'auto';
567
+ ta.style.height = `${Math.min(ta.scrollHeight, 160)}px`;
820
568
  }
821
569
 
822
- function connectCollabSocket() {
823
- if (!collabToken || !collabActiveUrl) return;
824
- closeCollabSocket();
825
-
826
- self.BrainClient.getConfig().then(config => {
827
- const serverUrl = new URL(config.brainUrl);
828
- const wsProto = serverUrl.protocol === 'https:' ? 'wss:' : 'ws:';
829
- const wsUrl = `${wsProto}//${serverUrl.host}/collab-ws?token=${collabToken}`;
830
-
831
- const ws = new WebSocket(wsUrl);
832
- collabSocket = ws;
570
+ $('chat-input').addEventListener('input', () => { autosize(); updateSendState(); });
571
+ $('chat-input').addEventListener('keydown', (e) => {
572
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
573
+ e.preventDefault();
574
+ sendChat($('chat-input').value);
575
+ }
576
+ });
577
+ $('btn-send-chat').addEventListener('click', () => sendChat($('chat-input').value));
578
+ $('btn-clear-chat').addEventListener('click', () => {
579
+ state.chat = [];
580
+ persistChat();
581
+ renderChat();
582
+ $('chat-input').focus();
583
+ });
584
+ document.querySelectorAll('.suggestion').forEach((btn) => {
585
+ btn.addEventListener('click', () => sendChat(btn.dataset.prompt, { grounded: true }));
586
+ });
833
587
 
834
- ws.onopen = () => {
835
- ws.send(JSON.stringify({ type: 'SUBSCRIBE', url: collabActiveUrl }));
836
- collabPresenceCount.textContent = 'Connected';
837
- };
588
+ async function consumePendingAsk() {
589
+ let pending;
590
+ try {
591
+ ({ pendingAsk: pending } = await chrome.storage.session.get('pendingAsk'));
592
+ } catch { return; }
593
+ if (!pending || Date.now() - pending.at > 60000) return;
594
+ await chrome.storage.session.remove('pendingAsk');
595
+ switchTab('chat');
596
+ const quote = UI.clip(pending.text, 600);
597
+ sendChat(`What do I know about this?\n\n“${quote}”`, {
598
+ grounded: false,
599
+ extraContext: pending.title ? `The selection comes from “${pending.title}” (${pending.url}).` : '',
600
+ });
601
+ }
838
602
 
839
- ws.onmessage = (event) => {
603
+ // ---------------------------------------------------------------------------
604
+ // Research
605
+ // ---------------------------------------------------------------------------
606
+
607
+ let researchPoll = null;
608
+
609
+ function researchRow(item, active) {
610
+ const row = el('div', 'research-row');
611
+ const status = el('div', 'r-status');
612
+ const meta = UI.researchStatus(item.status);
613
+ status.append(item.status === 'in_progress' ? el('div', 'spin') : el('div', `dot ${meta.tone}`));
614
+ status.title = meta.label;
615
+ const main = el('div', 'r-main');
616
+ main.append(el('div', 'r-topic', item.topic || item.title || 'Untitled'));
617
+ const when = UI.timeAgo(item.updated_at || item.created_at);
618
+ // Who wanted this: the user, or the AI's background (System 2) research for a project.
619
+ const why = item.origin === 'autonomous'
620
+ ? (item.project ? `Auto · ${item.project}` : 'Auto')
621
+ : item.origin === 'user' ? 'You asked' : '';
622
+ main.append(el('div', 'r-meta', [meta.label, why, when].filter(Boolean).join(' · ')));
623
+ if (item.origin === 'autonomous' && item.rationale) main.title = item.rationale;
624
+ row.append(status, main);
625
+ if (active) {
626
+ const cancel = el('button', 'icon-btn small');
627
+ cancel.type = 'button';
628
+ cancel.setAttribute('aria-label', `Cancel research: ${item.topic}`);
629
+ cancel.title = 'Cancel';
630
+ cancel.append(svg('close'));
631
+ cancel.addEventListener('click', async () => {
632
+ cancel.disabled = true;
840
633
  try {
841
- const data = JSON.parse(event.data);
842
- if (data.type === 'CHAT_MESSAGE') {
843
- appendCollabChatBubble(data.username, data.text, data.created_at);
844
- } else if (data.type === 'USER_JOINED') {
845
- appendCollabChatBubble('System', `${data.username} joined this page`, new Date().toISOString(), true);
846
- } else if (data.type === 'USER_LEFT') {
847
- appendCollabChatBubble('System', `${data.username} left`, new Date().toISOString(), true);
848
- } else if (data.type === 'ANNOTATION_ADDED') {
849
- fetchCollabAnnotations();
850
- }
634
+ await Brain.cancelResearch(item.id);
635
+ toast('Research cancelled');
636
+ loadResearch();
851
637
  } catch (err) {
852
- console.error(err);
638
+ cancel.disabled = false;
639
+ toast(describeError(err), true);
853
640
  }
854
- };
641
+ });
642
+ row.append(cancel);
643
+ }
644
+ return row;
645
+ }
855
646
 
856
- ws.onclose = () => {
857
- collabPresenceCount.textContent = 'Disconnected';
858
- };
647
+ function renderResearch() {
648
+ const { items, counts } = state.research;
649
+ const running = items.filter((i) => i.status === 'in_progress');
650
+ const queued = items.filter((i) => i.status === 'pending');
651
+ const active = [...running, ...queued];
652
+ const finished = items.filter((i) => i.status === 'done' || i.status === 'failed').slice(0, 12);
653
+ const QUEUED_SHOWN = 8;
859
654
 
860
- ws.onerror = () => {
861
- collabPresenceCount.textContent = 'Error';
862
- };
863
- });
864
- }
655
+ const countsEl = $('research-counts');
656
+ countsEl.replaceChildren();
657
+ const chips = [['in_progress', 'accent'], ['pending', ''], ['done', 'success'], ['failed', 'error']];
658
+ for (const [key, tone] of chips) {
659
+ if (!counts[key]) continue;
660
+ countsEl.append(el('span', `chip ${tone}`, `${counts[key]} ${UI.researchStatus(key).label.toLowerCase()}`));
661
+ }
865
662
 
866
- function closeCollabSocket() {
867
- if (collabSocket) {
868
- try {
869
- collabSocket.close();
870
- } catch {}
871
- collabSocket = null;
663
+ const activeRows = [...running, ...queued.slice(0, QUEUED_SHOWN)].map((i) => researchRow(i, true));
664
+ if (queued.length > QUEUED_SHOWN) {
665
+ activeRows.push(el('div', 'more-note', `+ ${queued.length - QUEUED_SHOWN} more queued`));
872
666
  }
667
+ renderListOrEmpty($('research-active'), activeRows, 'Nothing running. Queue a topic above, or use Research on any page.');
668
+ renderListOrEmpty($('research-done'), finished.map((i) => researchRow(i, false)), 'Finished research lands in your brain as a report.');
669
+
670
+ // The list is capped; the server's counts are the real totals.
671
+ const activeTotal = (counts.pending || 0) + (counts.in_progress || 0) || active.length;
672
+ const badge = $('research-active-count');
673
+ badge.hidden = activeTotal === 0;
674
+ badge.textContent = activeTotal > 99 ? '99+' : String(activeTotal);
675
+
676
+ clearInterval(researchPoll);
677
+ if (active.length) researchPoll = setInterval(loadResearch, 15000);
873
678
  }
874
679
 
875
- function appendCollabChatBubble(sender, text, timestamp, isSystem = false) {
876
- const bubble = document.createElement('div');
877
- if (isSystem) {
878
- bubble.className = 'chat-bubble system';
879
- bubble.textContent = text;
880
- } else {
881
- const isMe = sender.toLowerCase() === collabUsername.toLowerCase();
882
- bubble.className = `chat-bubble ${isMe ? 'user' : 'assistant'}`;
883
- bubble.innerHTML = `<span style="font-size: 10px; opacity: 0.8; font-weight: bold; display: block; margin-bottom: 2px;">${escapeHtml(sender)}</span>${escapeHtml(text)}`;
884
- }
885
- collabChatMessages.appendChild(bubble);
886
- collabChatMessages.scrollTop = collabChatMessages.scrollHeight;
680
+ function renderListOrEmpty(container, rows, emptyText) {
681
+ container.replaceChildren(...(rows.length ? rows : [el('div', 'empty', emptyText)]));
887
682
  }
888
683
 
889
- async function registerCollab(username, password) {
684
+ async function loadResearch() {
685
+ if (state.connection !== 'ok') return;
686
+ if (!state.research.items.length && state.tab === 'research') skeleton($('research-active'), 2);
890
687
  try {
891
- const config = await self.BrainClient.getConfig();
892
- const res = await fetch(`${config.brainUrl}/api/collab/auth/register`, {
893
- method: 'POST',
894
- headers: { 'Content-Type': 'application/json' },
895
- body: JSON.stringify({ username, password })
896
- });
897
- const data = await res.json();
898
- if (res.ok) {
899
- collabToken = data.token;
900
- collabUsername = data.username;
901
- await chrome.storage.local.set({ collabToken: data.token, collabUsername: data.username });
902
- showToast('Registered successfully!');
903
- showCollabMain();
904
- } else {
905
- showToast('Registration failed: ' + (data.error || 'Unknown error'), true);
906
- }
688
+ state.research = await Brain.listResearch();
689
+ renderResearch();
907
690
  } catch (err) {
908
- showToast('Registration error: ' + err.message, true);
691
+ if (isAuthOrOffline(err)) return checkConnection();
692
+ $('research-active').replaceChildren(el('div', 'error-note', `Couldn’t load research: ${describeError(err)}`));
909
693
  }
910
694
  }
911
695
 
912
- async function loginCollab(username, password) {
696
+ $('research-form').addEventListener('submit', async (e) => {
697
+ e.preventDefault();
698
+ const input = $('research-input');
699
+ const topic = input.value.trim();
700
+ if (!topic) return input.focus();
701
+ const btn = $('btn-queue-research');
702
+ setBusy(btn, true);
913
703
  try {
914
- const config = await self.BrainClient.getConfig();
915
- const res = await fetch(`${config.brainUrl}/api/collab/auth/login`, {
916
- method: 'POST',
917
- headers: { 'Content-Type': 'application/json' },
918
- body: JSON.stringify({ username, password })
919
- });
920
- const data = await res.json();
921
- if (res.ok) {
922
- collabToken = data.token;
923
- collabUsername = data.username;
924
- await chrome.storage.local.set({ collabToken: data.token, collabUsername: data.username });
925
- showToast('Logged in successfully!');
926
- showCollabMain();
927
- } else {
928
- showToast('Login failed: ' + (data.error || 'Unknown error'), true);
929
- }
704
+ await Brain.queueResearch(topic);
705
+ input.value = '';
706
+ toast('Research queued');
707
+ loadResearch();
930
708
  } catch (err) {
931
- showToast('Login error: ' + err.message, true);
709
+ toast(describeError(err), true);
710
+ } finally {
711
+ setBusy(btn, false);
712
+ }
713
+ });
714
+ $('btn-refresh-research').addEventListener('click', loadResearch);
715
+
716
+ // ---------------------------------------------------------------------------
717
+ // Settings sheet
718
+ // ---------------------------------------------------------------------------
719
+
720
+ function openSheet(open) {
721
+ const sheet = $('sheet');
722
+ sheet.classList.toggle('is-open', open);
723
+ sheet.toggleAttribute('inert', !open);
724
+ sheet.setAttribute('aria-hidden', String(!open));
725
+ $('scrim').hidden = !open;
726
+ if (open) {
727
+ loadBrains();
728
+ renderShortcut();
729
+ setTimeout(() => $('btn-close-settings').focus(), 60);
730
+ } else {
731
+ $('btn-open-settings').focus();
932
732
  }
933
733
  }
934
734
 
935
- async function logoutCollab() {
936
- collabToken = '';
937
- collabUsername = '';
938
- await chrome.storage.local.remove(['collabToken', 'collabUsername']);
939
- showCollabAuth();
940
- showToast('Logged out');
735
+ $('btn-open-settings').addEventListener('click', () => openSheet(true));
736
+ $('brain-chip').addEventListener('click', () => openSheet(true));
737
+ $('btn-close-settings').addEventListener('click', () => openSheet(false));
738
+ $('scrim').addEventListener('click', () => openSheet(false));
739
+
740
+ function brainLabel(id) {
741
+ if (!id || id === 'global') return 'Global';
742
+ const brain = state.brains.find((b) => b.id === id);
743
+ return (brain && brain.name) || id.replace(/^(project|tenant):/, '');
941
744
  }
942
745
 
943
- // --- Buttons / Forms bindings ---
944
- btnCollabLogin.addEventListener('click', () => {
945
- const u = collabUsernameInput.value.trim();
946
- const p = collabPasswordInput.value.trim();
947
- if (u && p) loginCollab(u, p);
948
- });
746
+ function renderBrainChip() {
747
+ $('brain-chip-label').textContent = brainLabel(state.activeBrainId);
748
+ }
949
749
 
950
- btnCollabRegister.addEventListener('click', () => {
951
- const u = collabUsernameInput.value.trim();
952
- const p = collabPasswordInput.value.trim();
953
- if (u && p) registerCollab(u, p);
750
+ async function loadBrains() {
751
+ const select = $('brain-select');
752
+ try {
753
+ const data = await Brain.listBrains();
754
+ state.brains = (data && data.brains) || [];
755
+ } catch { /* keep whatever we had */ }
756
+ const options = state.brains.length ? state.brains : [{ id: 'global', name: 'Global Brain', node_count: null }];
757
+ select.replaceChildren(...options.map((b) => {
758
+ const opt = document.createElement('option');
759
+ opt.value = b.id;
760
+ const count = typeof b.node_count === 'number' ? ` · ${b.node_count.toLocaleString()} memories` : '';
761
+ opt.textContent = `${b.id === 'global' ? 'Global brain' : b.name}${count}`;
762
+ return opt;
763
+ }));
764
+ if (!options.some((b) => b.id === state.activeBrainId)) {
765
+ const opt = document.createElement('option');
766
+ opt.value = state.activeBrainId;
767
+ opt.textContent = brainLabel(state.activeBrainId);
768
+ select.append(opt);
769
+ }
770
+ select.value = state.activeBrainId;
771
+ renderBrainChip();
772
+ }
773
+
774
+ $('brain-select').addEventListener('change', async (e) => {
775
+ state.activeBrainId = e.target.value || 'global';
776
+ await chrome.storage.sync.set({ activeBrainId: state.activeBrainId });
777
+ renderBrainChip();
778
+ toast(`Using ${brainLabel(state.activeBrainId)} brain`);
779
+ loadChat();
780
+ loadRecent();
781
+ refreshPage();
782
+ if ($('search-input').value.trim()) onSearchInput();
954
783
  });
955
784
 
956
- btnCollabLogout.addEventListener('click', logoutCollab);
785
+ function renderSettingsToggles() {
786
+ $('toggle-page-recall').checked = !!state.settings.pageRecall;
787
+ $('toggle-pill').checked = !!state.settings.showPill;
788
+ $('toggle-pill').disabled = !state.settings.pageRecall;
789
+ document.querySelector('.setting-row.sub').classList.toggle('is-disabled', !state.settings.pageRecall);
790
+ }
957
791
 
958
- btnCollabShowJoin.addEventListener('click', () => {
959
- collabJoinGroupRow.classList.toggle('hidden');
960
- collabCreateGroupRow.classList.add('hidden');
792
+ $('toggle-page-recall').addEventListener('change', async (e) => {
793
+ state.settings.pageRecall = e.target.checked;
794
+ await chrome.storage.sync.set({ pageRecall: state.settings.pageRecall });
795
+ renderSettingsToggles();
796
+ toast(state.settings.pageRecall ? 'Related memories on — takes effect on the next page load' : 'Related memories off');
797
+ });
798
+ $('toggle-pill').addEventListener('change', async (e) => {
799
+ state.settings.showPill = e.target.checked;
800
+ await chrome.storage.sync.set({ showPill: state.settings.showPill });
961
801
  });
962
802
 
963
- btnCollabShowCreate.addEventListener('click', () => {
964
- collabCreateGroupRow.classList.toggle('hidden');
965
- collabJoinGroupRow.classList.add('hidden');
803
+ function currentHost() {
804
+ const tab = state.page;
805
+ return tab && UI.isCapturableUrl(tab.url) ? new URL(tab.url).hostname.replace(/^www\./, '') : '';
806
+ }
807
+
808
+ function renderBlockButton() {
809
+ const host = currentHost();
810
+ const btn = $('btn-block-site');
811
+ btn.disabled = !host;
812
+ const blocked = host && UI.isBlocked(state.page.url, state.settings.blocklist);
813
+ btn.classList.toggle('danger', !blocked);
814
+ btn.replaceChildren(document.createTextNode(blocked ? 'Allow ' : 'Never check '), el('span', '', host || 'this site'));
815
+ if (blocked) btn.append(document.createTextNode(' again'));
816
+ }
817
+
818
+ $('btn-block-site').addEventListener('click', async () => {
819
+ const host = currentHost();
820
+ if (!host) return;
821
+ const list = Array.isArray(state.settings.blocklist) ? state.settings.blocklist.slice() : [];
822
+ const blocked = UI.isBlocked(state.page.url, list);
823
+ const next = blocked
824
+ ? list.filter((entry) => !UI.isBlocked(state.page.url, [entry]))
825
+ : [...list, host];
826
+ state.settings.blocklist = next;
827
+ await chrome.storage.sync.set({ blocklist: next });
828
+ renderBlockButton();
829
+ toast(blocked ? `${host} will be checked again` : `${host} won’t be checked for related memories`);
966
830
  });
967
831
 
968
- btnCollabSubmitJoin.addEventListener('click', async () => {
969
- const code = collabJoinCode.value.trim();
970
- if (!code) return;
832
+ $('btn-recompile').addEventListener('click', async () => {
833
+ const btn = $('btn-recompile');
834
+ setBusy(btn, true);
971
835
  try {
972
- const config = await self.BrainClient.getConfig();
973
- const res = await fetch(`${config.brainUrl}/api/collab/groups/join`, {
974
- method: 'POST',
975
- headers: {
976
- 'Content-Type': 'application/json',
977
- 'Authorization': `Bearer ${collabToken}`
978
- },
979
- body: JSON.stringify({ code })
980
- });
981
- const data = await res.json();
982
- if (res.ok) {
983
- showToast('Joined group!');
984
- collabJoinCode.value = '';
985
- collabJoinGroupRow.classList.add('hidden');
986
- await fetchCollabGroups();
987
- } else {
988
- showToast('Join error: ' + data.error, true);
989
- }
836
+ await Brain.compile();
837
+ toast('Search index rebuilt');
990
838
  } catch (err) {
991
- showToast('Failed to join: ' + err.message, true);
839
+ toast(describeError(err), true);
840
+ } finally {
841
+ setBusy(btn, false);
992
842
  }
993
843
  });
994
844
 
995
- btnCollabSubmitCreate.addEventListener('click', async () => {
996
- const name = collabCreateName.value.trim();
997
- if (!name) return;
845
+ async function renderShortcut() {
998
846
  try {
999
- const config = await self.BrainClient.getConfig();
1000
- const res = await fetch(`${config.brainUrl}/api/collab/groups`, {
1001
- method: 'POST',
1002
- headers: {
1003
- 'Content-Type': 'application/json',
1004
- 'Authorization': `Bearer ${collabToken}`
1005
- },
1006
- body: JSON.stringify({ name })
1007
- });
1008
- const data = await res.json();
1009
- if (res.ok) {
1010
- showToast('Group created!');
1011
- collabCreateName.value = '';
1012
- collabCreateGroupRow.classList.add('hidden');
1013
- await fetchCollabGroups();
1014
- } else {
1015
- showToast('Create error: ' + data.error, true);
1016
- }
1017
- } catch (err) {
1018
- showToast('Failed to create group: ' + err.message, true);
1019
- }
847
+ const commands = await chrome.commands.getAll();
848
+ const open = commands.find((c) => c.name === '_execute_action');
849
+ const remember = commands.find((c) => c.name === 'remember-page');
850
+ const parts = [open && open.shortcut, remember && remember.shortcut].filter(Boolean);
851
+ $('shortcut-summary').textContent = parts.length ? parts.join(' · ') : 'Not set';
852
+ } catch { /* ignore */ }
853
+ }
854
+
855
+ function renderSheetFooter() {
856
+ const foot = $('sheet-foot');
857
+ const ext = `Extension v${chrome.runtime.getManifest().version}`;
858
+ const brain = state.health && state.health.version ? `Brain v${state.health.version}` : '';
859
+ foot.replaceChildren(el('span', '', ext), el('span', '', brain));
860
+ }
861
+
862
+ $('link-options').addEventListener('click', () => chrome.runtime.openOptionsPage());
863
+ $('link-shortcuts').addEventListener('click', () => chrome.tabs.create({ url: 'chrome://extensions/shortcuts' }));
864
+ $('link-dashboard').addEventListener('click', async () => chrome.tabs.create({ url: await Brain.dashboardUrl('/') }));
865
+
866
+ // ---------------------------------------------------------------------------
867
+ // Gate buttons
868
+ // ---------------------------------------------------------------------------
869
+
870
+ $('gate-settings').addEventListener('click', () => chrome.runtime.openOptionsPage());
871
+ $('gate-retry').addEventListener('click', async () => {
872
+ const btn = $('gate-retry');
873
+ setBusy(btn, true);
874
+ const recovered = await checkConnection();
875
+ setBusy(btn, false);
876
+ if (recovered) loadAll();
1020
877
  });
1021
878
 
1022
- collabGroupSelect.addEventListener('change', () => {
1023
- const activeCode = collabGroupSelect.value;
1024
- collabSelectedGroup = collabGroups.find(g => g.code === activeCode) || null;
1025
- fetchCollabAnnotations();
1026
- });
879
+ // ---------------------------------------------------------------------------
880
+ // Global events
881
+ // ---------------------------------------------------------------------------
1027
882
 
1028
- btnCollabSaveNote.addEventListener('click', async () => {
1029
- const text = collabNoteInput.value.trim();
1030
- if (!text || !collabActiveUrl || !collabSelectedGroup) return;
1031
- try {
1032
- const config = await self.BrainClient.getConfig();
1033
- const res = await fetch(`${config.brainUrl}/api/collab/annotations`, {
1034
- method: 'POST',
1035
- headers: {
1036
- 'Content-Type': 'application/json',
1037
- 'Authorization': `Bearer ${collabToken}`
1038
- },
1039
- body: JSON.stringify({
1040
- url: collabActiveUrl,
1041
- groupCode: collabSelectedGroup.code,
1042
- text
1043
- })
1044
- });
1045
- if (res.ok) {
1046
- showToast('Note pinned!');
1047
- collabNoteInput.value = '';
1048
- fetchCollabAnnotations();
1049
- } else {
1050
- const errData = await res.json();
1051
- showToast('Pin note error: ' + errData.error, true);
1052
- }
1053
- } catch (err) {
1054
- showToast('Failed to pin note: ' + err.message, true);
883
+ document.addEventListener('keydown', (e) => {
884
+ const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement && document.activeElement.tagName);
885
+ if (e.key === 'Escape' && $('sheet').classList.contains('is-open')) return openSheet(false);
886
+ if (e.key === '/' && !typing && state.tab === 'recall') {
887
+ e.preventDefault();
888
+ $('search-input').focus();
1055
889
  }
1056
890
  });
1057
891
 
1058
- btnCollabSendChat.addEventListener('click', () => {
1059
- const text = collabChatInput.value.trim();
1060
- if (text && collabSocket && collabSocket.readyState === WebSocket.OPEN) {
1061
- collabSocket.send(JSON.stringify({ type: 'CHAT_MESSAGE', text }));
1062
- collabChatInput.value = '';
1063
- }
1064
- });
892
+ let pageTimer = null;
893
+ function schedulePageRefresh() {
894
+ clearTimeout(pageTimer);
895
+ pageTimer = setTimeout(() => { refreshPage(); updateSendState(); }, 150);
896
+ }
1065
897
 
1066
- collabChatInput.addEventListener('keydown', (e) => {
1067
- if (e.key === 'Enter' && !e.shiftKey) {
1068
- e.preventDefault();
1069
- btnCollabSendChat.click();
1070
- }
898
+ chrome.tabs.onActivated.addListener(schedulePageRefresh);
899
+ chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
900
+ if (!state.page || tabId !== state.page.id) return;
901
+ if (changeInfo.url || changeInfo.title || changeInfo.status === 'complete' || changeInfo.favIconUrl) schedulePageRefresh();
1071
902
  });
903
+ chrome.windows?.onFocusChanged?.addListener(schedulePageRefresh);
1072
904
 
1073
- // Listen for tab active changes
1074
- chrome.tabs.onActivated.addListener(() => {
1075
- updateCollabActiveUrl();
905
+ chrome.runtime.onMessage.addListener((msg) => {
906
+ if (msg && msg.type === 'CAPTURED') afterCapture(msg.action);
1076
907
  });
1077
908
 
1078
- chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
1079
- if (changeInfo.url) {
1080
- updateCollabActiveUrl();
909
+ chrome.storage.onChanged.addListener((changes, area) => {
910
+ if (area === 'session' && changes.pendingAsk && changes.pendingAsk.newValue) consumePendingAsk();
911
+ if (area === 'sync') {
912
+ if (changes.blocklist) { state.settings.blocklist = changes.blocklist.newValue || []; renderBlockButton(); }
913
+ if (changes.pageRecall) { state.settings.pageRecall = !!changes.pageRecall.newValue; renderSettingsToggles(); }
914
+ }
915
+ if (area === 'local' && (changes.brainUrl || changes.pat)) {
916
+ checkConnection().then((recovered) => { if (recovered) loadAll(); });
1081
917
  }
1082
918
  });
1083
919
 
1084
- // ---- Event listeners ----
1085
- searchInput.addEventListener('input', (e) => debounceSearch(e.target.value));
1086
-
1087
- // ---- Init ----
1088
- checkConnection();
1089
- loadSettingsState().then(() => {
1090
- loadRecentCaptures();
1091
- recordRememberInvariants();
1092
- initCollab();
1093
- });
1094
-
1095
- // Set intervals
1096
- setInterval(checkConnection, 15000);
1097
- setInterval(() => {
1098
- // Refresh active research periodically if currently viewing Research tab
1099
- const activeTab = document.querySelector('.tab-btn.active');
1100
- if (activeTab && activeTab.getAttribute('data-tab') === 'research') {
1101
- fetchResearch();
1102
- }
1103
- }, 30000);
920
+ // ---------------------------------------------------------------------------
921
+ // Init
922
+ // ---------------------------------------------------------------------------
923
+
924
+ function loadAll() {
925
+ refreshPage();
926
+ loadRecent();
927
+ loadResearch();
928
+ loadBrains();
929
+ }
930
+
931
+ async function init() {
932
+ const [sync, session] = await Promise.all([
933
+ chrome.storage.sync.get({ activeBrainId: 'global', pageRecall: false, showPill: true, blocklist: [] }),
934
+ chrome.storage.session.get('lastTab').catch(() => ({})),
935
+ ]);
936
+ state.activeBrainId = sync.activeBrainId || 'global';
937
+ state.settings = { pageRecall: sync.pageRecall, showPill: sync.showPill, blocklist: sync.blocklist || [] };
938
+ renderBrainChip();
939
+ renderSettingsToggles();
940
+ renderSheetFooter();
941
+ switchTab(TABS.includes(session.lastTab) ? session.lastTab : 'recall');
942
+ await loadChat();
943
+ await refreshPage();
944
+
945
+ await checkConnection();
946
+ if (state.connection === 'ok') loadAll();
947
+ consumePendingAsk();
948
+ setInterval(() => checkConnection({ quiet: true }).then((recovered) => { if (recovered) loadAll(); }), 30000);
949
+ }
950
+
951
+ init();
1104
952
  })();
1105
-
1106
-