moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
# Waggle Companion · Chrome MV3 extension
The browser-side hook for Waggle OS. Lets the user save any page or selection
to their workspace memory from anywhere on the web, without leaving the tab.
This implements **FR-1** from the 2026-05-28 addictiveness audit — closes the
"external trigger surface" rubric gap (dim 1) for the non-coder personas
whose real workflow lives in browser tabs (researcher, journalist, marketer,
writer, retired teacher).
## What it does (v0.1.0)
- **Popup** — shows connection status + the active workspace memory is saving to + two buttons (save selection / save page).
- **Right-click context menu** — "Save to Waggle memory" appears on any text selection.
- **Reuses existing sidecar endpoints** — `/api/browser-ext/session-token` for local token bootstrap, `/api/browser-ext/health` for status, and `/api/memory/frames` for ingest. No new ingest logic.
## How to load (developer mode, local install)
1. Start the Waggle sidecar with one of these env vars set so its CORS layer accepts the dev extension origin:
- **Quickest (dev only):** `WAGGLE_DEV_ALLOW_ANY_EXTENSION=1` — accepts any `chrome-extension://*` origin. Never set this in production.
- **Production-shaped:** `WAGGLE_BROWSER_EXT_IDS=<your-extension-id>` (comma-separated for multiple IDs). Pin once you have the loaded extension's ID from `chrome://extensions`.
2. Open `chrome://extensions` in Chrome (or Edge, or any Chromium browser).
3. Toggle **Developer mode** on (top right).
4. Click **Load unpacked** and pick this folder (`apps/browser-ext`).
5. Copy the extension ID shown on the card.
6. Restart the sidecar with `WAGGLE_BROWSER_EXT_IDS=<that-id>` for the production-shaped path, or skip this if you used the dev escape hatch in step 1.
7. Pin the extension to the toolbar.
8. Open the popup — you should see a green dot + "Connected" + the memory destination.
Without either env var set, the sidecar rejects Browser Companion token bootstrap and the popup shows setup recovery copy. The extension stores the sidecar session token in `chrome.storage.local.sessionToken` after a successful bootstrap and sends it as a bearer token on save/status calls.
## What's deliberately NOT in v0.1.0
- **Side panel chat** — the Chrome side panel for asking questions about the current page. Designed for v0.2; would call `/api/chat`.
- **One-time-code pairing UX** — v0.1.0 bootstraps the local session token for an env-allowlisted extension ID. A more explicit desktop Settings pairing flow with a one-time code is future hardening.
- **Cross-browser packaging** — manifest is MV3, works on Chrome/Edge/Brave. Firefox needs a parallel manifest shape.
- **Article extraction** — page text capture is `document.body.innerText` capped at 12k chars. Reader-mode style extraction belongs server-side.
- **Icons** — using browser default. Wire in real icons when we have the brand asset.
- **Build step** — vanilla JS, no bundler. Simpler MVP; if we add typescript/react for the side panel later, add Vite then.
## Files
| File | Role |
|---|---|
| `manifest.json` | MV3 manifest — permissions, action, content script, background |
| `popup.html` | Popup UI shell (dark Hive theme inline) |
| `popup.js` | Popup logic — health refresh, selection read, save dispatch |
| `content.js` | Per-page content script — extracts selection + body text on demand |
| `background.js` | Service worker — fetch wrapper to the Waggle sidecar |
## Sidecar contract
- `GET /api/browser-ext/session-token` -> `{ token }` for allowlisted extension origins / MV3 service-worker requests.
- `GET /api/browser-ext/health` -> `{ ok: true, version, activeWorkspaceId, activeWorkspace }` (defined in `packages/server/src/local/routes/browser-ext.ts`; `activeWorkspace` is legacy compatibility)
- `POST /api/memory/frames` — existing endpoint, body `{ content, source: 'import', importance: 'normal' | 'low' }`. Dedup runs server-side.
## Verification
After loading the unpacked extension:
1. Click the extension icon on any web page → status should read "Connected" with a green dot.
2. Select some text → "Save selection to memory" enables → click it → toast reads "Saved to Waggle memory ✓".
3. Open the Waggle desktop → Memory app → confirm the new frame appears with source `import`.
## Roadmap (post-MVP)
- v0.2 — side panel with chat about the current page (calls `/api/chat`).
- v0.3 — pre-load Waggle's "Ask about this page" agent on important pages (configurable).
- v0.4 — Firefox MV2 parallel manifest.
- v0.5 — explicit auth pairing UX (one-time code from desktop Settings).

View File

@@ -0,0 +1,141 @@
// Waggle Companion background service worker — routes messages from
// popup.js to the local Waggle sidecar at 127.0.0.1:3333.
//
// MV3 service workers are short-lived; we don't keep any state here
// beyond per-message handlers. The sidecar's session token (if any) is
// pulled from chrome.storage.local on every request.
const SIDECAR = 'http://127.0.0.1:3333';
async function readJson(response) {
try {
return await response.json();
} catch {
return null;
}
}
function authErrorMessage(status, body) {
const code = body?.code;
if (code === 'EXTENSION_NOT_ALLOWLISTED') {
return 'Browser Companion is not allowlisted. Add this extension ID to Waggle, restart Waggle, then try again.';
}
if (status === 401 && code === 'INVALID_TOKEN') {
return 'Browser Companion pairing expired. Reopen Waggle desktop, then try again.';
}
if (status === 401 && (code === 'MISSING_TOKEN' || !code)) {
return 'Browser Companion is not paired. Start Waggle desktop, then try again.';
}
return body?.error || `HTTP ${status}`;
}
async function requestSessionToken() {
const r = await fetch(`${SIDECAR}/api/browser-ext/session-token`, {
method: 'GET',
headers: {
Accept: 'application/json',
'X-Waggle-Extension-Id': chrome.runtime.id,
},
});
const data = await readJson(r);
if (!r.ok || !data?.token) {
return { ok: false, error: authErrorMessage(r.status, data) };
}
await chrome.storage.local.set({ sessionToken: data.token });
return { ok: true, token: data.token };
}
async function getAuthHeaders(options = {}) {
try {
const { sessionToken } = await chrome.storage.local.get(['sessionToken']);
if (sessionToken) return { headers: { Authorization: `Bearer ${sessionToken}` } };
if (!options.pair) return { headers: {} };
const paired = await requestSessionToken();
if (!paired.ok) return { headers: {}, error: paired.error };
return { headers: { Authorization: `Bearer ${paired.token}` } };
} catch (err) {
return { headers: {}, error: String(err) };
}
}
async function health() {
try {
const auth = await getAuthHeaders({ pair: true });
if (auth.error) return { ok: false, error: auth.error };
const r = await fetch(`${SIDECAR}/api/browser-ext/health`, {
method: 'GET',
headers: { Accept: 'application/json', ...auth.headers },
});
if (!r.ok) return { ok: false, error: authErrorMessage(r.status, await readJson(r)) };
return await r.json();
} catch (err) {
return { ok: false, error: String(err) };
}
}
async function saveMemory(payload) {
try {
const body = JSON.stringify({
content: payload.content,
source: payload.source || 'import',
importance: payload.importance || 'normal',
});
const auth = await getAuthHeaders({ pair: true });
if (auth.error) return { saved: false, error: auth.error };
let r = await fetch(`${SIDECAR}/api/memory/frames`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...auth.headers },
body,
});
if (r.status === 401) {
const paired = await requestSessionToken();
if (paired.ok) {
r = await fetch(`${SIDECAR}/api/memory/frames`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${paired.token}` },
body,
});
}
}
if (!r.ok) return { saved: false, error: authErrorMessage(r.status, await readJson(r)) };
const data = await r.json();
return {
saved: data?.saved ?? true,
duplicate: data?.duplicate ?? false,
frameId: data?.frameId,
};
} catch (err) {
return { saved: false, error: String(err) };
}
}
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
(async () => {
if (msg?.type === 'health') sendResponse(await health());
else if (msg?.type === 'save-memory') sendResponse(await saveMemory(msg));
else sendResponse({ error: 'unknown message type' });
})();
return true; // keep channel open for async sendResponse
});
// Context menu: right-click selection → "Save to Waggle memory"
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: 'waggle-save-selection',
title: 'Save to Waggle memory',
contexts: ['selection'],
});
});
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
if (info.menuItemId !== 'waggle-save-selection' || !info.selectionText) return;
const result = await saveMemory({
content: `Selection from ${tab?.title || tab?.url}\n\n${info.selectionText}`,
source: 'import',
importance: 'normal',
});
// Best-effort badge feedback (MV3 has no toast API in background).
await chrome.action.setBadgeText({ text: result.saved ? '✓' : '!' });
await chrome.action.setBadgeBackgroundColor({ color: result.saved ? '#10b981' : '#ef4444' });
setTimeout(() => chrome.action.setBadgeText({ text: '' }), 2500);
});

View File

@@ -0,0 +1,27 @@
// Waggle Companion content script — runs on every page, responds to
// "extract" messages from the popup with the current selection + the
// page's main text. Page-text extraction is intentionally simple
// (innerText of the body, trimmed) — the right place to do article
// extraction is server-side once the user opts in.
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.type !== 'extract') return;
try {
const selection = String(window.getSelection?.() || '').trim();
const bodyText = (document.body?.innerText || '').replace(/\s+\n/g, '\n').trim();
sendResponse({
selection,
page: {
url: location.href,
title: document.title || location.href,
// Cap at 12k chars so popup → background message-passing stays snappy.
// Server-side ingest pipeline can re-fetch the URL for the full body.
text: bodyText.slice(0, 12000),
},
});
} catch (err) {
sendResponse({ selection: '', page: null, error: String(err) });
}
// Returning true keeps the message channel open for the async sendResponse.
return true;
});

View File

@@ -0,0 +1,30 @@
{
"manifest_version": 3,
"name": "Waggle Companion",
"version": "0.1.0",
"description": "Save pages, selections, and questions to your Waggle workspace memory — Waggle gets smarter the more you use it.",
"permissions": [
"activeTab",
"storage",
"contextMenus"
],
"host_permissions": [
"http://127.0.0.1:3333/*",
"http://localhost:3333/*"
],
"action": {
"default_popup": "popup.html",
"default_title": "Waggle Companion"
},
"background": {
"service_worker": "background.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle"
}
]
}

106
apps/browser-ext/popup.html Normal file
View File

@@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Waggle Companion</title>
<style>
:root {
color-scheme: dark;
--bg: #08090c;
--fg: #f4f4f5;
--muted: #71717a;
--primary: #e5a000;
--primary-hover: #d18f00;
--border: #27272a;
--success: #10b981;
--danger: #ef4444;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
width: 320px;
background: var(--bg);
color: var(--fg);
font: 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
padding: 12px;
}
header {
display: flex; align-items: center; gap: 8px;
padding-bottom: 10px; border-bottom: 1px solid var(--border); margin-bottom: 10px;
}
.logo { width: 18px; height: 18px; background: var(--primary); border-radius: 4px; display: grid; place-items: center; font-weight: 700; color: var(--bg); font-size: 12px; }
h1 { font-size: 13px; font-weight: 600; margin: 0; flex: 1; }
.status {
display: inline-flex; align-items: center; gap: 5px;
font-size: 11px; color: var(--muted);
}
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
.dot.connected { background: var(--success); }
.dot.disconnected { background: var(--danger); }
.workspace { font-size: 11px; color: var(--muted); margin-bottom: 10px; }
.workspace strong { color: var(--fg); }
button {
width: 100%; padding: 8px 10px; margin-bottom: 6px;
background: #1a1a1f; color: var(--fg); border: 1px solid var(--border);
border-radius: 8px; font: inherit; cursor: pointer; text-align: left;
display: flex; align-items: center; gap: 8px;
transition: background 120ms, border-color 120ms, outline-color 120ms;
}
button:hover:not(:disabled) { background: #25252b; border-color: var(--primary); }
button:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
border-color: var(--primary);
}
button:disabled {
background: #101014;
color: var(--muted);
border-color: #1f1f26;
opacity: 1;
cursor: not-allowed;
}
button.primary { background: var(--primary); color: var(--bg); border-color: var(--primary); font-weight: 600; }
button.primary:hover:not(:disabled) { background: var(--primary-hover); border-color: var(--primary-hover); }
button.primary:disabled {
background: #15120a;
color: #9a8253;
border-color: #3a2a0a;
}
.icon { font-size: 14px; line-height: 1; }
#toast {
margin-top: 8px; padding: 6px 8px; border-radius: 6px; font-size: 11px;
background: #1a1a1f; border: 1px solid var(--border); color: var(--muted);
min-height: 16px;
}
#toast.ok { color: var(--success); border-color: var(--success); }
#toast.err { color: var(--danger); border-color: var(--danger); }
footer { margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--border); font-size: 10px; color: var(--muted); text-align: center; }
a { color: var(--primary); text-decoration: none; }
</style>
</head>
<body>
<header>
<span class="logo">W</span>
<h1>Waggle Companion</h1>
<span class="status"><span class="dot" id="dot"></span><span id="status-text"></span></span>
</header>
<div class="workspace">Memory destination: <strong id="workspace-name"></strong></div>
<button class="primary" id="save-selection" disabled>
<span class="icon">💾</span><span>Save selection to memory</span>
</button>
<button id="save-page">
<span class="icon">📄</span><span>Save whole page</span>
</button>
<button id="open-waggle">
<span class="icon">🐝</span><span>Open Waggle desktop</span>
</button>
<div id="toast" role="status" aria-live="polite"></div>
<footer>v0.1.0 · Requires Waggle running on 127.0.0.1:3333</footer>
<script src="popup.js"></script>
</body>
</html>

120
apps/browser-ext/popup.js Normal file
View File

@@ -0,0 +1,120 @@
// Waggle Companion popup — talks to background.js via chrome.runtime.sendMessage,
// background talks to the local Waggle sidecar at 127.0.0.1:3333. The popup
// itself never makes network requests so we don't pay the CORS preflight tax
// from an extension origin.
const $ = (id) => document.getElementById(id);
const dot = $('dot');
const statusText = $('status-text');
const workspaceNameEl = $('workspace-name');
const btnSelection = $('save-selection');
const btnPage = $('save-page');
const btnOpen = $('open-waggle');
const toast = $('toast');
let cachedSelection = '';
let cachedPageMeta = null;
let toastTimer = null;
function showToast(msg, kind = '', options = {}) {
if (toastTimer) clearTimeout(toastTimer);
toast.textContent = msg;
toast.className = kind;
if (!options.sticky && (kind === 'ok' || kind === 'err')) {
toastTimer = setTimeout(() => {
if (toast.textContent === msg) {
toast.textContent = '';
toast.className = '';
}
}, 3500);
}
}
function isSetupError(msg) {
return /allowlisted|paired|pairing/i.test(msg);
}
function formatMemoryDestination(reply) {
const workspaceName = typeof reply?.activeWorkspaceName === 'string'
? reply.activeWorkspaceName.trim()
: '';
const workspaceId = typeof reply?.activeWorkspaceId === 'string'
? reply.activeWorkspaceId.trim()
: typeof reply?.activeWorkspace === 'string'
? reply.activeWorkspace.trim()
: '';
if (workspaceName) return workspaceName;
if (workspaceId && workspaceId !== 'local-default' && workspaceId !== 'default-workspace') {
return `Workspace id: ${workspaceId}`;
}
return 'Personal memory';
}
async function refreshHealth() {
try {
const reply = await chrome.runtime.sendMessage({ type: 'health' });
if (reply?.ok) {
dot.className = 'dot connected';
statusText.textContent = 'Connected';
// textContent (not innerHTML) — workspace names are user-controlled
// and could otherwise be XSS sinks in the extension context.
workspaceNameEl.textContent = formatMemoryDestination(reply);
} else {
throw new Error(reply?.error || 'No response');
}
} catch (err) {
dot.className = 'dot disconnected';
statusText.textContent = 'Not connected';
workspaceNameEl.textContent = 'Unavailable';
const msg = err?.message || 'Start Waggle desktop on this machine, then re-open this popup.';
showToast(msg, 'err', { sticky: true });
}
}
async function readActiveTab() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) return null;
try {
const res = await chrome.tabs.sendMessage(tab.id, { type: 'extract' });
cachedSelection = res?.selection ?? '';
cachedPageMeta = res?.page ?? null;
btnSelection.disabled = !cachedSelection;
} catch {
// Content script unavailable (e.g. on chrome:// pages) — disable buttons gracefully.
btnSelection.disabled = true;
btnPage.disabled = true;
showToast('Waggle cannot read this browser page. Open a normal webpage, then try again.', 'err', { sticky: true });
}
}
async function save(kind) {
const isSelection = kind === 'selection';
const text = isSelection ? cachedSelection : (cachedPageMeta?.text || '');
if (!text) { showToast('Nothing to save.', 'err'); return; }
const url = cachedPageMeta?.url || '';
const title = cachedPageMeta?.title || '';
const prefix = isSelection ? 'Selection from' : 'Saved page';
const content = `${prefix} ${title || url}\n\n${text}`.slice(0, 16000);
showToast(`Saving ${isSelection ? 'selection' : 'page'}`);
const reply = await chrome.runtime.sendMessage({
type: 'save-memory',
content,
source: 'import',
importance: isSelection ? 'normal' : 'low',
url, title,
});
if (reply?.saved) {
showToast(reply.duplicate ? 'Already in memory.' : 'Saved to Waggle memory ✓', 'ok');
} else {
const msg = reply?.error || 'Save failed.';
showToast(msg, 'err', { sticky: isSetupError(msg) });
}
}
btnSelection.addEventListener('click', () => save('selection'));
btnPage.addEventListener('click', () => save('page'));
btnOpen.addEventListener('click', () => chrome.tabs.create({ url: 'http://127.0.0.1:3333' }));
refreshHealth();
readActiveTab();