total-recall-brain 3.28.2 → 3.30.1
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.
- package/bin/total-recall.mjs +24 -1
- package/docs/ARCHITECTURE.md +26 -18
- package/extension/background.js +259 -84
- package/extension/content-script.css +7 -13
- package/extension/content-script.js +252 -477
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-32.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/icons/mark.svg +6 -0
- package/extension/lib/brain-client.js +160 -73
- package/extension/lib/tokens.css +187 -0
- package/extension/lib/ui-helpers.js +229 -0
- package/extension/lib/ui-helpers.spec.mjs +94 -0
- package/extension/manifest.json +20 -5
- package/extension/options/options.css +64 -218
- package/extension/options/options.html +91 -43
- package/extension/options/options.js +208 -85
- package/extension/sidepanel/sidepanel.css +268 -970
- package/extension/sidepanel/sidepanel.html +200 -166
- package/extension/sidepanel/sidepanel.js +788 -942
- package/frontend/dist/assets/index-DGMNZi_1.js +5742 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/src/api/plugins.ts +124 -92
- package/frontend/src/components/ResearchAgendaTab.spec.tsx +11 -0
- package/frontend/src/components/ResearchAgendaTab.tsx +19 -14
- package/frontend/src/pages/PluginsPage.spec.tsx +87 -16
- package/frontend/src/pages/PluginsPage.tsx +454 -1218
- package/package.json +2 -1
- package/plugins/code-quality/README.md +25 -0
- package/plugins/code-quality/cli.mjs +25 -0
- package/plugins/code-quality/plugin.json +17 -0
- package/plugins/git-sentinel/README.md +27 -0
- package/{scaffold/.agent/plugins → plugins}/git-sentinel/cli.mjs +8 -5
- package/plugins/git-sentinel/plugin.json +18 -0
- package/plugins/system-monitor/README.md +26 -0
- package/{scaffold/.agent/plugins → plugins}/system-monitor/cli.mjs +5 -4
- package/{scaffold/.agent/plugins → plugins}/system-monitor/generator.mjs +4 -5
- package/plugins/system-monitor/plugin.json +29 -0
- package/scaffold/.agent/skills/meta-harness/SKILL.md +4 -4
- package/scaffold/.agent/skills/plugins/SKILL.md +53 -0
- package/scaffold/.agent/skills/total-recall/SKILL.md +22 -6
- package/scaffold/.agent/skills/total-recall/scripts/brain-state.json +1 -0
- package/src/cli/agent.mjs +1 -1
- package/src/cli/app/index.mjs +493 -0
- package/src/cli/brain.mjs +33 -3
- package/src/cli/command.mjs +80 -41
- package/src/cli/connect.mjs +10 -1
- package/src/cli/harness.mjs +1 -1
- package/src/cli/init.mjs +3 -1
- package/src/cli/mesh.mjs +118 -12
- package/src/cli/plugin/create.mjs +144 -25
- package/src/cli/plugin/index.mjs +83 -53
- package/src/cli/plugin/install.mjs +10 -127
- package/src/cli/plugin/list.mjs +44 -44
- package/src/cli/plugin/remove.mjs +23 -30
- package/src/cli/plugin/scaffold-cmd.mjs +30 -0
- package/src/cli/plugin/search.mjs +108 -23
- package/src/cli/rebuild.mjs +37 -1
- package/src/cli/recall.mjs +17 -2
- package/src/cli/research.mjs +4 -6
- package/src/cli/share.mjs +2 -0
- package/src/core/app-deploy/apply.mjs +386 -0
- package/src/core/app-deploy/design-tokens.mjs +217 -0
- package/src/core/app-deploy/plan.mjs +249 -0
- package/src/core/app-deploy/resolve.mjs +295 -0
- package/src/core/app-deploy/source.mjs +337 -0
- package/src/core/app-deploy/standalone.mjs +248 -0
- package/src/core/app-deploy/upgrade.mjs +256 -0
- package/src/core/app-deploy/verify.mjs +213 -0
- package/src/core/bound-hosts.mjs +1 -1
- package/src/core/brain-registry.mjs +146 -0
- package/src/core/brain-state.json +1 -0
- package/src/core/daemon-loop.mjs +30 -26
- package/src/core/dream.mjs +5 -17
- package/src/core/fact-seeker.mjs +53 -368
- package/src/core/lan-discovery.mjs +3 -3
- package/src/core/mesh-access-sync.mjs +134 -0
- package/src/core/mesh-access.mjs +18 -3
- package/src/core/mesh-discover.mjs +173 -0
- package/src/core/mesh.mjs +131 -32
- package/src/core/optimizer.mjs +4 -41
- package/src/core/plugin-bundle.mjs +167 -0
- package/src/core/plugin-context.mjs +8 -13
- package/src/core/plugin-loader.mjs +272 -68
- package/src/core/plugin-peers.mjs +163 -0
- package/src/core/plugin-public.mjs +85 -0
- package/src/core/plugin-runner-child.mjs +24 -0
- package/src/core/plugin-runner.mjs +102 -0
- package/src/core/plugin-store.mjs +384 -0
- package/src/core/plugin-tasks.mjs +212 -0
- package/src/core/post-mortem.mjs +40 -9
- package/src/core/research-gate.mjs +182 -0
- package/src/core/research-queue.mjs +47 -7
- package/src/core/research-surface.mjs +211 -0
- package/src/core/research.mjs +25 -32
- package/src/core/scheduler.mjs +67 -153
- package/src/core/schema.mjs +129 -0
- package/src/core/search.mjs +12 -1
- package/src/core/secret-integration-research.mjs +12 -8
- package/src/core/secrets-store.mjs +76 -2
- package/src/core/session-watcher.mjs +6 -0
- package/src/core/surface.mjs +64 -3
- package/src/core/task-executors.mjs +8 -45
- package/src/core/vault-cache.mjs +70 -4
- package/src/server/api.mjs +21 -0
- package/src/server/rest.mjs +2 -0
- package/src/server/route-manifest.json +21 -5
- package/src/server/routes/collab.mjs +38 -11
- package/src/server/routes/extension.mjs +3 -2
- package/src/server/routes/memory.mjs +24 -2
- package/src/server/routes/mesh.mjs +7 -7
- package/src/server/routes/plugins-mesh.mjs +49 -0
- package/src/server/routes/plugins.mjs +91 -426
- package/src/server/routes/research.mjs +5 -2
- package/src/server/routes/share.mjs +2 -0
- package/src/server/tools.mjs +29 -2
- package/extension/icons/README.md +0 -27
- package/extension/lib/preconfigured.js +0 -5
- package/extension/popup/popup.css +0 -227
- package/extension/popup/popup.html +0 -50
- package/extension/popup/popup.js +0 -175
- package/frontend/dist/assets/index-U7o-qU99.js +0 -5742
- package/scaffold/.agent/plugins/.gitkeep +0 -0
- package/scaffold/.agent/plugins/code-quality/README.md +0 -16
- package/scaffold/.agent/plugins/code-quality/cli.mjs +0 -15
- package/scaffold/.agent/plugins/code-quality/plugin.json +0 -34
- package/scaffold/.agent/plugins/git-sentinel/README.md +0 -16
- package/scaffold/.agent/plugins/git-sentinel/plugin.json +0 -34
- package/scaffold/.agent/plugins/meta-harness/README.md +0 -16
- package/scaffold/.agent/plugins/meta-harness/cli.mjs +0 -34
- package/scaffold/.agent/plugins/meta-harness/plugin.json +0 -34
- package/scaffold/.agent/plugins/system-monitor/README.md +0 -16
- package/scaffold/.agent/plugins/system-monitor/plugin.json +0 -38
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Total Recall — pure UI helpers shared by the side panel, options page and the
|
|
2
|
+
// service worker. No DOM or chrome.* access, so the same file is unit-tested
|
|
3
|
+
// under vitest (see ui-helpers.spec.mjs). Exposed as `self.TRUi`.
|
|
4
|
+
|
|
5
|
+
(function (root) {
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
// Raw cosine similarity a memory must reach before the extension calls it
|
|
9
|
+
// "related" to the page. The search API's `score` is rank-fused (RRF) and says
|
|
10
|
+
// nothing about relevance: the top hit scores ~0.49 however unrelated it is,
|
|
11
|
+
// and one lexical word match ("deployment") pushed a 0.38-similarity hit to
|
|
12
|
+
// 0.93. Calibrated on the live brain 2026-09-22: unrelated page titles topped
|
|
13
|
+
// out at 0.48, genuinely related ones started at 0.55.
|
|
14
|
+
const RELATED_MIN_SIMILARITY = 0.53;
|
|
15
|
+
// Only for brains too old to report `similarity`.
|
|
16
|
+
const RELATED_MIN_FUSED_SCORE = 0.75;
|
|
17
|
+
|
|
18
|
+
const HTML_ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
19
|
+
|
|
20
|
+
function escapeHtml(value) {
|
|
21
|
+
return String(value == null ? '' : value).replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isCapturableUrl(url) {
|
|
25
|
+
return /^https?:\/\//i.test(String(url || ''));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function hostnameOf(url) {
|
|
29
|
+
try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return ''; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Domain blocklist match: exact host or any subdomain of an entry. */
|
|
33
|
+
function isBlocked(url, blocklist) {
|
|
34
|
+
if (!Array.isArray(blocklist) || blocklist.length === 0) return false;
|
|
35
|
+
let host;
|
|
36
|
+
try { host = new URL(url).hostname.toLowerCase(); } catch { return false; }
|
|
37
|
+
return blocklist.some((entry) => {
|
|
38
|
+
const domain = String(entry || '').trim().toLowerCase()
|
|
39
|
+
.replace(/^[a-z]+:\/\//, '').replace(/\/.*$/, '').replace(/^\*\./, '');
|
|
40
|
+
return domain && (host === domain || host.endsWith(`.${domain}`));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function collapseWhitespace(text) {
|
|
45
|
+
return String(text || '').replace(/[ \t\f\v ]+/g, ' ').replace(/\s*\n\s*/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Trim to `max` chars, preferring to end on a sentence or word boundary. */
|
|
49
|
+
function clip(text, max) {
|
|
50
|
+
const clean = collapseWhitespace(text);
|
|
51
|
+
if (clean.length <= max) return clean;
|
|
52
|
+
const cut = clean.slice(0, max);
|
|
53
|
+
const sentence = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('.\n'));
|
|
54
|
+
if (sentence > max * 0.6) return cut.slice(0, sentence + 1);
|
|
55
|
+
const word = cut.lastIndexOf(' ');
|
|
56
|
+
return `${cut.slice(0, word > max * 0.6 ? word : max)}…`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build the memory body for a page capture so the node holds real content, not
|
|
61
|
+
* just a URL: the user's note, then the selection (or description + leading
|
|
62
|
+
* readable text), then the source.
|
|
63
|
+
*/
|
|
64
|
+
function buildCaptureExcerpt({ url, description, selection, text, note } = {}) {
|
|
65
|
+
const parts = [];
|
|
66
|
+
if (note && note.trim()) parts.push(note.trim());
|
|
67
|
+
if (selection && selection.trim()) {
|
|
68
|
+
parts.push(clip(selection, 4000).split('\n').map((line) => `> ${line}`).join('\n'));
|
|
69
|
+
} else {
|
|
70
|
+
if (description && description.trim()) parts.push(clip(description, 400));
|
|
71
|
+
if (text && text.trim()) {
|
|
72
|
+
const lead = clip(text, 1500);
|
|
73
|
+
if (!description || !lead.startsWith(description.trim().slice(0, 40))) parts.push(lead);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (url) parts.push(`Source: ${url}`);
|
|
77
|
+
return parts.join('\n\n');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function memoryText(result) {
|
|
81
|
+
return String(result.content || result.body || result.excerpt || result.snippet || '');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Keep only vault memories that are genuinely related: sessions have no
|
|
86
|
+
* title/body to show, and rank-fused scores alone say nothing about relevance.
|
|
87
|
+
*/
|
|
88
|
+
function filterRelated(results, { minSimilarity = RELATED_MIN_SIMILARITY, minFusedScore = RELATED_MIN_FUSED_SCORE, limit = 5, excludeUrl } = {}) {
|
|
89
|
+
return (Array.isArray(results) ? results : [])
|
|
90
|
+
.filter((r) => r && r.type !== 'session' && r.slug && r.title)
|
|
91
|
+
.filter((r) => (typeof r.similarity === 'number'
|
|
92
|
+
? r.similarity >= minSimilarity
|
|
93
|
+
: (r.score || 0) >= minFusedScore))
|
|
94
|
+
.filter((r) => !excludeUrl || !memoryText(r).includes(excludeUrl))
|
|
95
|
+
.slice(0, limit)
|
|
96
|
+
.map((r) => ({
|
|
97
|
+
slug: r.slug,
|
|
98
|
+
title: String(r.title),
|
|
99
|
+
excerpt: clip(stripMarkdown(memoryText(r)), 220),
|
|
100
|
+
category: r.category || 'memory',
|
|
101
|
+
similarity: typeof r.similarity === 'number' ? r.similarity : null,
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function stripMarkdown(text) {
|
|
106
|
+
return String(text || '')
|
|
107
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
108
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
109
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
|
110
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
111
|
+
.replace(/^\s{0,3}(#{1,6}|>|[-*+]|\d+\.)\s+/gm, '')
|
|
112
|
+
.replace(/(\*\*|__|\*|_)(.+?)\1/g, '$2');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function timeAgo(value, now = Date.now()) {
|
|
116
|
+
const t = typeof value === 'number' ? value : Date.parse(value);
|
|
117
|
+
if (!t || Number.isNaN(t)) return '';
|
|
118
|
+
const s = Math.max(0, Math.round((now - t) / 1000));
|
|
119
|
+
if (s < 45) return 'just now';
|
|
120
|
+
if (s < 3600) return `${Math.round(s / 60)}m ago`;
|
|
121
|
+
if (s < 86400) return `${Math.round(s / 3600)}h ago`;
|
|
122
|
+
if (s < 86400 * 7) return `${Math.round(s / 86400)}d ago`;
|
|
123
|
+
return new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function safeHref(url) {
|
|
127
|
+
return /^https?:\/\//i.test(url) ? url : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function renderInline(escaped) {
|
|
131
|
+
return escaped
|
|
132
|
+
.replace(/`([^`\n]+)`/g, '<code>$1</code>')
|
|
133
|
+
.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>')
|
|
134
|
+
.replace(/(^|[^*\w])\*([^*\n]+)\*(?!\w)/g, '$1<em>$2</em>')
|
|
135
|
+
.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (m, label, href) => {
|
|
136
|
+
// href is already HTML-escaped; unescape & only to test the scheme.
|
|
137
|
+
const safe = safeHref(href.replace(/&/g, '&'));
|
|
138
|
+
return safe ? `<a href="${href}" target="_blank" rel="noopener noreferrer">${label}</a>` : label;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Minimal, safe markdown for chat replies. Escapes everything first, then
|
|
144
|
+
* re-introduces a small whitelist: fenced/inline code, bold, italic, http(s)
|
|
145
|
+
* links, headings (as strong lines), bullet/numbered lists and paragraphs.
|
|
146
|
+
*/
|
|
147
|
+
function renderMarkdown(markdown) {
|
|
148
|
+
const src = String(markdown || '').replace(/\r\n/g, '\n');
|
|
149
|
+
const blocks = [];
|
|
150
|
+
const fence = /```[\w-]*\n?([\s\S]*?)```/g;
|
|
151
|
+
let last = 0;
|
|
152
|
+
let m;
|
|
153
|
+
while ((m = fence.exec(src))) {
|
|
154
|
+
blocks.push({ kind: 'text', value: src.slice(last, m.index) });
|
|
155
|
+
blocks.push({ kind: 'code', value: m[1].replace(/\n$/, '') });
|
|
156
|
+
last = fence.lastIndex;
|
|
157
|
+
}
|
|
158
|
+
blocks.push({ kind: 'text', value: src.slice(last) });
|
|
159
|
+
|
|
160
|
+
const html = [];
|
|
161
|
+
for (const block of blocks) {
|
|
162
|
+
if (block.kind === 'code') {
|
|
163
|
+
html.push(`<pre><code>${escapeHtml(block.value)}</code></pre>`);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const lines = block.value.split('\n');
|
|
167
|
+
let list = null;
|
|
168
|
+
let para = [];
|
|
169
|
+
const flushPara = () => {
|
|
170
|
+
if (para.length) html.push(`<p>${para.map((l) => renderInline(escapeHtml(l))).join('<br>')}</p>`);
|
|
171
|
+
para = [];
|
|
172
|
+
};
|
|
173
|
+
const flushList = () => {
|
|
174
|
+
if (list) html.push(`<${list.tag}>${list.items.map((i) => `<li>${renderInline(escapeHtml(i))}</li>`).join('')}</${list.tag}>`);
|
|
175
|
+
list = null;
|
|
176
|
+
};
|
|
177
|
+
for (const line of lines) {
|
|
178
|
+
const bullet = line.match(/^\s*[-*+]\s+(.*)$/);
|
|
179
|
+
const numbered = line.match(/^\s*\d+[.)]\s+(.*)$/);
|
|
180
|
+
const heading = line.match(/^\s{0,3}#{1,6}\s+(.*)$/);
|
|
181
|
+
if (bullet || numbered) {
|
|
182
|
+
flushPara();
|
|
183
|
+
const tag = bullet ? 'ul' : 'ol';
|
|
184
|
+
if (list && list.tag !== tag) flushList();
|
|
185
|
+
if (!list) list = { tag, items: [] };
|
|
186
|
+
list.items.push((bullet || numbered)[1]);
|
|
187
|
+
} else if (heading) {
|
|
188
|
+
flushPara(); flushList();
|
|
189
|
+
html.push(`<p class="md-heading">${renderInline(escapeHtml(heading[1]))}</p>`);
|
|
190
|
+
} else if (!line.trim()) {
|
|
191
|
+
flushPara(); flushList();
|
|
192
|
+
} else {
|
|
193
|
+
flushList();
|
|
194
|
+
para.push(line);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
flushPara(); flushList();
|
|
198
|
+
}
|
|
199
|
+
return html.join('');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const RESEARCH_STATUS = {
|
|
203
|
+
pending: { label: 'Queued', tone: 'neutral' },
|
|
204
|
+
in_progress: { label: 'Researching', tone: 'accent' },
|
|
205
|
+
done: { label: 'Done', tone: 'success' },
|
|
206
|
+
failed: { label: 'Failed', tone: 'error' },
|
|
207
|
+
};
|
|
208
|
+
function researchStatus(status) {
|
|
209
|
+
return RESEARCH_STATUS[status] || { label: String(status || 'Unknown'), tone: 'neutral' };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const api = {
|
|
213
|
+
RELATED_MIN_SIMILARITY,
|
|
214
|
+
RELATED_MIN_FUSED_SCORE,
|
|
215
|
+
escapeHtml,
|
|
216
|
+
isCapturableUrl,
|
|
217
|
+
hostnameOf,
|
|
218
|
+
isBlocked,
|
|
219
|
+
clip,
|
|
220
|
+
buildCaptureExcerpt,
|
|
221
|
+
filterRelated,
|
|
222
|
+
stripMarkdown,
|
|
223
|
+
timeAgo,
|
|
224
|
+
renderMarkdown,
|
|
225
|
+
researchStatus,
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
root.TRUi = api;
|
|
229
|
+
})(typeof self !== 'undefined' ? self : globalThis);
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import vm from 'node:vm';
|
|
5
|
+
|
|
6
|
+
// Load it the way Chrome does: a classic script that attaches to `self`.
|
|
7
|
+
const sandbox = { URL };
|
|
8
|
+
sandbox.self = sandbox;
|
|
9
|
+
vm.runInNewContext(fs.readFileSync(new URL('./ui-helpers.js', import.meta.url), 'utf8'), sandbox);
|
|
10
|
+
const ui = sandbox.TRUi;
|
|
11
|
+
|
|
12
|
+
describe('escapeHtml / renderMarkdown', () => {
|
|
13
|
+
it('escapes every HTML-significant character', () => {
|
|
14
|
+
expect(ui.escapeHtml(`<img src=x onerror="alert('1')">&`)).toBe('<img src=x onerror="alert('1')">&');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('never lets raw HTML through', () => {
|
|
18
|
+
const html = ui.renderMarkdown('hi <script>alert(1)</script> **b** <img src=x onerror=alert(1)>');
|
|
19
|
+
expect(html).not.toMatch(/<script|<img/);
|
|
20
|
+
expect(html).toContain('<strong>b</strong>');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('only links http(s) targets', () => {
|
|
24
|
+
expect(ui.renderMarkdown('[ok](https://example.com/a?b=1&c=2)')).toContain('<a href="https://example.com/a?b=1&c=2"');
|
|
25
|
+
const bad = ui.renderMarkdown('[bad](javascript:alert(1))');
|
|
26
|
+
expect(bad).not.toContain('<a');
|
|
27
|
+
expect(bad).toContain('bad');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('renders code fences, lists and headings', () => {
|
|
31
|
+
const html = ui.renderMarkdown('# Title\n\n- one\n- two\n\n1. a\n2. b\n\n```js\nconst x = "<b>";\n```');
|
|
32
|
+
expect(html).toContain('<p class="md-heading">Title</p>');
|
|
33
|
+
expect(html).toContain('<ul><li>one</li><li>two</li></ul>');
|
|
34
|
+
expect(html).toContain('<ol><li>a</li><li>b</li></ol>');
|
|
35
|
+
expect(html).toContain('<pre><code>const x = "<b>";</code></pre>');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('filterRelated', () => {
|
|
40
|
+
const results = [
|
|
41
|
+
{ type: 'session', session_id: 's', score: 0.9 },
|
|
42
|
+
{ type: 'vault', slug: 'noise', title: 'Unrelated', body: 'x', score: 0.49, similarity: 0.41 },
|
|
43
|
+
{ type: 'vault', slug: 'close', title: 'Close', content: '**Deploy** notes', score: 0.48, similarity: 0.7, category: 'facts' },
|
|
44
|
+
{ type: 'vault', slug: 'agree', title: 'Agree', body: 'y', score: 0.8 },
|
|
45
|
+
{ type: 'vault', slug: 'lexical-fluke', title: 'Fluke', body: 'z', score: 0.93, similarity: 0.38 },
|
|
46
|
+
{ type: 'vault', slug: 'self', title: 'This page', body: 'Source: https://a.test/p', score: 0.9, similarity: 0.9 },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
it('drops sessions, rank-only noise, lexical flukes and memories of the page itself', () => {
|
|
50
|
+
const out = ui.filterRelated(results, { excludeUrl: 'https://a.test/p' });
|
|
51
|
+
expect(out.map(r => r.slug)).toEqual(['close', 'agree']);
|
|
52
|
+
expect(out[0]).toMatchObject({ title: 'Close', excerpt: 'Deploy notes', category: 'facts', similarity: 0.7 });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('tolerates junk input', () => {
|
|
56
|
+
expect(ui.filterRelated(undefined)).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('capture helpers', () => {
|
|
61
|
+
it('quotes the selection and cites the source', () => {
|
|
62
|
+
const body = ui.buildCaptureExcerpt({ url: 'https://a.test', selection: 'line one\nline two', note: 'why' });
|
|
63
|
+
expect(body).toBe('why\n\n> line one\n> line two\n\nSource: https://a.test');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('falls back to description plus leading text, clipped', () => {
|
|
67
|
+
const text = 'Sentence one is here. '.repeat(200);
|
|
68
|
+
const body = ui.buildCaptureExcerpt({ url: 'https://a.test', description: 'A page about things', text });
|
|
69
|
+
expect(body.startsWith('A page about things\n\nSentence one')).toBe(true);
|
|
70
|
+
expect(body.length).toBeLessThan(2000);
|
|
71
|
+
expect(body.endsWith('Source: https://a.test')).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('isBlocked matches hosts and subdomains, not substrings', () => {
|
|
75
|
+
expect(ui.isBlocked('https://mail.google.com/x', ['google.com'])).toBe(true);
|
|
76
|
+
expect(ui.isBlocked('https://notgoogle.com/', ['google.com'])).toBe(false);
|
|
77
|
+
expect(ui.isBlocked('https://bank.example/', ['https://bank.example/login'])).toBe(true);
|
|
78
|
+
expect(ui.isBlocked('https://example.com/', ['co'])).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('isCapturableUrl accepts only web pages', () => {
|
|
82
|
+
expect(ui.isCapturableUrl('https://x.y')).toBe(true);
|
|
83
|
+
expect(ui.isCapturableUrl('chrome://extensions')).toBe(false);
|
|
84
|
+
expect(ui.isCapturableUrl('file:///etc/hosts')).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('timeAgo buckets', () => {
|
|
88
|
+
const now = Date.parse('2026-09-22T12:00:00Z');
|
|
89
|
+
expect(ui.timeAgo('2026-09-22T11:59:50Z', now)).toBe('just now');
|
|
90
|
+
expect(ui.timeAgo('2026-09-22T11:30:00Z', now)).toBe('30m ago');
|
|
91
|
+
expect(ui.timeAgo('2026-09-21T12:00:00Z', now)).toBe('1d ago');
|
|
92
|
+
expect(ui.timeAgo('nonsense', now)).toBe('');
|
|
93
|
+
});
|
|
94
|
+
});
|
package/extension/manifest.json
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
|
-
"name": "Total Recall
|
|
4
|
-
"version": "0.
|
|
5
|
-
"description": "
|
|
3
|
+
"name": "Total Recall",
|
|
4
|
+
"version": "0.2.0",
|
|
5
|
+
"description": "Capture pages into your Total Recall brain, recall what you already know, and chat with it beside any tab.",
|
|
6
|
+
"minimum_chrome_version": "116",
|
|
6
7
|
"permissions": ["activeTab", "contextMenus", "sidePanel", "storage", "tabs"],
|
|
7
|
-
"optional_permissions": ["history", "webNavigation"],
|
|
8
8
|
"host_permissions": ["http://127.0.0.1:*/*", "http://localhost:*/*"],
|
|
9
|
+
"optional_host_permissions": ["http://*/*", "https://*/*"],
|
|
9
10
|
"background": {
|
|
10
11
|
"service_worker": "background.js"
|
|
11
12
|
},
|
|
12
13
|
"action": {
|
|
14
|
+
"default_title": "Open Total Recall",
|
|
13
15
|
"default_icon": {
|
|
14
16
|
"16": "icons/icon-16.png",
|
|
17
|
+
"32": "icons/icon-32.png",
|
|
15
18
|
"48": "icons/icon-48.png",
|
|
16
19
|
"128": "icons/icon-128.png"
|
|
17
20
|
}
|
|
@@ -19,8 +22,19 @@
|
|
|
19
22
|
"side_panel": {
|
|
20
23
|
"default_path": "sidepanel/sidepanel.html"
|
|
21
24
|
},
|
|
25
|
+
"commands": {
|
|
26
|
+
"_execute_action": {
|
|
27
|
+
"suggested_key": { "default": "Alt+Shift+R" },
|
|
28
|
+
"description": "Open the Total Recall side panel"
|
|
29
|
+
},
|
|
30
|
+
"remember-page": {
|
|
31
|
+
"suggested_key": { "default": "Alt+Shift+S" },
|
|
32
|
+
"description": "Remember the current page (or the selected text)"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"omnibox": { "keyword": "tr" },
|
|
22
36
|
"content_scripts": [{
|
|
23
|
-
"matches": ["
|
|
37
|
+
"matches": ["http://*/*", "https://*/*"],
|
|
24
38
|
"js": ["content-script.js"],
|
|
25
39
|
"css": ["content-script.css"],
|
|
26
40
|
"run_at": "document_idle"
|
|
@@ -32,6 +46,7 @@
|
|
|
32
46
|
"incognito": "not_allowed",
|
|
33
47
|
"icons": {
|
|
34
48
|
"16": "icons/icon-16.png",
|
|
49
|
+
"32": "icons/icon-32.png",
|
|
35
50
|
"48": "icons/icon-48.png",
|
|
36
51
|
"128": "icons/icon-128.png"
|
|
37
52
|
}
|
|
@@ -1,218 +1,64 @@
|
|
|
1
|
-
/* Total Recall —
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
.
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
.
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
.field-group .hint {
|
|
68
|
-
font-weight: 400;
|
|
69
|
-
color: #585b70;
|
|
70
|
-
font-size: 11px;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
.field-group input[type="text"],
|
|
74
|
-
.field-group input[type="password"],
|
|
75
|
-
.field-group textarea {
|
|
76
|
-
width: 100%;
|
|
77
|
-
padding: 10px 14px;
|
|
78
|
-
background: #1e1e2e;
|
|
79
|
-
border: 1px solid #313244;
|
|
80
|
-
border-radius: 10px;
|
|
81
|
-
color: #cdd6f4;
|
|
82
|
-
font-size: 14px;
|
|
83
|
-
outline: none;
|
|
84
|
-
font-family: inherit;
|
|
85
|
-
transition: border-color 0.2s ease;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
.field-group input:focus,
|
|
89
|
-
.field-group textarea:focus {
|
|
90
|
-
border-color: #89b4fa;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
.field-group textarea {
|
|
94
|
-
resize: vertical;
|
|
95
|
-
min-height: 80px;
|
|
96
|
-
font-family: 'SF Mono', Menlo, Monaco, monospace;
|
|
97
|
-
font-size: 13px;
|
|
98
|
-
line-height: 1.6;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/* ---- Connection test ---- */
|
|
102
|
-
.connection-row {
|
|
103
|
-
display: flex;
|
|
104
|
-
align-items: center;
|
|
105
|
-
gap: 12px;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
.connection-status {
|
|
109
|
-
font-size: 13px;
|
|
110
|
-
font-weight: 600;
|
|
111
|
-
transition: color 0.3s ease;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
.connection-status.ok {
|
|
115
|
-
color: #a6e3a1;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
.connection-status.fail {
|
|
119
|
-
color: #f38ba8;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/* ---- Radio group ---- */
|
|
123
|
-
.radio-group {
|
|
124
|
-
display: flex;
|
|
125
|
-
flex-direction: column;
|
|
126
|
-
gap: 8px;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
.radio-label {
|
|
130
|
-
display: flex;
|
|
131
|
-
align-items: center;
|
|
132
|
-
gap: 10px;
|
|
133
|
-
padding: 10px 14px;
|
|
134
|
-
background: #1e1e2e;
|
|
135
|
-
border: 1px solid #313244;
|
|
136
|
-
border-radius: 10px;
|
|
137
|
-
cursor: pointer;
|
|
138
|
-
transition: border-color 0.2s ease;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
.radio-label:hover {
|
|
142
|
-
border-color: #45475a;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
.radio-label input[type="radio"] {
|
|
146
|
-
accent-color: #89b4fa;
|
|
147
|
-
width: 16px;
|
|
148
|
-
height: 16px;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
.radio-text {
|
|
152
|
-
font-size: 13px;
|
|
153
|
-
font-weight: 600;
|
|
154
|
-
color: #cdd6f4;
|
|
155
|
-
min-width: 60px;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
.radio-desc {
|
|
159
|
-
font-size: 12px;
|
|
160
|
-
color: #585b70;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/* ---- Buttons ---- */
|
|
164
|
-
.btn {
|
|
165
|
-
padding: 10px 20px;
|
|
166
|
-
font-size: 13px;
|
|
167
|
-
font-weight: 600;
|
|
168
|
-
border: 1px solid #313244;
|
|
169
|
-
border-radius: 10px;
|
|
170
|
-
cursor: pointer;
|
|
171
|
-
transition: background 0.2s ease, border-color 0.2s ease, transform 0.1s ease;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
.btn:active {
|
|
175
|
-
transform: scale(0.97);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
.btn.primary {
|
|
179
|
-
background: #89b4fa;
|
|
180
|
-
color: #11111b;
|
|
181
|
-
border-color: #89b4fa;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
.btn.primary:hover {
|
|
185
|
-
background: #74c7ec;
|
|
186
|
-
border-color: #74c7ec;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
.btn.secondary {
|
|
190
|
-
background: #1e1e2e;
|
|
191
|
-
color: #cdd6f4;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
.btn.secondary:hover {
|
|
195
|
-
background: #313244;
|
|
196
|
-
border-color: #89b4fa;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/* ---- Actions footer ---- */
|
|
200
|
-
.actions {
|
|
201
|
-
display: flex;
|
|
202
|
-
align-items: center;
|
|
203
|
-
gap: 14px;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
.save-status {
|
|
207
|
-
font-size: 13px;
|
|
208
|
-
font-weight: 600;
|
|
209
|
-
transition: opacity 0.3s ease;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
.save-status.saved {
|
|
213
|
-
color: #a6e3a1;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
.save-status.error {
|
|
217
|
-
color: #f38ba8;
|
|
218
|
-
}
|
|
1
|
+
/* Total Recall — extension options. Tokens come from ../lib/tokens.css. */
|
|
2
|
+
|
|
3
|
+
body { font-size: 14px; min-height: 100vh; }
|
|
4
|
+
|
|
5
|
+
.page { max-width: 680px; margin: 0 auto; padding: 48px 24px 64px; display: flex; flex-direction: column; gap: 18px; }
|
|
6
|
+
|
|
7
|
+
.hero { display: flex; align-items: center; gap: 16px; margin-bottom: 10px; }
|
|
8
|
+
.hero-mark { border-radius: 12px; filter: drop-shadow(0 8px 24px rgba(59, 130, 246, 0.25)); }
|
|
9
|
+
.hero h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.025em; }
|
|
10
|
+
.hero p { color: var(--text-secondary); font-size: 13.5px; margin-top: 2px; }
|
|
11
|
+
|
|
12
|
+
.card {
|
|
13
|
+
display: flex; flex-direction: column; gap: 18px; padding: 22px;
|
|
14
|
+
background: var(--glass); border: 1px solid var(--glass-border); border-radius: var(--radius-lg);
|
|
15
|
+
backdrop-filter: blur(20px) saturate(1.3); box-shadow: var(--shadow-md);
|
|
16
|
+
}
|
|
17
|
+
.card-head { display: flex; align-items: flex-start; gap: 12px; }
|
|
18
|
+
.card-head > div { flex: 1; }
|
|
19
|
+
.card-head h2 { font-size: 15px; font-weight: 700; letter-spacing: -0.015em; }
|
|
20
|
+
.card-head p { font-size: 12.5px; color: var(--text-secondary); margin-top: 2px; }
|
|
21
|
+
.card-icon { width: 34px; height: 34px; flex: none; display: grid; place-items: center; border-radius: 10px; color: var(--accent-hover); background: var(--accent-muted); border: 1px solid var(--border-accent); }
|
|
22
|
+
|
|
23
|
+
.field { display: flex; flex-direction: column; gap: 6px; }
|
|
24
|
+
.field label { font-size: 12.5px; font-weight: 600; color: var(--text-secondary); }
|
|
25
|
+
.muted { font-weight: 400; color: var(--text-tertiary); }
|
|
26
|
+
.input, .textarea { font-size: 13.5px; padding: 9px 12px; }
|
|
27
|
+
.mono { font-family: var(--font-mono); font-size: 12.5px; }
|
|
28
|
+
.input-row { display: flex; gap: 6px; }
|
|
29
|
+
.input-row .icon-btn { width: 38px; height: auto; border: 1px solid var(--border); }
|
|
30
|
+
.input-row .icon-btn[aria-pressed="true"] { color: var(--accent-hover); border-color: var(--border-accent); }
|
|
31
|
+
.help { font-size: 12px; color: var(--text-tertiary); line-height: 1.55; }
|
|
32
|
+
.help code { font-family: var(--font-mono); font-size: 11.5px; color: var(--text-secondary); background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 5px; padding: 1px 5px; }
|
|
33
|
+
.help a { display: inline-flex; align-items: center; gap: 3px; }
|
|
34
|
+
|
|
35
|
+
.actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; }
|
|
36
|
+
.btn { height: 34px; padding: 0 14px; font-size: 13px; }
|
|
37
|
+
.saved { font-size: 12px; font-weight: 600; color: var(--success); animation: rise 0.2s ease both; }
|
|
38
|
+
|
|
39
|
+
.result {
|
|
40
|
+
display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; padding: 12px 14px; border-radius: var(--radius-md);
|
|
41
|
+
font-size: 12.5px; background: var(--bg-secondary); border: 1px solid var(--border); animation: rise 0.2s ease both;
|
|
42
|
+
}
|
|
43
|
+
.result[hidden] { display: none; }
|
|
44
|
+
.result.ok { border-color: rgba(52, 211, 153, 0.25); background: var(--success-muted); }
|
|
45
|
+
.result.fail { border-color: rgba(248, 113, 113, 0.25); background: var(--error-muted); grid-template-columns: 1fr; color: var(--error); }
|
|
46
|
+
.result dt { color: var(--text-tertiary); }
|
|
47
|
+
.result dd { color: var(--text-primary); font-weight: 500; }
|
|
48
|
+
|
|
49
|
+
.setting { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
|
|
50
|
+
.setting.sub { margin-top: -6px; padding-left: 14px; border-left: 2px solid var(--border); }
|
|
51
|
+
.setting.sub.is-disabled { opacity: 0.5; }
|
|
52
|
+
.setting-title { font-size: 13.5px; font-weight: 600; margin-bottom: 2px; }
|
|
53
|
+
|
|
54
|
+
.shortcuts { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; }
|
|
55
|
+
.shortcuts > div { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 11px 14px; background: var(--bg-tertiary); }
|
|
56
|
+
.shortcuts > div + div { border-top: 1px solid var(--border); }
|
|
57
|
+
.shortcuts dt { font-size: 13px; }
|
|
58
|
+
.shortcuts dd { display: flex; gap: 4px; }
|
|
59
|
+
.shortcuts .unset { font-size: 12px; color: var(--text-tertiary); }
|
|
60
|
+
.kbd { min-width: 22px; height: 22px; font-size: 11.5px; }
|
|
61
|
+
.tips { display: flex; flex-direction: column; gap: 6px; font-size: 12.5px; color: var(--text-secondary); }
|
|
62
|
+
.tips > div { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
|
|
63
|
+
|
|
64
|
+
.foot { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-tertiary); padding: 4px 4px 0; }
|